feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가

- 입고관리: 상세/목록 UI 개선, actions 로직 강화
- 재고현황: 상세/목록 개선, StockAuditModal 신규 추가
- 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화
- 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가
- 견적: QuoteTransactionModal 기능 개선
- 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선
- UniversalListPage: 템플릿 기능 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-28 21:15:25 +09:00
parent 79b39a3ef6
commit 1f640622e0
23 changed files with 4683 additions and 1946 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { use } from 'react';
import { useSearchParams } from 'next/navigation';
import { ReceivingDetail } from '@/components/material/ReceivingManagement';
interface Props {
@@ -9,5 +10,8 @@ interface Props {
export default function ReceivingDetailPage({ params }: Props) {
const { id } = use(params);
return <ReceivingDetail id={id} />;
const searchParams = useSearchParams();
const mode = (searchParams.get('mode') as 'view' | 'edit' | 'new') || 'view';
return <ReceivingDetail id={id} mode={mode} />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from 'react';
import { AlertCircle } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Document, DocumentItem } from '../types';
import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
@@ -20,12 +20,20 @@ import {
JointbarInspectionDocument,
QualityDocumentUploader,
} from './documents';
import type { ImportInspectionTemplate } from './documents/ImportInspectionDocument';
// 검사 템플릿 API
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
interface InspectionModalV2Props {
isOpen: boolean;
onClose: () => void;
document: Document | null;
documentItem: DocumentItem | null;
// 수입검사 템플릿 로드용 추가 props
itemName?: string;
specification?: string;
supplier?: string;
}
// 문서 타입별 정보
@@ -318,17 +326,90 @@ const WorkLogDocument = () => {
);
};
// 로딩 컴포넌트
const LoadingDocument = () => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<p className="text-gray-600 text-sm"> 릿 ...</p>
</div>
);
// 에러 컴포넌트
const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-gray-800 font-medium mb-2">릿 </p>
<p className="text-gray-500 text-sm mb-4">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
</button>
)}
</div>
);
/**
* InspectionModal V2
* - DocumentViewer 시스템 사용
* - 기존 문서 렌더링 로직 유지
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
*/
export const InspectionModalV2 = ({
isOpen,
onClose,
document: doc,
documentItem,
itemName,
specification,
supplier,
}: InspectionModalV2Props) => {
// 수입검사 템플릿 상태
const [importTemplate, setImportTemplate] = useState<ImportInspectionTemplate | null>(null);
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
// 수입검사 템플릿 로드 (모달 열릴 때)
useEffect(() => {
if (isOpen && doc?.type === 'import' && itemName && specification) {
loadInspectionTemplate();
}
// 모달 닫힐 때 상태 초기화
if (!isOpen) {
setImportTemplate(null);
setTemplateError(null);
}
}, [isOpen, doc?.type, itemName, specification]);
const loadInspectionTemplate = async () => {
if (!itemName || !specification) return;
setIsLoadingTemplate(true);
setTemplateError(null);
try {
const result = await getInspectionTemplate({
itemName,
specification,
lotNo: documentItem?.code,
supplier,
});
if (result.success && result.data) {
// API 응답을 ImportInspectionTemplate 형식으로 변환
setImportTemplate(result.data as ImportInspectionTemplate);
} else {
setTemplateError(result.error || '템플릿을 불러올 수 없습니다.');
}
} catch (error) {
console.error('[InspectionModalV2] loadInspectionTemplate error:', error);
setTemplateError('템플릿 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingTemplate(false);
}
};
if (!doc) return null;
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
@@ -362,6 +443,20 @@ export const InspectionModalV2 = ({
}
};
// 수입검사 문서 렌더링 (Lazy Loading)
const renderImportInspectionDocument = () => {
if (isLoadingTemplate) {
return <LoadingDocument />;
}
if (templateError) {
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
}
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
return <ImportInspectionDocument template={importTemplate || undefined} />;
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
@@ -374,7 +469,7 @@ export const InspectionModalV2 = ({
case 'shipping':
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
case 'import':
return <ImportInspectionDocument />;
return renderImportInspectionDocument();
case 'product':
return <ProductInspectionDocument />;
case 'report':

View File

@@ -67,6 +67,7 @@ import {
revertProductionOrder,
revertOrderConfirmation,
deleteOrder,
createProductionOrder,
type Order,
type OrderStatus,
} from "@/components/orders";
@@ -151,6 +152,14 @@ export default function OrderDetailPage() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 생산지시 생성 모달 상태
const [isProductionDialogOpen, setIsProductionDialogOpen] = useState(false);
const [isCreatingProduction, setIsCreatingProduction] = useState(false);
const [productionPriority, setProductionPriority] = useState<"normal" | "high" | "urgent">("normal");
const [productionMemo, setProductionMemo] = useState("");
// 생산지시 완료 알림 모달 상태
const [isProductionSuccessDialogOpen, setIsProductionSuccessDialogOpen] = useState(false);
// 취소 폼 상태
const [cancelReason, setCancelReason] = useState("");
const [cancelDetail, setCancelDetail] = useState("");
@@ -195,8 +204,42 @@ export default function OrderDetailPage() {
};
const handleProductionOrder = () => {
// 생산지시 생성 페이지로 이동
router.push(`/sales/order-management-sales/${orderId}/production-order`);
// 생산지시 생성 모달 열기
setProductionPriority("normal");
setProductionMemo("");
setIsProductionDialogOpen(true);
};
// 생산지시 확정 처리
const handleProductionOrderSubmit = async () => {
if (order) {
setIsCreatingProduction(true);
try {
const result = await createProductionOrder(order.id, {
priority: productionPriority,
memo: productionMemo || undefined,
});
if (result.success && result.data) {
// 주문 상태 업데이트
setOrder({ ...order, status: "production_ordered" as OrderStatus });
setIsProductionDialogOpen(false);
// 성공 알림 모달 표시
setIsProductionSuccessDialogOpen(true);
} else {
toast.error(result.error || "생산지시 생성에 실패했습니다.");
}
} catch (error) {
console.error("Error creating production order:", error);
toast.error("생산지시 생성 중 오류가 발생했습니다.");
} finally {
setIsCreatingProduction(false);
}
}
};
// 생산지시 완료 알림 확인
const handleProductionSuccessConfirm = () => {
setIsProductionSuccessDialogOpen(false);
};
const handleViewProductionOrder = () => {
@@ -419,53 +462,6 @@ export default function OrderDetailPage() {
return (
<div className="space-y-6">
{/* 수주 정보 헤더 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{order.lotNumber}
</code>
{getOrderStatusBadge(order.status)}
</div>
<div className="text-sm text-muted-foreground">
: {order.orderDate}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 문서 버튼들 */}
<div className="flex items-center gap-2 pt-4 border-t">
<span className="text-sm text-muted-foreground mr-2">:</span>
<Button
variant="outline"
size="sm"
onClick={() => openDocumentModal("contract")}
>
<FileCheck className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openDocumentModal("transaction")}
>
<FileSpreadsheet className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openDocumentModal("purchaseOrder")}
>
<ClipboardList className="h-4 w-4 mr-1" />
</Button>
</div>
</CardContent>
</Card>
{/* 기본 정보 */}
<Card>
<CardHeader>
@@ -473,10 +469,16 @@ export default function OrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<InfoItem label="발주처" value={order.client} />
<InfoItem label="로트번호" value={order.lotNumber} />
<InfoItem label="접수일" value={order.orderDate} />
<InfoItem label="수주처" value={order.client} />
<InfoItem label="현장명" value={order.siteName} />
<InfoItem label="담당자" value={order.manager} />
<InfoItem label="연락처" value={order.contact} />
<div>
<p className="text-sm text-muted-foreground"></p>
<div className="mt-1">{getOrderStatusBadge(order.status)}</div>
</div>
</div>
</CardContent>
</Card>
@@ -488,14 +490,16 @@ export default function OrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<InfoItem label="수주일자" value={order.orderDate} />
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
<InfoItem label="배송방식" value={order.deliveryMethod} />
<InfoItem label="운임비용" value={order.shippingCost} />
<InfoItem label="수신(반장/업체)" value={order.receiver} />
<InfoItem label="수신" value={order.receiver} />
<InfoItem label="수신처 연락처" value={order.receiverContact} />
<InfoItem label="수신처 주소" value={order.address} />
<div className="md:col-span-2">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{order.address || "-"}</p>
</div>
</div>
</CardContent>
</Card>
@@ -512,11 +516,11 @@ export default function OrderDetailPage() {
</Card>
)}
{/* 제품 내역 (트리 구조) */}
{/* 제품내용 (아코디언) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg"> </CardTitle>
<CardTitle className="text-lg"></CardTitle>
{order.products && order.products.length > 0 && (
<div className="flex items-center gap-2">
<Button
@@ -570,26 +574,14 @@ export default function OrderDetailPage() {
<span className="font-medium">{product.productName}</span>
{product.openWidth && product.openHeight && (
<span className="ml-2 text-sm text-gray-500">
({product.openWidth} × {product.openHeight} mm)
({product.openWidth} × {product.openHeight})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{product.floor && (
<span className="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
{product.floor}
</span>
)}
{product.code && (
<span className="text-xs font-medium text-gray-600 bg-gray-100 px-2 py-0.5 rounded">
{product.code}
</span>
)}
<span className="text-xs text-gray-500 ml-2">
{productItems.length}
</span>
</div>
<span className="text-sm text-gray-500">
{productItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
@@ -600,34 +592,20 @@ export default function OrderDetailPage() {
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode || "-"}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice || 0)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount || 0)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -642,114 +620,76 @@ export default function OrderDetailPage() {
</div>
);
})}
</div>
) : null}
</CardContent>
</Card>
{/* 매칭되지 않은 부품 (기타 부품) */}
{(() => {
const unmatchedItems = getUnmatchedItems();
if (unmatchedItems.length === 0) return null;
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="p-4 bg-gray-50 flex items-center gap-3">
<Package className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-600"> </span>
<span className="text-xs text-gray-500 ml-auto">
{unmatchedItems.length}
</span>
</div>
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{unmatchedItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode || "-"}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice || 0)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount || 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 기타부품 (아코디언) */}
{(() => {
const unmatchedItems = getUnmatchedItems();
if (unmatchedItems.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleProduct("other-parts")}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{expandedProducts.has("other-parts") ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-600"></span>
</div>
);
})()}
</div>
) : (
/* products가 없는 경우: 기존 부품 테이블 표시 */
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{order.items && order.items.length > 0 ? (
order.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode || "-"}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice || 0)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount || 0)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="text-center text-gray-400 py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
<span className="text-sm text-gray-500">
{unmatchedItems.length}
</span>
</button>
{expandedProducts.has("other-parts") && (
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{unmatchedItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
);
})()}
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
{/* 합계 */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">
@@ -760,7 +700,7 @@ export default function OrderDetailPage() {
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">{Number.isInteger(order.discountRate || 0) ? (order.discountRate || 0) : Math.round(order.discountRate || 0)}%</span>
</div>
<div className="flex items-center gap-4 text-lg font-semibold">
<div className="flex items-center gap-4 text-lg font-semibold border-t pt-2 mt-2">
<span>:</span>
<span className="w-32 text-right text-green-600">
{formatAmount(order.totalAmount || 0)}
@@ -771,79 +711,84 @@ export default function OrderDetailPage() {
</Card>
</div>
);
}, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems, openDocumentModal]);
}, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems]);
// 견적 수정 핸들러
const handleEditQuote = () => {
if (order?.quoteId) {
router.push(`/sales/quotes/${order.quoteId}?mode=edit`);
}
};
// 수주서 보기 핸들러
const handleViewOrderDocument = () => {
openDocumentModal("salesOrder");
};
// 커스텀 헤더 액션 (상태별 버튼)
const renderHeaderActions = useCallback(() => {
if (!order) return null;
// 상태별 버튼 표시 여부
const showEditQuoteButton = !!order.quoteId; // 연결된 견적이 있을 때
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
const showConfirmButton = order.status === "order_registered";
const showProductionCreateButton = order.status === "order_confirmed";
const showProductionViewButton = false;
const showRevertButton = order.status === "production_ordered";
const showRevertConfirmButton = order.status === "order_confirmed";
const showCancelButton =
order.status !== "shipped" &&
order.status !== "cancelled" &&
order.status !== "production_ordered";
// 삭제 버튼은 수주등록 또는 취소 상태에서 표시
const showDeleteButton = order.status === "order_registered" || order.status === "cancelled";
return (
<div className="flex items-center gap-2 flex-wrap">
{showEditButton && (
<Button variant="outline" onClick={handleEdit}>
{/* 견적 수정 */}
{showEditQuoteButton && (
<Button variant="outline" onClick={handleEditQuote}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
{/* 수주서 보기 */}
<Button variant="outline" onClick={handleViewOrderDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
{/* 수주 확정 */}
{showConfirmButton && (
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionCreateButton && (
<Button onClick={handleProductionOrder}>
<Factory className="h-4 w-4 mr-2" />
</Button>
)}
{showProductionViewButton && (
<Button onClick={handleViewProductionOrder}>
<Eye className="h-4 w-4 mr-2" />
</Button>
)}
{showRevertButton && (
<Button variant="outline" onClick={handleRevertProduction} className="border-amber-200 text-amber-600 hover:border-amber-300 hover:bg-amber-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{/* 수주확정 되돌리기 */}
{showRevertConfirmButton && (
<Button variant="outline" onClick={handleRevertConfirmation} className="border-slate-300 text-slate-600 hover:border-slate-400 hover:bg-slate-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{showCancelButton && (
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
<XCircle className="h-4 w-4 mr-2" />
{/* 생산지시 생성 */}
{showProductionCreateButton && (
<Button onClick={handleProductionOrder}>
<Factory className="h-4 w-4 mr-2" />
</Button>
)}
{showDeleteButton && (
<Button variant="outline" onClick={handleDelete} className="border-red-200 text-red-600 hover:border-red-300 hover:bg-red-50">
<Trash2 className="h-4 w-4 mr-2" />
{/* 생산지시 되돌리기 */}
{showRevertButton && (
<Button variant="outline" onClick={handleRevertProduction} className="border-amber-200 text-amber-600 hover:border-amber-300 hover:bg-amber-50">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
)}
{/* 수정 */}
{showEditButton && (
<Button variant="outline" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
</div>
);
}, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel, handleDelete]);
}, [order, handleEditQuote, handleViewOrderDocument, handleConfirmOrder, handleRevertConfirmation, handleProductionOrder, handleRevertProduction, handleEdit]);
// V2 패턴: ?mode=edit일 때 수정 컴포넌트 렌더링
if (isEditMode) {
@@ -886,6 +831,12 @@ export default function OrderDetailPage() {
discountRate: order.discountRate,
totalAmount: order.totalAmount,
remarks: order.remarks,
// 수주서 전용 필드
documentNumber: order.lotNumber,
certificationNumber: order.lotNumber,
recipientName: order.receiver,
recipientContact: order.receiverContact,
shutterCount: order.products?.length || 0,
}}
/>
)}
@@ -1264,6 +1215,102 @@ export default function OrderDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 생산지시 생성 모달 */}
<Dialog open={isProductionDialogOpen} onOpenChange={setIsProductionDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 우선순위 */}
<div className="space-y-3">
<Label className="text-base font-medium"></Label>
<div className="flex gap-2">
<Button
type="button"
variant={productionPriority === "urgent" ? "default" : "outline"}
className={productionPriority === "urgent" ? "bg-gray-900 hover:bg-gray-800" : ""}
onClick={() => setProductionPriority("urgent")}
>
</Button>
<Button
type="button"
variant={productionPriority === "high" ? "default" : "outline"}
className={productionPriority === "high" ? "bg-gray-900 hover:bg-gray-800" : ""}
onClick={() => setProductionPriority("high")}
>
</Button>
<Button
type="button"
variant={productionPriority === "normal" ? "default" : "outline"}
className={productionPriority === "normal" ? "bg-orange-500 hover:bg-orange-600" : ""}
onClick={() => setProductionPriority("normal")}
>
</Button>
</div>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="productionMemo"></Label>
<Textarea
id="productionMemo"
placeholder="비고 사항을 입력하세요"
value={productionMemo}
onChange={(e) => setProductionMemo(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsProductionDialogOpen(false)}
className="bg-gray-900 text-white hover:bg-gray-800"
>
</Button>
<Button
onClick={handleProductionOrderSubmit}
className="bg-gray-900 hover:bg-gray-800"
disabled={isCreatingProduction}
>
{isCreatingProduction ? "처리 중..." : "생산지시 확정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 생산지시 완료 알림 모달 */}
<Dialog open={isProductionSuccessDialogOpen} onOpenChange={setIsProductionSuccessDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-center"></DialogTitle>
</DialogHeader>
<div className="py-6 text-center space-y-2">
<p className="font-medium text-lg"> .</p>
<p className="text-sm text-muted-foreground">
&gt;
</p>
</div>
<DialogFooter className="justify-center">
<Button
onClick={handleProductionSuccessConfirm}
className="bg-gray-900 hover:bg-gray-800 min-w-[120px]"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</>

View File

@@ -5,7 +5,8 @@
*
* 수주 관리 페이지
* - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기
* - 필터 탭: 전체, 수주등록, 수주확정, 생산지시완료, 미수
* - 상태 필터: 셀렉트박스 (전체, 수주등록, N자수정, 수주확정, 생산지시완료)
* - 날짜 범위 필터: 달력
* - 완전한 반응형 지원
* - API 연동 완료 (2025-01-08)
*/
@@ -31,8 +32,8 @@ import { BadgeSm } from "@/components/atoms/BadgeSm";
import {
UniversalListPage,
type UniversalListConfig,
type TabOption,
type TableColumn,
type FilterFieldConfig,
} from "@/components/templates/UniversalListPage";
import { toast } from "sonner";
import {
@@ -126,11 +127,34 @@ function OrderListContent() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all");
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 날짜 범위 필터 상태
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
// 필터 상태
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
status: 'all',
});
// 상태 필터 설정 (filterConfig)
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'order_registered', label: '수주등록' },
{ value: 'order_confirmed', label: '수주확정' },
{ value: 'production_ordered', label: '생산지시완료' },
],
allOptionLabel: '전체',
},
];
// 취소 확인 다이얼로그 state
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [cancelTargetId, setCancelTargetId] = useState<string | null>(null);
@@ -187,19 +211,14 @@ function OrderListContent() {
const matchesSearch =
!searchTerm ||
order.lotNumber.toLowerCase().includes(searchLower) ||
order.quoteNumber.toLowerCase().includes(searchLower) ||
order.client.toLowerCase().includes(searchLower) ||
order.siteName.toLowerCase().includes(searchLower);
// 상태 필터 (filterValues 사용)
const statusFilter = filterValues.status as string;
let matchesFilter = true;
if (filterType === "registered") {
matchesFilter = order.status === "order_registered";
} else if (filterType === "confirmed") {
matchesFilter = order.status === "order_confirmed";
} else if (filterType === "production_ordered") {
matchesFilter = order.status === "production_ordered";
} else if (filterType === "receivable") {
matchesFilter = order.hasReceivable === true;
if (statusFilter && statusFilter !== "all") {
matchesFilter = order.status === statusFilter;
}
return matchesSearch && matchesFilter;
@@ -249,10 +268,10 @@ function OrderListContent() {
};
}, [mobileDisplayCount, filteredOrders.length]);
// 탭이나 검색어 변경 시 모바일 표시 개수 초기화
// 필터나 검색어 변경 시 모바일 표시 개수 초기화
useEffect(() => {
setMobileDisplayCount(20);
}, [searchTerm, filterType]);
}, [searchTerm, filterValues]);
// 통계 계산 (API stats 우선 사용, 없으면 로컬 계산)
const now = new Date();
@@ -453,54 +472,26 @@ function OrderListContent() {
});
}, []);
// 탭 구성
const tabs: TabOption[] = [
{
value: "all",
label: "전체",
count: orders.length,
color: "blue",
},
{
value: "registered",
label: "수주등록",
count: orders.filter((o) => o.status === "order_registered").length,
color: "gray",
},
{
value: "confirmed",
label: "수주확정",
count: orders.filter((o) => o.status === "order_confirmed").length,
color: "gray",
},
{
value: "production_ordered",
label: "생산지시완료",
count: orders.filter((o) => o.status === "production_ordered").length,
color: "blue",
},
{
value: "receivable",
label: "미수",
count: orders.filter((o) => o.hasReceivable).length,
color: "red",
},
];
// 테이블 컬럼 정의
// 테이블 컬럼 정의 (16개: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
const tableColumns: TableColumn[] = [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "lotNumber", label: "로트번호", className: "px-4" },
{ key: "quoteNumber", label: "견적번호", className: "px-4" },
{ key: "client", label: "발주처", className: "px-4" },
{ key: "siteName", label: "현장명", className: "px-4" },
{ key: "status", label: "상태", className: "px-4" },
{ key: "expectedShipDate", label: "출고예정일", className: "px-4" },
{ key: "deliveryMethod", label: "배송방식", className: "px-4" },
{ key: "actions", label: "작업", className: "px-4" },
{ key: "rowNumber", label: "번호", className: "px-2 text-center" },
{ key: "lotNumber", label: "로트번호", className: "px-2" },
{ key: "siteName", label: "현장명", className: "px-2" },
{ key: "expectedShipDate", label: "출고예정일", className: "px-2" },
{ key: "orderDate", label: "접수일", className: "px-2" },
{ key: "client", label: "수주처", className: "px-2" },
{ key: "productName", label: "제품명", className: "px-2" },
{ key: "receiver", label: "수신자", className: "px-2" },
{ key: "receiverAddress", label: "수신주소", className: "px-2" },
{ key: "receiverPlace", label: "수신처", className: "px-2" },
{ key: "deliveryMethod", label: "배송", className: "px-2" },
{ key: "manager", label: "담당자", className: "px-2" },
{ key: "frameCount", label: "틀수", className: "px-2 text-center" },
{ key: "status", label: "상태", className: "px-2" },
{ key: "remarks", label: "비고", className: "px-2" },
];
// 테이블 행 렌더링
// 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
const renderTableRow = (
order: Order,
index: number,
@@ -508,7 +499,6 @@ function OrderListContent() {
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
const itemId = order.id;
return (
<TableRow
@@ -522,52 +512,25 @@ function OrderListContent() {
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell>{globalIndex}</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
{order.lotNumber}
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
{order.quoteNumber}
</code>
</TableCell>
<TableCell>{order.client}</TableCell>
<TableCell>{order.siteName}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell>{order.siteName || "-"}</TableCell>
<TableCell>{order.expectedShipDate || "-"}</TableCell>
<TableCell>{order.orderDate || "-"}</TableCell>
<TableCell>{order.client || "-"}</TableCell>
<TableCell>{(order as any).productName || "-"}</TableCell>
<TableCell>{(order as any).receiver || "-"}</TableCell>
<TableCell className="max-w-[150px] truncate">{(order as any).receiverAddress || "-"}</TableCell>
<TableCell>{(order as any).receiverPlace || "-"}</TableCell>
<TableCell>{order.deliveryMethodLabel || "-"}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(order)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(order)}
>
<Edit className="h-4 w-4" />
</Button>
{/* 삭제 버튼 - shipped, cancelled 제외 모든 상태에서 표시 */}
{order.status !== "shipped" && order.status !== "cancelled" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(order.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
)}
</TableCell>
<TableCell>{(order as any).manager || "-"}</TableCell>
<TableCell className="text-center">{(order as any).frameCount || "-"}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell className="max-w-[100px] truncate">{(order as any).remarks || "-"}</TableCell>
</TableRow>
);
};
@@ -697,12 +660,25 @@ function OrderListContent() {
columns: tableColumns,
tabs: tabs,
defaultTab: filterType,
// 탭 제거 - 상태 필터는 headerActions의 셀렉트박스로 대체
// tabs: undefined,
// defaultTab: undefined,
computeStats: () => stats,
searchPlaceholder: "로트번호, 견적번호, 주처, 현장명 검색...",
searchPlaceholder: "로트번호, 현장명, 주처 검색...",
// 날짜 범위 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
presetsPosition: 'inline',
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: "orderDate", // 접수일 기준 필터링
},
itemsPerPage: itemsPerPage,
@@ -718,17 +694,21 @@ function OrderListContent() {
);
},
tabFilter: (order, activeTab) => {
if (activeTab === "all") return true;
if (activeTab === "registered") return order.status === "order_registered";
if (activeTab === "confirmed") return order.status === "order_confirmed";
if (activeTab === "production_ordered") return order.status === "production_ordered";
if (activeTab === "receivable") return order.hasReceivable === true;
return true;
// 커스텀 필터 함수 (filterConfig 사용)
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
const statusVal = fv.status as string;
// 상태 필터
if (statusVal && statusVal !== 'all' && item.status !== statusVal) {
return false;
}
return true;
});
},
headerActions: () => (
<div className="flex gap-2 ml-auto">
<div className="flex items-center gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
<Bell className="w-4 h-4 mr-2" />
@@ -740,6 +720,11 @@ function OrderListContent() {
</div>
),
// 필터 설정 (filterConfig 사용 - PC 인라인, 모바일 바텀시트)
filterConfig,
initialFilters: filterValues,
filterTitle: '수주 필터',
renderTableRow: renderTableRow,
renderMobileCard: renderMobileCard,
@@ -807,8 +792,8 @@ function OrderListContent() {
selectedItems,
onSelectionChange: setSelectedItems,
}}
onTabChange={(value) => {
setFilterType(value);
onFilterChange={(newFilters) => {
setFilterValues(newFilters);
setCurrentPage(1);
}}
onSearchChange={setSearchTerm}