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}

View File

@@ -1,54 +1,94 @@
'use client';
/**
* 입고 상세 페이지
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
* 입고 상세/등록/수정 페이지
* 기획서 2026-01-28 기준 마이그레이션
*
* 상태에 따라 다른 UI 표시:
* - 검사대기: 입고증, 목록, 검사등록 버튼
* - 배송중/발주완료: 목록, 입고처리 버튼 (입고증 없음)
* - 입고대기: 목록 버튼만 (입고증 없음)
* - 입고완료: 입고증, 목록 버튼
* mode 패턴:
* - view: 상세 조회 (읽기 전용)
* - edit: 수정 모드
* - new: 신규 등록 모드
*
* 섹션:
* 1. 기본 정보 - 로트번호, 품목코드, 품목명, 규격, 단위, 발주처, 입고수량, 입고일, 작성자, 상태, 비고
* 2. 수입검사 정보 - 검사일, 검사결과, 업체 제공 성적서 자료
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, ClipboardCheck, Download } from 'lucide-react';
import { Upload, FileText } from 'lucide-react';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { receivingConfig } from './receivingConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { getReceivingById, processReceiving } from './actions';
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
import type { ReceivingDetail as ReceivingDetailType, ReceivingProcessFormData } from './types';
import { ReceivingProcessDialog } from './ReceivingProcessDialog';
import { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
import { SuccessDialog } from './SuccessDialog';
import { getReceivingById, createReceiving, updateReceiving } from './actions';
import {
RECEIVING_STATUS_OPTIONS,
type ReceivingDetail as ReceivingDetailType,
type ReceivingStatus,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
interface Props {
id: string;
mode?: 'view' | 'edit' | 'new';
}
export function ReceivingDetail({ id }: Props) {
// 초기 폼 데이터
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
lotNo: '',
itemCode: '',
itemName: '',
specification: '',
unit: 'EA',
supplier: '',
receivingQty: undefined,
receivingDate: '',
createdBy: '',
status: 'receiving_pending',
remark: '',
inspectionDate: '',
inspectionResult: '',
certificateFile: undefined,
};
export function ReceivingDetail({ id, mode = 'view' }: Props) {
const router = useRouter();
const [isReceivingProcessDialogOpen, setIsReceivingProcessDialogOpen] = useState(false);
const [isReceiptDialogOpen, setIsReceiptDialogOpen] = useState(false);
const [successDialog, setSuccessDialog] = useState<{
open: boolean;
type: 'inspection' | 'receiving';
lotNo?: string;
}>({ open: false, type: 'receiving' });
const isNewMode = mode === 'new' || id === 'new';
const isEditMode = mode === 'edit';
const isViewMode = mode === 'view' && !isNewMode;
// API 데이터 상태
const [detail, setDetail] = useState<ReceivingDetailType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 폼 데이터 (등록/수정 모드용)
const [formData, setFormData] = useState<Partial<ReceivingDetailType>>(INITIAL_FORM_DATA);
// 수입검사 성적서 모달 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
// API 데이터 로드
const loadData = useCallback(async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
@@ -57,6 +97,25 @@ export function ReceivingDetail({ id }: Props) {
if (result.success && result.data) {
setDetail(result.data);
// 수정 모드일 때 폼 데이터 설정
if (isEditMode) {
setFormData({
lotNo: result.data.lotNo || '',
itemCode: result.data.itemCode,
itemName: result.data.itemName,
specification: result.data.specification || '',
unit: result.data.unit || 'EA',
supplier: result.data.supplier,
receivingQty: result.data.receivingQty,
receivingDate: result.data.receivingDate || '',
createdBy: result.data.createdBy || '',
status: result.data.status,
remark: result.data.remark || '',
inspectionDate: result.data.inspectionDate || '',
inspectionResult: result.data.inspectionResult || '',
certificateFile: result.data.certificateFile,
});
}
} else {
setError(result.error || '입고 정보를 찾을 수 없습니다.');
}
@@ -67,200 +126,307 @@ export function ReceivingDetail({ id }: Props) {
} finally {
setIsLoading(false);
}
}, [id]);
}, [id, isNewMode, isEditMode]);
// 데이터 로드
useEffect(() => {
loadData();
}, [loadData]);
// 목록으로 돌아가기
const handleGoBack = useCallback(() => {
router.push('/ko/material/receiving-management');
}, [router]);
// 폼 입력 핸들러
const handleInputChange = (field: keyof ReceivingDetailType, value: string | number | undefined) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// 입고증 다이얼로그 열기
const handleOpenReceipt = useCallback(() => {
setIsReceiptDialogOpen(true);
}, []);
// 검사등록 페이지로 이동
const handleGoToInspection = useCallback(() => {
router.push('/ko/material/receiving-management/inspection');
}, [router]);
// 입고처리 다이얼로그 열기
const handleOpenReceivingProcessDialog = useCallback(() => {
setIsReceivingProcessDialogOpen(true);
}, []);
// 입고 완료 처리 (API 호출)
const handleReceivingComplete = useCallback(async (formData: ReceivingProcessFormData) => {
// 저장 핸들러
const handleSave = async () => {
setIsSaving(true);
try {
const result = await processReceiving(id, formData);
if (result.success) {
setIsReceivingProcessDialogOpen(false);
setSuccessDialog({ open: true, type: 'receiving', lotNo: formData.receivingLot });
} else {
alert(result.error || '입고처리에 실패했습니다.');
if (isNewMode) {
const result = await createReceiving(formData);
if (result.success) {
toast.success('입고가 등록되었습니다.');
router.push('/ko/material/receiving-management');
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
} else if (isEditMode) {
const result = await updateReceiving(id, formData);
if (result.success) {
toast.success('입고 정보가 수정되었습니다.');
router.push(`/ko/material/receiving-management/${id}?mode=view`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ReceivingDetail] handleReceivingComplete error:', err);
alert('입고처리 중 오류가 발생했습니다.');
console.error('[ReceivingDetail] handleSave error:', err);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [id]);
};
// 성공 다이얼로그 닫기
const handleSuccessDialogClose = useCallback(() => {
setSuccessDialog({ open: false, type: 'receiving' });
router.push('/ko/material/receiving-management');
}, [router]);
// 수입검사하기 버튼 핸들러 - 모달로 표시
const handleInspection = () => {
setIsInspectionModalOpen(true);
};
// 커스텀 헤더 액션 (상태별 버튼들)
const customHeaderActions = useMemo(() => {
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
const handleCancel = () => {
if (isNewMode) {
router.push('/ko/material/receiving-management');
} else {
router.push(`/ko/material/receiving-management/${id}?mode=view`);
}
};
// ===== 읽기 전용 필드 렌더링 =====
const renderReadOnlyField = (label: string, value: string | number | undefined, isEditModeStyle = false) => (
<div>
<Label className="text-sm text-muted-foreground">{label}</Label>
{isEditModeStyle ? (
<div className="mt-1.5 px-3 py-2 bg-gray-200 border border-gray-300 rounded-md text-sm text-gray-500 cursor-not-allowed select-none">
{value || '-'}
</div>
) : (
<div className="mt-1.5 px-3 py-2 bg-gray-50 border rounded-md text-sm">
{value || '-'}
</div>
)}
</div>
);
// ===== 상세 보기 콘텐츠 =====
const renderViewContent = useCallback(() => {
if (!detail) return null;
// 상태별 버튼 구성
const showInspectionButton = detail.status === 'inspection_pending';
const showReceivingProcessButton =
detail.status === 'order_completed' || detail.status === 'shipping';
const showReceiptButton =
detail.status === 'inspection_pending' || detail.status === 'completed';
return (
<>
{/* 발주번호와 상태 뱃지 */}
<span className="text-lg text-muted-foreground">{detail.orderNo}</span>
<Badge className={`${RECEIVING_STATUS_STYLES[detail.status]}`}>
{RECEIVING_STATUS_LABELS[detail.status]}
</Badge>
{showReceiptButton && (
<Button variant="outline" onClick={handleOpenReceipt}>
<FileText className="w-4 h-4 mr-1.5" />
</Button>
)}
{showInspectionButton && (
<Button onClick={handleGoToInspection}>
<ClipboardCheck className="w-4 h-4 mr-1.5" />
</Button>
)}
{showReceivingProcessButton && (
<Button onClick={handleOpenReceivingProcessDialog}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
)}
</>
);
}, [detail, handleOpenReceipt, handleGoToInspection, handleOpenReceivingProcessDialog]);
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
if (!detail) return null;
// 입고 정보 표시 여부: 검사대기, 입고대기, 입고완료
const showReceivingInfo =
detail.status === 'inspection_pending' ||
detail.status === 'receiving_pending' ||
detail.status === 'completed';
return (
<div className="space-y-6">
{/* 발주 정보 */}
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base"> </CardTitle>
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.orderNo}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{renderReadOnlyField('로트번호', detail.lotNo)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('발주처', detail.supplier)}
{renderReadOnlyField('입고수량', detail.receivingQty)}
{renderReadOnlyField('입고일', detail.receivingDate)}
{renderReadOnlyField('작성자', detail.createdBy)}
{renderReadOnlyField('상태',
detail.status === 'receiving_pending' ? '입고대기' :
detail.status === 'completed' ? '입고완료' :
detail.status === 'inspection_completed' ? '검사완료' :
detail.status
)}
{renderReadOnlyField('비고', detail.remark)}
</div>
</CardContent>
</Card>
{/* 수입검사 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{renderReadOnlyField('검사일', detail.inspectionDate)}
{renderReadOnlyField('검사결과', detail.inspectionResult)}
</div>
<div className="mt-4">
<Label className="text-sm text-muted-foreground"> </Label>
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
{detail.certificateFileName ? (
<div className="flex items-center justify-center gap-2">
<FileText className="w-5 h-5" />
<span>{detail.certificateFileName}</span>
</div>
) : (
'등록된 파일이 없습니다.'
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}, [detail]);
// ===== 등록/수정 폼 콘텐츠 =====
const renderFormContent = useCallback(() => {
return (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 로트번호 - 읽기전용 */}
{renderReadOnlyField('로트번호', formData.lotNo, true)}
{/* 품목코드 - 수정가능 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.orderDate || '-'}</p>
<Label htmlFor="itemCode" className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Input
id="itemCode"
value={formData.itemCode || ''}
onChange={(e) => handleInputChange('itemCode', e.target.value)}
className="mt-1.5"
placeholder="품목코드 입력"
/>
</div>
{/* 품목명 - 읽기전용 */}
{renderReadOnlyField('품목명', formData.itemName, true)}
{/* 규격 - 읽기전용 */}
{renderReadOnlyField('규격', formData.specification, true)}
{/* 단위 - 읽기전용 */}
{renderReadOnlyField('단위', formData.unit, true)}
{/* 발주처 - 수정가능 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.supplier}</p>
<Label htmlFor="supplier" className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Input
id="supplier"
value={formData.supplier || ''}
onChange={(e) => handleInputChange('supplier', e.target.value)}
className="mt-1.5"
placeholder="발주처 입력"
/>
</div>
{/* 입고수량 - 수정가능 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.itemCode}</p>
<Label htmlFor="receivingQty" className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Input
id="receivingQty"
type="number"
value={formData.receivingQty ?? ''}
onChange={(e) => handleInputChange('receivingQty', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1.5"
placeholder="입고수량 입력"
/>
</div>
{/* 입고일 - 수정가능 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.itemName}</p>
<Label htmlFor="receivingDate" className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Input
id="receivingDate"
type="date"
value={formData.receivingDate || ''}
onChange={(e) => handleInputChange('receivingDate', e.target.value)}
className="mt-1.5"
/>
</div>
{/* 작성자 - 읽기전용 */}
{renderReadOnlyField('작성자', formData.createdBy, true)}
{/* 상태 - 수정가능 (셀렉트) */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.specification || '-'}</p>
<Label htmlFor="status" className="text-sm text-muted-foreground">
<span className="text-red-500">*</span>
</Label>
<Select
key={`status-${formData.status}`}
value={formData.status}
onValueChange={(value) => handleInputChange('status', value as ReceivingStatus)}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{RECEIVING_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 - 수정가능 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">
{detail.orderQty} {detail.orderUnit}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.dueDate || '-'}</p>
<Label htmlFor="remark" className="text-sm text-muted-foreground">
</Label>
<Input
id="remark"
value={formData.remark || ''}
onChange={(e) => handleInputChange('remark', e.target.value)}
className="mt-1.5"
placeholder="비고 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 입고 정보 - 검사대기/입고대기/입고완료 상태에서만 표시 */}
{showReceivingInfo && (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.receivingDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">
{detail.receivingQty !== undefined
? `${detail.receivingQty} ${detail.orderUnit}`
: '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">LOT</p>
<p className="font-medium">{detail.receivingLot || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">LOT</p>
<p className="font-medium">{detail.supplierLot || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.receivingLocation || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.receivingManager || '-'}</p>
{/* 수입검사 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 검사일 - 읽기전용 */}
{renderReadOnlyField('검사일', formData.inspectionDate, true)}
{/* 검사결과 - 읽기전용 */}
{renderReadOnlyField('검사결과', formData.inspectionResult, true)}
</div>
{/* 업체 제공 성적서 자료 - 파일 업로드 */}
<div className="mt-4">
<Label className="text-sm text-muted-foreground"> </Label>
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors">
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<Upload className="w-8 h-8 text-gray-400" />
<span> , .</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
</div>
);
}, [detail]);
}, [formData]);
// 에러 상태 표시
if (!isLoading && (error || !detail)) {
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
<>
<Button variant="outline" onClick={handleInspection}>
</Button>
</>
) : undefined;
// 에러 상태 표시 (view/edit 모드에서만)
if (!isNewMode && !isLoading && (error || !detail)) {
return (
<ServerErrorPage
title="입고 정보를 불러올 수 없습니다"
@@ -272,45 +438,58 @@ export function ReceivingDetail({ id }: Props) {
);
}
// 동적 config 생성
const dynamicConfig = {
...receivingConfig,
title: isViewMode ? '입고 상세' : '입고',
description: isNewMode
? '새로운 입고를 등록합니다'
: isEditMode
? '입고 정보를 수정합니다'
: '입고 상세를 관리합니다',
actions: {
...receivingConfig.actions,
showEdit: isViewMode,
showDelete: false,
},
};
return (
<>
<IntegratedDetailTemplate
config={receivingConfig}
mode="view"
config={dynamicConfig}
mode={isNewMode ? 'create' : isEditMode ? 'edit' : 'view'}
initialData={detail || {}}
itemId={id}
itemId={isNewMode ? undefined : id}
isLoading={isLoading}
isSaving={isSaving}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderView={() => renderViewContent()}
renderForm={() => renderFormContent()}
onSave={handleSave}
onCancel={handleCancel}
/>
{/* 입고증 다이얼로그 */}
{detail && (
<ReceivingReceiptDialog
open={isReceiptDialogOpen}
onOpenChange={setIsReceiptDialogOpen}
detail={detail}
/>
)}
{/* 입고처리 다이얼로그 */}
{detail && (
<ReceivingProcessDialog
open={isReceivingProcessDialogOpen}
onOpenChange={setIsReceivingProcessDialogOpen}
detail={detail}
onComplete={handleReceivingComplete}
/>
)}
{/* 성공 다이얼로그 */}
<SuccessDialog
open={successDialog.open}
type={successDialog.type}
lotNo={successDialog.lotNo}
onClose={handleSuccessDialogClose}
{/* 수입검사 성적서 모달 */}
<InspectionModalV2
isOpen={isInspectionModalOpen}
onClose={() => setIsInspectionModalOpen(false)}
document={{
id: 'import-inspection',
type: 'import',
title: '수입검사 성적서',
}}
documentItem={{
id: id,
title: detail?.itemName || '수입검사 성적서',
date: detail?.inspectionDate || '',
code: detail?.lotNo || '',
}}
// 수입검사 템플릿 로드용 props
itemName={detail?.itemName}
specification={detail?.specification}
supplier={detail?.supplier}
/>
</>
);
}
}

View File

@@ -1,22 +1,24 @@
'use client';
/**
* 입고 목록 - UniversalListPage 마이그레이션
* 입고 목록 - 기획서 기준 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 서버 사이드 페이지네이션 (getReceivings API)
* - 통계 카드 (getReceivingStats API)
* - 고정 탭 필터 (전체/입고대기/입고완료)
* - 테이블 푸터 (요약 정보)
* 기획서 기준:
* - 날짜 범위 필터
* - 상태 셀렉트 필터 (전체, 입고대기, 입고완료, 검사완료)
* - 통계 카드 (입고대기, 입고완료, 검사 중, 검사완료)
* - 입고 등록 버튼
* - 테이블 헤더: 체크박스, 번호, 로트번호, 수입검사, 검사일, 발주처, 품목코드, 품목명, 규격, 단위, 입고수량, 입고일, 작성자, 상태
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Package,
Truck,
CheckCircle2,
Clock,
ClipboardCheck,
Calendar,
Plus,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -28,9 +30,9 @@ import {
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type StatCard,
type ListParams,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getReceivings, getReceivingStats } from './actions';
@@ -48,6 +50,17 @@ export function ReceivingList() {
const [stats, setStats] = useState<ReceivingStats | null>(null);
const [totalItems, setTotalItems] = useState(0);
// ===== 날짜 범위 상태 =====
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
// ===== 필터 상태 =====
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
status: 'all',
});
// 초기 통계 로드
useEffect(() => {
const loadStats = async () => {
@@ -72,60 +85,70 @@ export function ReceivingList() {
[router]
);
// ===== 입고 등록 핸들러 =====
const handleRegister = useCallback(() => {
router.push('/ko/material/receiving-management/new');
}, [router]);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(
() => [
{
label: '입고대기',
value: `${stats?.receivingPendingCount ?? 0}`,
icon: Package,
value: `${stats?.receivingPendingCount ?? 0}`,
icon: Clock,
iconColor: 'text-yellow-600',
},
{
label: '배송중',
value: `${stats?.shippingCount ?? 0}`,
icon: Truck,
iconColor: 'text-blue-600',
label: '입고완료',
value: `${stats?.receivingCompletedCount ?? 0}`,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '검사대기',
value: `${stats?.inspectionPendingCount ?? 0}`,
label: '검사',
value: `${stats?.inspectionPendingCount ?? 0}`,
icon: ClipboardCheck,
iconColor: 'text-orange-600',
},
{
label: '금일입고',
value: `${stats?.todayReceivingCount ?? 0}`,
icon: Calendar,
iconColor: 'text-green-600',
label: '검사완료',
value: `${stats?.inspectionCompletedCount ?? 0}`,
icon: CheckCircle2,
iconColor: 'text-blue-600',
},
],
[stats]
);
// ===== 탭 옵션 (고정) =====
const tabs: TabOption[] = useMemo(
() => [
{ value: 'all', label: '전체', count: totalItems },
{ value: 'receiving_pending', label: '입고대기', count: stats?.receivingPendingCount ?? 0 },
{ value: 'completed', label: '입고완료', count: stats?.todayReceivingCount ?? 0 },
],
[totalItems, stats]
);
// ===== 필터 설정 =====
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'select',
options: [
{ value: 'all', label: '전체' },
{ value: 'receiving_pending', label: '입고대기' },
{ value: 'completed', label: '입고완료' },
{ value: 'inspection_completed', label: '검사완료' },
],
defaultValue: 'all',
},
];
// ===== 테이블 푸터 =====
const tableFooter = useMemo(
() => (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={10} className="py-3">
<TableCell colSpan={15} className="py-3">
<span className="text-sm text-muted-foreground">
{totalItems} / {stats?.receivingPendingCount ?? 0} / {' '}
{stats?.inspectionPendingCount ?? 0}
{totalItems}
</span>
</TableCell>
</TableRow>
),
[totalItems, stats]
[totalItems]
);
// ===== UniversalListPage Config =====
@@ -133,7 +156,7 @@ export function ReceivingList() {
() => ({
// 페이지 기본 정보
title: '입고 목록',
description: '입고 관리',
description: '입고 관리합니다',
icon: Package,
basePath: '/material/receiving-management',
@@ -144,11 +167,14 @@ export function ReceivingList() {
actions: {
getList: async (params?: ListParams) => {
try {
const statusFilter = params?.filters?.status as string;
const result = await getReceivings({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
status: params?.tab !== 'all' ? params?.tab : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: params?.search || undefined,
startDate,
endDate,
});
if (result.success) {
@@ -158,7 +184,7 @@ export function ReceivingList() {
setStats(statsResult.data);
}
// totalItems 업데이트 (푸터 및 탭용)
// totalItems 업데이트
setTotalItems(result.pagination.total);
return {
@@ -176,17 +202,21 @@ export function ReceivingList() {
},
},
// 테이블 컬럼
// 테이블 컬럼 (기획서 순서)
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'orderNo', label: '발주번호', className: 'min-w-[150px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[130px]' },
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[80px] text-center' },
{ key: 'inspectionDate', label: '검사일', className: 'w-[100px] text-center' },
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'supplier', label: '공급업체', className: 'min-w-[100px]' },
{ key: 'orderQty', label: '발주수량', className: 'w-[100px] text-center' },
{ key: 'receivingQty', label: '입고수량', className: 'w-[100px] text-center' },
{ key: 'lotNo', label: 'LOT번호', className: 'w-[120px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'specification', label: '규격', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'receivingQty', label: '입고수량', className: 'w-[80px] text-center' },
{ key: 'receivingDate', label: '입고일', className: 'w-[100px] text-center' },
{ key: 'createdBy', label: '작성자', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
],
// 서버 사이드 페이지네이션
@@ -194,15 +224,38 @@ export function ReceivingList() {
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '발주번호, 품목코드, 품목명, 공급업체 검색...',
searchPlaceholder: '로트번호, 품목코드, 품목명 검색...',
// 탭 설정
tabs,
defaultTab: 'all',
// 날짜 범위 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 필터 설정
filterConfig,
initialFilters: filterValues,
// 통계 카드
stats: statCards,
// 헤더 액션 (입고 등록 버튼)
headerActions: () => (
<Button
variant="default"
size="sm"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={handleRegister}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
),
// 테이블 푸터
tableFooter,
@@ -226,17 +279,19 @@ export function ReceivingList() {
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.orderNo}</TableCell>
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
<TableCell>{item.supplier}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell>{item.supplier}</TableCell>
<TableCell className="text-center">
{item.orderQty} {item.orderUnit}
</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">
{item.receivingQty !== undefined ? item.receivingQty : '-'}
</TableCell>
<TableCell className="text-center">{item.lotNo || '-'}</TableCell>
<TableCell className="text-center">{item.receivingDate || '-'}</TableCell>
<TableCell className="text-center">{item.createdBy || '-'}</TableCell>
<TableCell className="text-center">
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
{RECEIVING_STATUS_LABELS[item.status]}
@@ -265,9 +320,11 @@ export function ReceivingList() {
<Badge variant="outline" className="text-xs">
#{globalIndex}
</Badge>
<Badge variant="outline" className="text-xs">
{item.orderNo}
</Badge>
{item.lotNo && (
<Badge variant="outline" className="text-xs">
{item.lotNo}
</Badge>
)}
</>
}
title={item.itemName}
@@ -279,13 +336,11 @@ export function ReceivingList() {
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="공급업체" value={item.supplier} />
<InfoField label="발주수량" value={`${item.orderQty} ${item.orderUnit}`} />
<InfoField
label="입고수량"
value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'}
/>
<InfoField label="LOT번호" value={item.lotNo || '-'} />
<InfoField label="발주처" value={item.supplier} />
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
<InfoField label="검사일" value={item.inspectionDate || '-'} />
<InfoField label="입고수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
<InfoField label="입고일" value={item.receivingDate || '-'} />
</div>
}
actions={
@@ -310,8 +365,13 @@ export function ReceivingList() {
);
},
}),
[tabs, statCards, tableFooter, handleRowClick]
[statCards, filterConfig, filterValues, tableFooter, handleRowClick, handleRegister, startDate, endDate]
);
return <UniversalListPage config={config} />;
return (
<UniversalListPage
config={config}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
/>
);
}

View File

@@ -13,6 +13,9 @@
'use server';
// ===== 목데이터 모드 플래그 =====
const USE_MOCK_DATA = true;
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
@@ -24,6 +27,235 @@ import type {
ReceivingProcessFormData,
} from './types';
// ===== 목데이터 =====
const MOCK_RECEIVING_LIST: ReceivingItem[] = [
{
id: '1',
lotNo: 'LOT-2026-001',
inspectionStatus: '적',
inspectionDate: '2026-01-25',
supplier: '(주)대한철강',
itemCode: 'STEEL-001',
itemName: 'SUS304 스테인리스 판재',
specification: '1000x2000x3T',
unit: 'EA',
receivingQty: 100,
receivingDate: '2026-01-26',
createdBy: '김철수',
status: 'completed',
},
{
id: '2',
lotNo: 'LOT-2026-002',
inspectionStatus: '적',
inspectionDate: '2026-01-26',
supplier: '삼성전자부품',
itemCode: 'ELEC-002',
itemName: 'MCU 컨트롤러 IC',
specification: 'STM32F103C8T6',
unit: 'EA',
receivingQty: 500,
receivingDate: '2026-01-27',
createdBy: '이영희',
status: 'completed',
},
{
id: '3',
lotNo: 'LOT-2026-003',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '한국플라스틱',
itemCode: 'PLAS-003',
itemName: 'ABS 사출 케이스',
specification: '150x100x50',
unit: 'SET',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '박민수',
status: 'receiving_pending',
},
{
id: '4',
lotNo: 'LOT-2026-004',
inspectionStatus: '부적',
inspectionDate: '2026-01-27',
supplier: '(주)대한철강',
itemCode: 'STEEL-002',
itemName: '알루미늄 프로파일',
specification: '40x40x2000L',
unit: 'EA',
receivingQty: 50,
receivingDate: '2026-01-28',
createdBy: '김철수',
status: 'inspection_pending',
},
{
id: '5',
lotNo: 'LOT-2026-005',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '글로벌전자',
itemCode: 'ELEC-005',
itemName: 'DC 모터 24V',
specification: '24V 100RPM',
unit: 'EA',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '최지훈',
status: 'receiving_pending',
},
{
id: '6',
lotNo: 'LOT-2026-006',
inspectionStatus: '적',
inspectionDate: '2026-01-24',
supplier: '동양화학',
itemCode: 'CHEM-001',
itemName: '에폭시 접착제',
specification: '500ml',
unit: 'EA',
receivingQty: 200,
receivingDate: '2026-01-25',
createdBy: '이영희',
status: 'completed',
},
{
id: '7',
lotNo: 'LOT-2026-007',
inspectionStatus: '적',
inspectionDate: '2026-01-28',
supplier: '삼성전자부품',
itemCode: 'ELEC-007',
itemName: '커패시터 100uF',
specification: '100uF 50V',
unit: 'EA',
receivingQty: 1000,
receivingDate: '2026-01-28',
createdBy: '박민수',
status: 'completed',
},
{
id: '8',
lotNo: 'LOT-2026-008',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '한국볼트',
itemCode: 'BOLT-001',
itemName: 'SUS 볼트 M8x30',
specification: 'M8x30 SUS304',
unit: 'EA',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '김철수',
status: 'receiving_pending',
},
];
const MOCK_RECEIVING_STATS: ReceivingStats = {
receivingPendingCount: 3,
receivingCompletedCount: 4,
inspectionPendingCount: 1,
inspectionCompletedCount: 5,
};
// 기획서 2026-01-28 기준 상세 목데이터
const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
'1': {
id: '1',
// 기본 정보
lotNo: 'LOT-2026-001',
itemCode: 'STEEL-001',
itemName: 'SUS304 스테인리스 판재',
specification: '1000x2000x3T',
unit: 'EA',
supplier: '(주)대한철강',
receivingQty: 100,
receivingDate: '2026-01-26',
createdBy: '김철수',
status: 'completed',
remark: '',
// 수입검사 정보
inspectionDate: '2026-01-25',
inspectionResult: '합격',
certificateFile: undefined,
// 하위 호환
orderNo: 'PO-2026-001',
orderUnit: 'EA',
},
'2': {
id: '2',
lotNo: 'LOT-2026-002',
itemCode: 'ELEC-002',
itemName: 'MCU 컨트롤러 IC',
specification: 'STM32F103C8T6',
unit: 'EA',
supplier: '삼성전자부품',
receivingQty: 500,
receivingDate: '2026-01-27',
createdBy: '이영희',
status: 'completed',
remark: '긴급 입고',
inspectionDate: '2026-01-26',
inspectionResult: '합격',
orderNo: 'PO-2026-002',
orderUnit: 'EA',
},
'3': {
id: '3',
lotNo: 'LOT-2026-003',
itemCode: 'PLAS-003',
itemName: 'ABS 사출 케이스',
specification: '150x100x50',
unit: 'SET',
supplier: '한국플라스틱',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '박민수',
status: 'receiving_pending',
remark: '',
inspectionDate: undefined,
inspectionResult: undefined,
orderNo: 'PO-2026-003',
orderUnit: 'SET',
},
'4': {
id: '4',
lotNo: 'LOT-2026-004',
itemCode: 'STEEL-002',
itemName: '알루미늄 프로파일',
specification: '40x40x2000L',
unit: 'EA',
supplier: '(주)대한철강',
receivingQty: 50,
receivingDate: '2026-01-28',
createdBy: '김철수',
status: 'inspection_pending',
remark: '검사 진행 중',
inspectionDate: '2026-01-27',
inspectionResult: '불합격',
orderNo: 'PO-2026-004',
orderUnit: 'EA',
},
'5': {
id: '5',
lotNo: 'LOT-2026-005',
itemCode: 'ELEC-005',
itemName: 'DC 모터 24V',
specification: '24V 100RPM',
unit: 'EA',
supplier: '글로벌전자',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '최지훈',
status: 'receiving_pending',
remark: '',
inspectionDate: undefined,
inspectionResult: undefined,
orderNo: 'PO-2026-005',
orderUnit: 'EA',
},
};
// ===== API 데이터 타입 =====
interface ReceivingApiData {
id: number;
@@ -171,6 +403,46 @@ export async function getReceivings(params?: {
error?: string;
__authError?: boolean;
}> {
// ===== 목데이터 모드 =====
if (USE_MOCK_DATA) {
let filteredData = [...MOCK_RECEIVING_LIST];
// 상태 필터
if (params?.status && params.status !== 'all') {
filteredData = filteredData.filter(item => item.status === params.status);
}
// 검색 필터
if (params?.search) {
const search = params.search.toLowerCase();
filteredData = filteredData.filter(
item =>
item.lotNo?.toLowerCase().includes(search) ||
item.itemCode.toLowerCase().includes(search) ||
item.itemName.toLowerCase().includes(search) ||
item.supplier.toLowerCase().includes(search)
);
}
const page = params?.page || 1;
const perPage = params?.perPage || 20;
const total = filteredData.length;
const lastPage = Math.ceil(total / perPage);
const startIndex = (page - 1) * perPage;
const paginatedData = filteredData.slice(startIndex, startIndex + perPage);
return {
success: true,
data: paginatedData,
pagination: {
currentPage: page,
lastPage,
perPage,
total,
},
};
}
try {
const searchParams = new URLSearchParams();
@@ -260,6 +532,11 @@ export async function getReceivingStats(): Promise<{
error?: string;
__authError?: boolean;
}> {
// ===== 목데이터 모드 =====
if (USE_MOCK_DATA) {
return { success: true, data: MOCK_RECEIVING_STATS };
}
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/stats`,
@@ -295,6 +572,15 @@ export async function getReceivingById(id: string): Promise<{
error?: string;
__authError?: boolean;
}> {
// ===== 목데이터 모드 =====
if (USE_MOCK_DATA) {
const detail = MOCK_RECEIVING_DETAIL[id];
if (detail) {
return { success: true, data: detail };
}
return { success: false, error: '입고 정보를 찾을 수 없습니다.' };
}
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}`,
@@ -457,4 +743,228 @@ export async function processReceiving(
console.error('[ReceivingActions] processReceiving error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 수입검사 템플릿 타입 (ImportInspectionDocument와 동일) =====
export interface InspectionTemplateResponse {
templateId: string;
templateName: string;
headerInfo: {
productName: string;
specification: string;
materialNo: string;
lotSize: number;
supplier: string;
lotNo: string;
inspectionDate: string;
inspector: string;
reportDate: string;
approvers: {
writer?: string;
reviewer?: string;
approver?: string;
};
};
inspectionItems: Array<{
id: string;
no: number;
name: string;
subName?: string;
parentId?: string;
standard: {
description?: string;
value?: string | number;
options?: Array<{
id: string;
label: string;
tolerance: string;
isSelected: boolean;
}>;
};
inspectionMethod: string;
inspectionCycle: string;
measurementType: 'okng' | 'numeric' | 'both';
measurementCount: number;
rowSpan?: number;
isSubRow?: boolean;
}>;
notes?: string[];
}
// ===== 수입검사 템플릿 조회 (품목명/규격 기반) =====
export async function getInspectionTemplate(params: {
itemName: string;
specification: string;
lotNo?: string;
supplier?: string;
}): Promise<{
success: boolean;
data?: InspectionTemplateResponse;
error?: string;
__authError?: boolean;
}> {
// ===== 목데이터 모드 - EGI 강판 템플릿 반환 =====
if (USE_MOCK_DATA) {
// 품목명/규격에 따라 다른 템플릿 반환 (추후 24종 확장)
const mockTemplate: InspectionTemplateResponse = {
templateId: 'EGI-001',
templateName: '전기 아연도금 강판',
headerInfo: {
productName: params.itemName || '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"',
specification: params.specification || '1.55 * 1218 × 480',
materialNo: 'PE02RB',
lotSize: 200,
supplier: params.supplier || '지오TNS (KG스틸)',
lotNo: params.lotNo || '250715-02',
inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
inspector: '노원호',
reportDate: new Date().toISOString().split('T')[0],
approvers: {
writer: '노원호',
reviewer: '',
approver: '',
},
},
inspectionItems: [
{
id: 'appearance',
no: 1,
name: '겉모양',
standard: { description: '사용상 해로운 결함이 없을 것' },
inspectionMethod: '육안검사',
inspectionCycle: '',
measurementType: 'okng',
measurementCount: 3,
},
{
id: 'thickness',
no: 2,
name: '치수',
subName: '두께',
standard: {
value: 1.55,
options: [
{ id: 't1', label: '0.8 이상 ~ 1.0 미만', tolerance: '± 0.07', isSelected: false },
{ id: 't2', label: '1.0 이상 ~ 1.25 미만', tolerance: '± 0.08', isSelected: false },
{ id: 't3', label: '1.25 이상 ~ 1.6 미만', tolerance: '± 0.10', isSelected: true },
{ id: 't4', label: '1.6 이상 ~ 2.0 미만', tolerance: '± 0.12', isSelected: false },
],
},
inspectionMethod: 'n = 3\nc = 0',
inspectionCycle: '체크검사',
measurementType: 'numeric',
measurementCount: 3,
rowSpan: 3,
},
{
id: 'width',
no: 2,
name: '치수',
subName: '너비',
parentId: 'thickness',
standard: {
value: 1219,
options: [{ id: 'w1', label: '1250 미만', tolerance: '+ 7\n- 0', isSelected: true }],
},
inspectionMethod: '',
inspectionCycle: '',
measurementType: 'numeric',
measurementCount: 3,
isSubRow: true,
},
{
id: 'length',
no: 2,
name: '치수',
subName: '길이',
parentId: 'thickness',
standard: {
value: 480,
options: [{ id: 'l1', label: '2000 이상 ~ 4000 미만', tolerance: '+ 15\n- 0', isSelected: true }],
},
inspectionMethod: '',
inspectionCycle: '',
measurementType: 'numeric',
measurementCount: 3,
isSubRow: true,
},
{
id: 'tensileStrength',
no: 3,
name: '인장강도 (N/㎟)',
standard: { description: '270 이상' },
inspectionMethod: '',
inspectionCycle: '',
measurementType: 'numeric',
measurementCount: 1,
},
{
id: 'elongation',
no: 4,
name: '연신율 %',
standard: {
options: [
{ id: 'e1', label: '두께 0.6 이상 ~ 1.0 미만', tolerance: '36 이상', isSelected: false },
{ id: 'e2', label: '두께 1.0 이상 ~ 1.6 미만', tolerance: '37 이상', isSelected: true },
{ id: 'e3', label: '두께 1.6 이상 ~ 2.3 미만', tolerance: '38 이상', isSelected: false },
],
},
inspectionMethod: '공급업체\n밀시트',
inspectionCycle: '입고시',
measurementType: 'numeric',
measurementCount: 1,
},
{
id: 'zincCoating',
no: 5,
name: '아연의 최소 부착량 (g/㎡)',
standard: { description: '편면 17 이상' },
inspectionMethod: '',
inspectionCycle: '',
measurementType: 'numeric',
measurementCount: 2,
},
],
notes: [
'※ 1.55mm의 경우 KS F 4510에 따른 MIN 1.5의 기준에 따름',
'※ 두께의 경우 너비 1000 이상 ~ 1250 미만 기준에 따름',
],
};
return { success: true, data: mockTemplate };
}
// ===== 실제 API 호출 =====
try {
const searchParams = new URLSearchParams();
searchParams.set('item_name', params.itemName);
searchParams.set('specification', params.specification);
if (params.lotNo) searchParams.set('lot_no', params.lotNo);
if (params.supplier) searchParams.set('supplier', params.supplier);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspection-templates?${searchParams.toString()}`,
{ method: 'GET', cache: 'no-store' }
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '검사 템플릿 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '검사 템플릿 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ReceivingActions] getInspectionTemplate error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -4,11 +4,12 @@
// 입고 상태
export type ReceivingStatus =
| 'order_completed' // 발주완료
| 'shipping' // 배송중
| 'inspection_pending' // 검사대기
| 'receiving_pending' // 입고대기
| 'completed'; // 입고완료
| 'order_completed' // 발주완료
| 'shipping' // 배송중
| 'inspection_pending' // 검사대기
| 'receiving_pending' // 입고대기
| 'completed' // 입고완료
| 'inspection_completed'; // 검사완료
// 상태 라벨
export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
@@ -17,6 +18,7 @@ export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
inspection_pending: '검사대기',
receiving_pending: '입고대기',
completed: '입고완료',
inspection_completed: '검사완료',
};
// 상태 스타일
@@ -26,40 +28,65 @@ export const RECEIVING_STATUS_STYLES: Record<ReceivingStatus, string> = {
inspection_pending: 'bg-orange-100 text-orange-800',
receiving_pending: 'bg-yellow-100 text-yellow-800',
completed: 'bg-green-100 text-green-800',
inspection_completed: 'bg-blue-100 text-blue-800',
};
// 상세 페이지용 상태 옵션 (셀렉트박스)
export const RECEIVING_STATUS_OPTIONS = [
{ value: 'receiving_pending', label: '입고대기' },
{ value: 'completed', label: '입고완료' },
{ value: 'inspection_completed', label: '검사완료' },
] as const;
// 입고 목록 아이템
export interface ReceivingItem {
id: string;
orderNo: string; // 발주번호
itemCode: string; // 품목코드
itemName: string; // 품목명
supplier: string; // 공급업체
orderQty: number; // 발주수량
orderUnit: string; // 발주단위
receivingQty?: number; // 입고수량
lotNo?: string; // LOT번호
status: ReceivingStatus; // 상태
lotNo?: string; // 로트번호
inspectionStatus?: string; // 수입검사 (적/부적/-)
inspectionDate?: string; // 검사일
supplier: string; // 발주처
itemCode: string; // 품목코드
itemName: string; // 품목명
specification?: string; // 규격
unit: string; // 단위
receivingQty?: number; // 입고수량
receivingDate?: string; // 입고일
createdBy?: string; // 작성자
status: ReceivingStatus; // 상태
// 기존 필드 (하위 호환)
orderNo?: string; // 발주번호
orderQty?: number; // 발주수량
orderUnit?: string; // 발주단위
}
// 입고 상세 정보
// 입고 상세 정보 (기획서 2026-01-28 기준)
export interface ReceivingDetail {
id: string;
orderNo: string; // 발주번호
orderDate?: string; // 발주일자
supplier: string; // 공급업체
itemCode: string; // 품목코드
itemName: string; // 품목명
specification?: string; // 규격
orderQty: number; // 발주수량
orderUnit: string; // 발주단위
dueDate?: string; // 납기일
status: ReceivingStatus;
// 입고 정보
receivingDate?: string; // 입고일자
receivingQty?: number; // 입고수량
receivingLot?: string; // 입고LOT
supplierLot?: string; // 공급업체LOT
// 기본 정보
lotNo?: string; // 로트번호 (읽기전용)
itemCode: string; // 품목코드 (수정가능)
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
specification?: string; // 규격 (읽기전용)
unit: string; // 단위 (읽기전용)
supplier: string; // 발주처 (수정가능)
receivingQty?: number; // 입고수량 (수정가능)
receivingDate?: string; // 입고일 (수정가능)
createdBy?: string; // 작성자 (읽기전용)
status: ReceivingStatus; // 상태 (수정가능)
remark?: string; // 비고 (수정가능)
// 수입검사 정보
inspectionDate?: string; // 검사일 (읽기전용)
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
certificateFileName?: string; // 파일명
// 기존 필드 (하위 호환)
orderNo?: string; // 발주번호
orderDate?: string; // 발주일자
orderQty?: number; // 발주수량
orderUnit?: string; // 발주단위
dueDate?: string; // 납기일
receivingLot?: string; // 입고LOT
supplierLot?: string; // 공급업체LOT
receivingLocation?: string; // 입고위치
receivingManager?: string; // 입고담당
}
@@ -103,10 +130,13 @@ export interface ReceivingProcessFormData {
// 통계 데이터
export interface ReceivingStats {
receivingPendingCount: number; // 입고대기
shippingCount: number; // 배송중
inspectionPendingCount: number; // 검사대기
todayReceivingCount: number; // 금일입고
receivingPendingCount: number; // 입고대기
receivingCompletedCount: number; // 입고완료
inspectionPendingCount: number; // 검사
inspectionCompletedCount: number; // 검사완료
// 기존 필드 (하위 호환)
shippingCount?: number; // 배송중
todayReceivingCount?: number; // 금일입고
}
// 필터 탭

View File

@@ -0,0 +1,243 @@
'use client';
/**
* 재고 실사 모달
*
* 기능:
* - 재고 목록 표시 (품목코드, 품목명, 규격, 단위, 실제 재고량)
* - 실제 재고량 입력/수정
* - 저장 시 일괄 업데이트
*/
import { useState, useEffect, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { updateStockAudit } from './actions';
import type { StockItem } from './types';
interface StockAuditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stocks: StockItem[];
onComplete?: () => void;
}
interface AuditItem {
id: string;
itemCode: string;
itemName: string;
specification: string;
unit: string;
calculatedQty: number;
actualQty: number;
newActualQty: number;
}
export function StockAuditModal({
open,
onOpenChange,
stocks,
onComplete,
}: StockAuditModalProps) {
const [auditItems, setAuditItems] = useState<AuditItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// 모달이 열릴 때 데이터 초기화
useEffect(() => {
if (open && stocks.length > 0) {
setAuditItems(
stocks.map((stock) => ({
id: stock.id,
itemCode: stock.itemCode,
itemName: stock.itemName,
specification: stock.specification || '',
unit: stock.unit,
calculatedQty: stock.calculatedQty,
actualQty: stock.actualQty,
newActualQty: stock.actualQty,
}))
);
}
}, [open, stocks]);
// 실제 재고량 변경 핸들러
const handleQtyChange = useCallback((id: string, value: string) => {
const numValue = value === '' ? 0 : parseFloat(value);
if (isNaN(numValue)) return;
setAuditItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, newActualQty: numValue } : item
)
);
}, []);
// 저장
const handleSubmit = async () => {
// 변경된 항목만 필터링
const changedItems = auditItems.filter(
(item) => item.actualQty !== item.newActualQty
);
if (changedItems.length === 0) {
toast.info('변경된 항목이 없습니다.');
return;
}
setIsSubmitting(true);
try {
const updates = changedItems.map((item) => ({
id: item.id,
actualQty: item.newActualQty,
}));
const result = await updateStockAudit(updates);
if (result.success) {
toast.success(`${changedItems.length}개 항목의 재고가 업데이트되었습니다.`);
onOpenChange(false);
onComplete?.();
} else {
toast.error(result.error || '재고 실사 저장에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockAuditModal] handleSubmit error:', error);
toast.error('재고 실사 저장 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[1200px] w-full p-0 gap-0 max-h-[80vh] flex flex-col">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4 flex-shrink-0">
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
</DialogHeader>
<div className="px-6 pb-6 space-y-6 flex-1 overflow-hidden flex flex-col">
{/* 테이블 */}
{isLoading ? (
<ContentSkeleton type="table" rows={6} />
) : auditItems.length === 0 ? (
<div className="border rounded-lg flex-1">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : (
<div className="border rounded-lg overflow-auto flex-1">
<Table className="w-full">
<TableHeader className="sticky top-0 bg-gray-50 z-10">
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium w-[15%]"></TableHead>
<TableHead className="text-center font-medium w-[25%]"></TableHead>
<TableHead className="text-center font-medium w-[15%]"></TableHead>
<TableHead className="text-center font-medium w-[8%]"></TableHead>
<TableHead className="text-center font-medium w-[12%]"> </TableHead>
<TableHead className="text-center font-medium w-[15%]"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center font-medium">
{item.itemCode}
</TableCell>
<TableCell className="text-center max-w-[200px] truncate" title={item.itemName}>
{item.itemName}
</TableCell>
<TableCell className="text-center">{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center text-gray-600">
{item.calculatedQty}
</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={item.newActualQty}
onChange={(e) => handleQtyChange(item.id, e.target.value)}
className="w-24 text-center mx-auto"
min={0}
step={1}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 버튼 영역 */}
<div className="flex gap-3 flex-shrink-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'저장'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,49 +1,76 @@
'use client';
/**
* 재고현황 상세 페이지
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
* API 연동 버전 (2025-12-26)
* 재고현황 상세/수정 페이지
*
* 기획서 기준:
* - 기본 정보: 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
* - 수정 가능: 실제 재고량, 안전재고, 상태 (사용/미사용)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useState, useCallback, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { stockStatusConfig } from './stockStatusConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { getStockById } from './actions';
import {
ITEM_TYPE_LABELS,
ITEM_TYPE_STYLES,
STOCK_STATUS_LABELS,
LOT_STATUS_LABELS,
} from './types';
import type { StockDetail, LotDetail } from './types';
import { getStockById, updateStock } from './actions';
import { USE_STATUS_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
interface StockStatusDetailProps {
id: string;
}
// 상세 페이지용 데이터 타입
interface StockDetailData {
id: string;
stockNumber: string;
itemCode: string;
itemName: string;
specification: string;
unit: string;
calculatedQty: number;
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}
export function StockStatusDetail({ id }: StockStatusDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const initialMode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
// API 데이터 상태
const [detail, setDetail] = useState<StockDetail | null>(null);
const [detail, setDetail] = useState<StockDetailData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 폼 데이터 (수정 모드용)
const [formData, setFormData] = useState<{
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}>({
actualQty: 0,
safetyStock: 0,
useStatus: 'active',
});
// 저장 중 상태
const [isSaving, setIsSaving] = useState(false);
// API 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
@@ -53,7 +80,26 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
const result = await getStockById(id);
if (result.success && result.data) {
setDetail(result.data);
const data = result.data;
// API 응답을 상세 페이지용 데이터로 변환
const detailData: StockDetailData = {
id: data.id,
stockNumber: data.id, // stockNumber가 없으면 id 사용
itemCode: data.itemCode,
itemName: data.itemName,
specification: data.specification || '-',
unit: data.unit,
calculatedQty: data.currentStock, // 계산 재고량
actualQty: data.currentStock, // 실제 재고량 (별도 필드 없으면 currentStock 사용)
safetyStock: data.safetyStock,
useStatus: data.status === null ? 'active' : 'active', // 기본값
};
setDetail(detailData);
setFormData({
actualQty: detailData.actualQty,
safetyStock: detailData.safetyStock,
useStatus: detailData.useStatus,
});
} else {
setError(result.error || '재고 정보를 찾을 수 없습니다.');
}
@@ -71,201 +117,185 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
loadData();
}, [loadData]);
// 가장 오래된 LOT 찾기 (FIFO 권장용)
const oldestLot = useMemo(() => {
if (!detail || detail.lots.length === 0) return null;
return detail.lots.reduce((oldest, lot) =>
lot.daysElapsed > oldest.daysElapsed ? lot : oldest
);
}, [detail]);
// 폼 값 변경 핸들러
const handleInputChange = (field: keyof typeof formData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: field === 'useStatus' ? value : Number(value),
}));
};
// 총 수량 계산
const totalQty = useMemo(() => {
if (!detail) return 0;
return detail.lots.reduce((sum, lot) => sum + lot.qty, 0);
}, [detail]);
// 저장 핸들러
const handleSave = async () => {
if (!detail) return;
// 커스텀 헤더 액션 (품목코드와 상태 뱃지)
const customHeaderActions = useMemo(() => {
setIsSaving(true);
try {
const result = await updateStock(id, formData);
if (result.success) {
toast.success('재고 정보가 저장되었습니다.');
// 상세 데이터 업데이트
setDetail((prev) =>
prev
? {
...prev,
actualQty: formData.actualQty,
safetyStock: formData.safetyStock,
useStatus: formData.useStatus,
}
: null
);
// view 모드로 전환
router.push(`/ko/material/stock-status/${id}?mode=view`);
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[StockStatusDetail] handleSave error:', err);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 읽기 전용 필드 렌더링 (수정 모드에서 구분용)
const renderReadOnlyField = (label: string, value: string | number, isEditMode = false) => (
<div>
<Label className="text-sm text-muted-foreground">{label}</Label>
{isEditMode ? (
// 수정 모드: 읽기 전용임을 명확히 표시 (어두운 배경 + cursor-not-allowed)
<div className="mt-1.5 px-3 py-2 bg-gray-200 border border-gray-300 rounded-md text-sm text-gray-500 cursor-not-allowed select-none">
{value}
</div>
) : (
// 보기 모드: 일반 텍스트 스타일
<div className="mt-1.5 px-3 py-2 bg-gray-50 border rounded-md text-sm">
{value}
</div>
)}
</div>
);
// 상세 보기 모드 렌더링
const renderViewContent = useCallback(() => {
if (!detail) return null;
return (
<>
<span className="text-muted-foreground">{detail.itemCode}</span>
<Badge variant="outline" className="text-xs">
{STOCK_STATUS_LABELS[detail.status]}
</Badge>
</>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('재고번호', detail.stockNumber)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
</div>
{/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('계산 재고량', detail.calculatedQty)}
{renderReadOnlyField('실제 재고량', detail.actualQty)}
{renderReadOnlyField('안전재고', detail.safetyStock)}
</div>
{/* Row 3: 상태 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
</div>
</div>
</CardContent>
</Card>
);
}, [detail]);
// 폼 콘텐츠 렌더링
// 수정 모드 렌더링
const renderFormContent = useCallback(() => {
if (!detail) return null;
return (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.itemCode}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.itemName}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<Badge className={`text-xs ${ITEM_TYPE_STYLES[detail.itemType]}`}>
{ITEM_TYPE_LABELS[detail.itemType]}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.category}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.specification || '-'}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.unit}</div>
</div>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 (읽기 전용) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('재고번호', detail.stockNumber, true)}
{renderReadOnlyField('품목코드', detail.itemCode, true)}
{renderReadOnlyField('품목명', detail.itemName, true)}
{renderReadOnlyField('규격', detail.specification, true)}
</div>
</CardContent>
</Card>
{/* 재고 현황 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{detail.currentStock} <span className="text-base font-normal">{detail.unit}</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-lg font-medium">
{detail.safetyStock} <span className="text-sm font-normal">{detail.unit}</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="font-medium">{detail.location}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">LOT </div>
<div className="font-medium">{detail.lotCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="font-medium">{detail.lastReceiptDate}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<Badge variant="outline" className="text-xs">
{STOCK_STATUS_LABELS[detail.status]}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('단위', detail.unit, true)}
{renderReadOnlyField('계산 재고량', detail.calculatedQty, true)}
{/* LOT별 상세 재고 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium">LOT별 </CardTitle>
<div className="text-sm text-muted-foreground">
FIFO · LOT부터
{/* 실제 재고량 (수정 가능) */}
<div>
<Label htmlFor="actualQty" className="text-sm text-muted-foreground">
</Label>
<Input
id="actualQty"
type="number"
value={formData.actualQty}
onChange={(e) => handleInputChange('actualQty', e.target.value)}
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
min={0}
/>
</div>
{/* 안전재고 (수정 가능) */}
<div>
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
</Label>
<Input
id="safetyStock"
type="number"
value={formData.safetyStock}
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
min={0}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[60px] text-center">FIFO</TableHead>
<TableHead className="min-w-[100px]">LOT번호</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.lots.map((lot: LotDetail) => (
<TableRow key={lot.id}>
<TableCell className="text-center">
<div className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
{lot.fifoOrder}
</div>
</TableCell>
<TableCell className="font-medium">{lot.lotNo}</TableCell>
<TableCell>{lot.receiptDate}</TableCell>
<TableCell className="text-center">
<span className={lot.daysElapsed > 30 ? 'text-orange-600 font-medium' : ''}>
{lot.daysElapsed}
</span>
</TableCell>
<TableCell>{lot.supplier}</TableCell>
<TableCell>{lot.poNumber}</TableCell>
<TableCell className="text-center">
{lot.qty} {lot.unit}
</TableCell>
<TableCell className="text-center">{lot.location}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{LOT_STATUS_LABELS[lot.status]}
</Badge>
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={6} className="text-right">
:
</TableCell>
<TableCell className="text-center">
{totalQty} {detail.unit}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* FIFO 권장 메시지 */}
{oldestLot && oldestLot.daysElapsed > 30 && (
<div className="flex items-start gap-2 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-orange-800">
<span className="font-medium">FIFO :</span> LOT {oldestLot.lotNo}{' '}
{oldestLot.daysElapsed} . .
{/* Row 3: 상태 (수정 가능) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
</Label>
<Select
key={`useStatus-${formData.useStatus}`}
value={formData.useStatus}
onValueChange={(value) => handleInputChange('useStatus', value)}
>
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}, [detail, totalQty, oldestLot]);
}, [detail, formData]);
// 에러 상태 표시
if (!isLoading && (error || !detail)) {
@@ -283,13 +313,14 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
return (
<IntegratedDetailTemplate
config={stockStatusConfig}
mode="view"
mode={initialMode as 'view' | 'edit'}
initialData={detail || {}}
itemId={id}
isLoading={isLoading}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
isSaving={isSaving}
renderView={() => renderViewContent()}
renderForm={() => renderFormContent()}
onSave={handleSave}
/>
);
}
}

View File

@@ -10,14 +10,14 @@
* - 테이블 푸터 (요약 정보)
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Package,
CheckCircle2,
Clock,
AlertCircle,
Eye,
Loader2,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Button } from '@/components/ui/button';
@@ -29,15 +29,15 @@ import {
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type StatCard,
type ListParams,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getStocks, getStockStats, getStockStatsByType } from './actions';
import { ITEM_TYPE_LABELS, ITEM_TYPE_STYLES, STOCK_STATUS_LABELS } from './types';
import { getStocks, getStockStats } from './actions';
import { USE_STATUS_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
import { ClipboardList } from 'lucide-react';
import { StockAuditModal } from './StockAuditModal';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -45,57 +45,121 @@ const ITEMS_PER_PAGE = 20;
export function StockStatusList() {
const router = useRouter();
// ===== 통계 및 품목유형별 통계 (외부 관리) =====
// ===== 통계 (외부 관리) =====
const [stockStats, setStockStats] = useState<StockStats | null>(null);
const [typeStats, setTypeStats] = useState<Record<string, { label: string; count: number; total_qty: number | string }>>({});
const [totalItems, setTotalItems] = useState(0);
// 초기 통계 로드
useEffect(() => {
const loadStats = async () => {
try {
const [statsResult, typeStatsResult] = await Promise.all([
getStockStats(),
getStockStatsByType(),
]);
if (statsResult.success && statsResult.data) {
setStockStats(statsResult.data);
}
if (typeStatsResult.success && typeStatsResult.data) {
setTypeStats(typeStatsResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockStatusList] loadStats error:', error);
// ===== 날짜 범위 상태 =====
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
// ===== 데이터 상태 (수주관리 패턴) =====
const [stocks, setStocks] = useState<StockItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// ===== 검색 및 필터 상태 =====
const [searchTerm, setSearchTerm] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
useStatus: 'all',
});
// ===== 재고 실사 모달 상태 =====
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
const [isAuditLoading, setIsAuditLoading] = useState(false);
// 데이터 로드 함수
const loadData = useCallback(async () => {
try {
setIsLoading(true);
const [stocksResult, statsResult] = await Promise.all([
getStocks({
page: 1,
perPage: 9999, // 전체 데이터 로드 (클라이언트 사이드 필터링)
startDate,
endDate,
}),
getStockStats(),
]);
if (stocksResult.success && stocksResult.data) {
setStocks(stocksResult.data);
setTotalCount(stocksResult.pagination.total);
}
};
loadStats();
}, []);
if (statsResult.success && statsResult.data) {
setStockStats(statsResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockStatusList] loadData error:', error);
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터 로드 및 날짜 변경 시 재로드
useEffect(() => {
loadData();
}, [loadData]);
// 클라이언트 사이드 필터링
const filteredStocks = stocks.filter((stock) => {
// 검색 필터
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
// 상태 필터
const useStatusFilter = filterValues.useStatus as string;
if (useStatusFilter && useStatusFilter !== 'all') {
if (stock.useStatus !== useStatusFilter) return false;
}
return true;
});
// ===== 행 클릭 핸들러 =====
const handleRowClick = useCallback(
(item: StockItem) => {
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
},
[router]
);
const handleRowClick = (item: StockItem) => {
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
};
// ===== 재고 실사 버튼 핸들러 =====
const handleStockAudit = () => {
setIsAuditLoading(true);
// 약간의 딜레이 후 모달 오픈 (로딩 UI 표시를 위해)
setTimeout(() => {
setIsAuditModalOpen(true);
setIsAuditLoading(false);
}, 100);
};
// ===== 재고 실사 완료 핸들러 =====
const handleAuditComplete = () => {
loadData(); // 데이터 새로고침
};
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<StockItem>[] = useMemo(() => [
const excelColumns: ExcelColumn<StockItem>[] = [
{ header: '재고번호', key: 'stockNumber' },
{ header: '품목코드', key: 'itemCode' },
{ header: '품목명', key: 'itemName' },
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || String(value) },
{ header: '규격', key: 'specification' },
{ header: '단위', key: 'unit' },
{ header: '재고량', key: 'stockQty' },
{ header: '계산 재고량', key: 'calculatedQty' },
{ header: '실제 재고량', key: 'actualQty' },
{ header: '안전재고', key: 'safetyStock' },
{ header: 'LOT수', key: 'lotCount' },
{ header: 'LOT경과일', key: 'lotDaysElapsed' },
{ header: '상태', key: 'status', transform: (value) => value ? STOCK_STATUS_LABELS[value as StockStatusType] : '-' },
{ header: '위치', key: 'location' },
], []);
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
];
// ===== API 응답 매핑 함수 =====
const mapStockResponse = useCallback((result: unknown): StockItem[] => {
const mapStockResponse = (result: unknown): StockItem[] => {
const data = result as { data?: { data?: Record<string, unknown>[] } };
const rawItems = data.data?.data ?? [];
return rawItems.map((item: Record<string, unknown>) => {
@@ -103,311 +167,324 @@ export function StockStatusList() {
const hasStock = !!stock;
return {
id: String(item.id ?? ''),
stockNumber: hasStock ? (String(stock?.stock_number ?? stock?.id ?? item.id)) : String(item.id ?? ''),
itemCode: (item.code ?? '') as string,
itemName: (item.name ?? '') as string,
itemType: (item.item_type ?? 'RM') as ItemType,
specification: (item.specification ?? item.attributes ?? '') as string,
unit: (item.unit ?? 'EA') as string,
calculatedQty: hasStock ? (parseFloat(String(stock?.calculated_qty ?? stock?.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
lotCount: hasStock ? (Number(stock?.lot_count) || 0) : 0,
lotDaysElapsed: hasStock ? (Number(stock?.days_elapsed) || 0) : 0,
status: hasStock ? (stock?.status as StockStatusType | null) : null,
useStatus: (item.is_active === false || item.status === 'inactive') ? 'inactive' : 'active',
location: hasStock ? ((stock?.location as string) || '-') : '-',
hasStock,
};
});
}, []);
};
// ===== 통계 카드 =====
const stats: StatCard[] = useMemo(
() => [
{
label: '전체 품목',
value: `${stockStats?.totalItems || 0}`,
icon: Package,
iconColor: 'text-gray-600',
},
{
label: '정상 재고',
value: `${stockStats?.normalCount || 0}`,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '재고 부족',
value: `${stockStats?.lowCount || 0}`,
icon: Clock,
iconColor: 'text-orange-600',
},
{
label: '재고 없음',
value: `${stockStats?.outCount || 0}`,
icon: AlertCircle,
iconColor: 'text-red-600',
},
],
[stockStats]
);
const stats = [
{
label: '전체 품목',
value: `${stockStats?.totalItems || 0}`,
icon: Package,
iconColor: 'text-gray-600',
},
{
label: '정상 재고',
value: `${stockStats?.normalCount || 0}`,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '재고부족',
value: `${stockStats?.lowCount || 0}`,
icon: AlertCircle,
iconColor: 'text-red-600',
},
];
// ===== 탭 옵션 (기본 탭 + 품목유형별 통계) =====
const tabs: TabOption[] = useMemo(() => {
// 기본 탭 정의 (Item 모델의 MATERIAL_TYPES: RM, SM, CS)
const defaultTabs: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'RM', label: '원자재' },
{ value: 'SM', label: '부자재' },
{ value: 'CS', label: '소모품' },
];
// ===== 필터 설정 (전체/사용/미사용) =====
const filterConfig: FilterFieldConfig[] = [
{
key: 'useStatus',
label: '상태',
type: 'select',
options: [
{ value: 'all', label: '전체' },
{ value: 'active', label: '사용' },
{ value: 'inactive', label: '미사용' },
],
defaultValue: 'all',
},
];
return defaultTabs.map((tab) => {
if (tab.value === 'all') {
return { ...tab, count: stockStats?.totalItems || 0 };
}
const stat = typeStats[tab.value];
const count = typeof stat?.count === 'number' ? stat.count : 0;
return { ...tab, count };
});
}, [typeStats, stockStats?.totalItems]);
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'stockNumber', label: '재고번호', className: 'w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'calculatedQty', label: '계산 재고량', className: 'w-[100px] text-center' },
{ key: 'actualQty', label: '실제 재고량', className: 'w-[100px] text-center' },
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
];
// ===== 테이블 푸터 =====
const tableFooter = useMemo(() => {
const lowStockCount = stockStats?.lowCount || 0;
// ===== 테이블 행 렌더링 =====
const renderTableRow = (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={12} className="py-3">
<span className="text-sm text-muted-foreground">
{totalItems} / {lowStockCount}
<TableRow
key={item.id}
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.stockNumber}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">{item.calculatedQty}</TableCell>
<TableCell className="text-center">{item.actualQty}</TableCell>
<TableCell className="text-center">{item.safetyStock}</TableCell>
<TableCell className="text-center">
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
{USE_STATUS_LABELS[item.useStatus]}
</span>
</TableCell>
</TableRow>
);
}, [totalItems, stockStats?.lowCount]);
};
// ===== UniversalListPage Config =====
const config: UniversalListConfig<StockItem> = useMemo(
() => ({
// 페이지 기본 정보
title: '재고 목록',
description: '재고현황 관리',
icon: Package,
basePath: '/material/stock-status',
// ID 추출
idField: 'id',
// API 액션 (서버 사이드 페이지네이션)
actions: {
getList: async (params?: ListParams) => {
try {
const result = await getStocks({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
itemType: params?.tab !== 'all' ? (params?.tab as ItemType) : undefined,
search: params?.search || undefined,
});
if (result.success) {
// 통계 및 품목유형별 통계 다시 로드
const [statsResult, typeStatsResult] = await Promise.all([
getStockStats(),
getStockStatsByType(),
]);
if (statsResult.success && statsResult.data) {
setStockStats(statsResult.data);
}
if (typeStatsResult.success && typeStatsResult.data) {
setTypeStats(typeStatsResult.data);
}
// totalItems 업데이트 (푸터용)
setTotalItems(result.pagination.total);
return {
success: true,
data: result.data,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
}
},
},
// 테이블 컬럼
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[200px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'stockQty', label: '재고량', className: 'w-[80px] text-center' },
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
{ key: 'lot', label: 'LOT', className: 'w-[100px] text-center' },
{ key: 'status', label: '상태', className: 'w-[60px] text-center' },
{ key: 'location', label: '위치', className: 'w-[60px] text-center' },
],
// 서버 사이드 페이지네이션
clientSideFiltering: false,
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '품목코드, 품목명 검색...',
// 탭 설정
tabs,
defaultTab: 'all',
// 통계 카드
stats,
// 테이블 푸터
tableFooter,
// 엑셀 다운로드 설정
excelDownload: {
columns: excelColumns,
filename: '재고현황',
sheetName: '재고',
fetchAllUrl: '/api/proxy/stocks',
fetchAllParams: ({ activeTab, searchValue }) => {
const params: Record<string, string> = {};
if (activeTab && activeTab !== 'all') {
params.item_type = activeTab;
}
if (searchValue) {
params.search = searchValue;
}
return params;
},
mapResponse: mapStockResponse,
},
// 테이블 행 렌더링
renderTableRow: (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<TableRow
key={item.id}
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleRowClick(item)}
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<ListMobileCard
key={item.id}
id={item.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
onClick={() => handleRowClick(item)}
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.stockNumber}</Badge>
</>
}
title={item.itemName}
statusBadge={
<Badge
variant="outline"
className={`text-xs ${item.useStatus === 'inactive' ? 'text-gray-400' : ''}`}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.itemCode}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.itemName}</TableCell>
<TableCell>
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
{ITEM_TYPE_LABELS[item.itemType]}
</Badge>
</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">{item.stockQty}</TableCell>
<TableCell className="text-center">{item.safetyStock}</TableCell>
<TableCell className="text-center">
<div className="flex flex-col items-center">
<span>{item.lotCount}</span>
{item.lotDaysElapsed > 0 && (
<span className="text-xs text-muted-foreground">
{item.lotDaysElapsed}
</span>
)}
</div>
</TableCell>
<TableCell className="text-center">
{item.status ? (
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
{STOCK_STATUS_LABELS[item.status]}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">{item.location}</TableCell>
</TableRow>
);
},
{USE_STATUS_LABELS[item.useStatus]}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="규격" value={item.specification || '-'} />
<InfoField label="단위" value={item.unit} />
<InfoField label="계산 재고량" value={`${item.calculatedQty}`} />
<InfoField label="실제 재고량" value={`${item.actualQty}`} />
<InfoField label="안전재고" value={`${item.safetyStock}`} />
</div>
}
actions={
handlers.isSelected && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item);
}}
>
<Eye className="w-4 h-4 mr-1" />
</Button>
</div>
)
}
/>
);
};
// 모바일 카드 렌더링
renderMobileCard: (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<ListMobileCard
key={item.id}
id={item.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
onClick={() => handleRowClick(item)}
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.itemCode}</Badge>
</>
}
title={item.itemName}
statusBadge={
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
{ITEM_TYPE_LABELS[item.itemType]}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="단위" value={item.unit} />
<InfoField label="위치" value={item.location} />
<InfoField label="재고량" value={`${item.stockQty}`} />
<InfoField label="안전재고" value={`${item.safetyStock}`} />
<InfoField
label="LOT"
value={`${item.lotCount}${item.lotDaysElapsed > 0 ? ` (${item.lotDaysElapsed}일 경과)` : ''}`}
/>
<InfoField
label="상태"
value={item.status ? STOCK_STATUS_LABELS[item.status] : '-'}
className={item.status === 'low' ? 'text-orange-600' : ''}
/>
</div>
}
actions={
handlers.isSelected && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item);
}}
>
<Eye className="w-4 h-4 mr-1" />
</Button>
</div>
)
}
/>
);
// ===== UniversalListPage Config (수주관리 패턴 - useMemo 없음) =====
const config: UniversalListConfig<StockItem> = {
title: '재고 목록',
description: '재고를 관리합니다',
icon: Package,
basePath: '/material/stock-status',
idField: 'id',
// 클라이언트 사이드 필터링 (수주관리 패턴)
actions: {
getList: async () => ({
success: true,
data: filteredStocks,
totalCount: filteredStocks.length,
}),
},
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '품목코드, 품목명 검색...',
// 검색 필터 함수
searchFilter: (stock, searchValue) => {
const searchLower = searchValue.toLowerCase();
return (
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower)
);
},
// 커스텀 필터 함수
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
const useStatusVal = fv.useStatus as string;
if (useStatusVal && useStatusVal !== 'all' && item.useStatus !== useStatusVal) {
return false;
}
return true;
});
},
// 날짜 범위 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 필터 설정
filterConfig,
initialFilters: filterValues,
// 통계
computeStats: () => stats,
// 헤더 액션 버튼
headerActions: () => (
<Button
variant="default"
size="sm"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={handleStockAudit}
disabled={isAuditLoading}
>
{isAuditLoading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<ClipboardList className="w-4 h-4 mr-1" />
)}
{isAuditLoading ? '로딩 중...' : '재고 실사'}
</Button>
),
// 테이블 푸터
tableFooter: (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={12} className="py-3">
<span className="text-sm text-muted-foreground">
{filteredStocks.length}
</span>
</TableCell>
</TableRow>
),
// 엑셀 다운로드 설정
excelDownload: {
columns: excelColumns,
filename: '재고현황',
sheetName: '재고',
fetchAllUrl: '/api/proxy/stocks',
fetchAllParams: ({ searchValue, filters }) => {
const params: Record<string, string> = {};
if (filters?.useStatus && filters.useStatus !== 'all') {
params.use_status = filters.useStatus as string;
}
if (searchValue) {
params.search = searchValue;
}
params.start_date = startDate;
params.end_date = endDate;
return params;
},
}),
[tabs, stats, tableFooter, handleRowClick, excelColumns, mapStockResponse]
mapResponse: mapStockResponse,
},
renderTableRow,
renderMobileCard,
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<UniversalListPage<StockItem>
config={config}
initialData={filteredStocks}
initialTotalCount={filteredStocks.length}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
onSearchChange={setSearchTerm}
/>
{/* 재고 실사 모달 */}
<StockAuditModal
open={isAuditModalOpen}
onOpenChange={setIsAuditModalOpen}
stocks={stocks}
onComplete={handleAuditComplete}
/>
</>
);
return <UniversalListPage config={config} />;
}
}

View File

@@ -114,17 +114,33 @@ function transformApiToListItem(data: ItemApiData): StockItem {
const stock = data.stock;
const hasStock = !!stock;
// description 또는 attributes에서 규격 정보 추출
let specification = '';
if (data.description) {
specification = data.description;
} else if (data.attributes && typeof data.attributes === 'object') {
const attrs = data.attributes as Record<string, unknown>;
if (attrs.specification) {
specification = String(attrs.specification);
}
}
return {
id: String(data.id),
stockNumber: hasStock ? String((stock as unknown as Record<string, unknown>).stock_number ?? stock.id ?? data.id) : String(data.id),
itemCode: data.code,
itemName: data.name,
itemType: data.item_type,
specification,
unit: data.unit || 'EA',
calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).calculated_qty ?? stock.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? stock.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
lotCount: hasStock ? (stock.lot_count || 0) : 0,
lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
status: hasStock ? stock.status : null,
useStatus: data.is_active === false ? 'inactive' : 'active',
location: hasStock ? (stock.location || '-') : '-',
hasStock,
};
@@ -210,9 +226,12 @@ export async function getStocks(params?: {
search?: string;
itemType?: string;
status?: string;
useStatus?: string;
location?: string;
sortBy?: string;
sortDir?: string;
startDate?: string;
endDate?: string;
}): Promise<{
success: boolean;
data: StockItem[];
@@ -232,9 +251,14 @@ export async function getStocks(params?: {
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.useStatus && params.useStatus !== 'all') {
searchParams.set('is_active', params.useStatus === 'active' ? '1' : '0');
}
if (params?.location) searchParams.set('location', params.location);
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks${queryString ? `?${queryString}` : ''}`;
@@ -410,3 +434,99 @@ export async function getStockById(id: string): Promise<{
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 재고 단건 수정 =====
export async function updateStock(
id: string,
data: {
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actual_qty: data.actualQty,
safety_stock: data.safetyStock,
is_active: data.useStatus === 'active',
}),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '재고 수정에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] updateStock error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 재고 실사 (일괄 업데이트) =====
export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/audit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: updates.map((u) => ({
item_id: u.id,
actual_qty: u.actualQty,
})),
}),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 실사 저장에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '재고 실사 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] updateStockAudit error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -4,25 +4,24 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
/**
* 재고현황 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView에서 처리
*
* 특이사항:
* - view 모드만 지원 (edit 없음)
* - LOT별 상세 재고 테이블
* - FIFO 권장 메시지
* 기획서 기준:
* - 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
* - 실제 재고량, 안전재고, 상태 (수정 가능)
*/
export const stockStatusConfig: DetailConfig = {
title: '재고 상세',
description: '재고 정보를 조회합니다',
description: '재고 상세를 관리합니다',
icon: Package,
basePath: '/material/stock-status',
fields: [], // renderView 사용으로 필드 정의 불필요
gridColumns: 3,
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 4,
actions: {
showBack: true,
showDelete: false,
showEdit: false, // 수정 기능 없음
showEdit: true,
backLabel: '목록',
editLabel: '수정',
saveLabel: '저장',
cancelLabel: '취소',
},
};

View File

@@ -43,19 +43,30 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
// 재고 목록 아이템 (Item 기준 + Stock 정보)
export interface StockItem {
id: string;
stockNumber: string; // 재고번호 (Stock.stock_number)
itemCode: string; // Item.code
itemName: string; // Item.name
itemType: ItemType; // Item.item_type (RM, SM, CS)
specification: string; // 규격 (Item.specification 또는 attributes)
unit: string; // Item.unit
calculatedQty: number; // 계산 재고량 (Stock.calculated_qty)
actualQty: number; // 실제 재고량 (Stock.actual_qty)
stockQty: number; // Stock.stock_qty (없으면 0)
safetyStock: number; // Stock.safety_stock (없으면 0)
lotCount: number; // Stock.lot_count (없으면 0)
lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
status: StockStatusType | null; // Stock.status (없으면 null)
useStatus: 'active' | 'inactive'; // 사용/미사용 상태
location: string; // Stock.location (없으면 '-')
hasStock: boolean; // Stock 데이터 존재 여부
}
// 사용 상태 라벨
export const USE_STATUS_LABELS: Record<'active' | 'inactive', string> = {
active: '사용',
inactive: '미사용',
};
// LOT별 상세 재고
export interface LotDetail {
id: string;

View File

@@ -6,8 +6,7 @@
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
* - 견적 불러오기 섹션
* - 기본 정보 섹션
* - 수주/배송 정보 섹션
* - 수신처 주소 섹션
* - 수주/배송 정보 섹션 (주소 포함)
* - 비고 섹션
* - 품목 내역 섹션
*/
@@ -46,8 +45,6 @@ import {
X,
Plus,
Trash2,
Info,
MapPin,
Truck,
Package,
MessageSquare,
@@ -133,6 +130,8 @@ const DELIVERY_METHODS = [
{ value: "direct", label: "직접배차" },
{ value: "pickup", label: "상차" },
{ value: "courier", label: "택배" },
{ value: "self", label: "직접수령" },
{ value: "freight", label: "화물" },
];
// 운임비용 옵션
@@ -162,11 +161,11 @@ interface FieldErrors {
// 필드명 한글 매핑
const FIELD_NAME_MAP: Record<string, string> = {
clientName: "주처",
clientName: "주처",
siteName: "현장명",
deliveryRequestDate: "납품요청일",
receiver: "수신(반장/업체)",
receiverContact: "수신처 연락처",
receiver: "수신",
receiverContact: "수신처",
items: "품목 내역",
};
@@ -456,13 +455,34 @@ export function OrderRegistration({
{/* 기본 정보 섹션 */}
<FormSection
title="기본 정보"
description="주처 및 현장 정보를 입력하세요"
description="주처 및 현장 정보를 입력하세요"
icon={FileText}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 첫 번째 줄: 로트번호, 접수일, 수주처, 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value=""
disabled
className="bg-muted"
placeholder="자동 생성"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value=""
disabled
className="bg-muted"
placeholder="자동 생성"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<Select
value={form.clientId}
@@ -478,8 +498,8 @@ export function OrderRegistration({
disabled={!!form.selectedQuotation || isClientsLoading}
>
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
<SelectValue placeholder={isClientsLoading ? "불러오는 중..." : "주처 선택"}>
{form.clientName || (isClientsLoading ? "불러오는 중..." : "주처 선택")}
<SelectValue placeholder={isClientsLoading ? "불러오는 중..." : "주처 선택"}>
{form.clientName || (isClientsLoading ? "불러오는 중..." : "주처 선택")}
</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -514,6 +534,7 @@ export function OrderRegistration({
)}
</div>
{/* 두 번째 줄: 담당자, 연락처, 상태 */}
<div className="space-y-2">
<Label></Label>
<Input
@@ -535,6 +556,16 @@ export function OrderRegistration({
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value=""
disabled
className="bg-muted"
placeholder="자동 생성"
/>
</div>
</div>
</FormSection>
@@ -544,93 +575,55 @@ export function OrderRegistration({
description="출고 및 배송 정보를 입력하세요"
icon={Truck}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 출고예정일 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 첫 번째 줄: 수주일, 납품요청일, 출고예정일, 배송방식 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm((prev) => ({
...prev,
expectedShipDate: e.target.value,
}))
}
disabled={form.expectedShipDateUndecided}
className="flex-1"
/>
<div className="flex items-center gap-2">
<Checkbox
id="expectedShipDateUndecided"
checked={form.expectedShipDateUndecided}
onCheckedChange={(checked) =>
setForm((prev) => ({
...prev,
expectedShipDateUndecided: checked as boolean,
expectedShipDate: checked ? "" : prev.expectedShipDate,
}))
}
/>
<Label
htmlFor="expectedShipDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
<Label></Label>
<Input
value=""
disabled
className="bg-muted"
placeholder="자동 생성"
/>
</div>
{/* 납품요청일 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.deliveryRequestDate}
onChange={(e) => {
setForm((prev) => ({
...prev,
deliveryRequestDate: e.target.value,
}));
clearFieldError("deliveryRequestDate");
}}
disabled={form.deliveryRequestDateUndecided}
className={cn("flex-1", fieldErrors.deliveryRequestDate && "border-red-500")}
/>
<div className="flex items-center gap-2">
<Checkbox
id="deliveryRequestDateUndecided"
checked={form.deliveryRequestDateUndecided}
onCheckedChange={(checked) => {
setForm((prev) => ({
...prev,
deliveryRequestDateUndecided: checked as boolean,
deliveryRequestDate: checked
? ""
: prev.deliveryRequestDate,
}));
if (checked) clearFieldError("deliveryRequestDate");
}}
/>
<Label
htmlFor="deliveryRequestDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
<Input
type="date"
value={form.deliveryRequestDate}
onChange={(e) => {
setForm((prev) => ({
...prev,
deliveryRequestDate: e.target.value,
}));
clearFieldError("deliveryRequestDate");
}}
disabled={form.deliveryRequestDateUndecided}
className={cn(fieldErrors.deliveryRequestDate && "border-red-500")}
/>
{fieldErrors.deliveryRequestDate && (
<p className="text-sm text-red-500">{fieldErrors.deliveryRequestDate}</p>
)}
</div>
{/* 배송방식 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm((prev) => ({
...prev,
expectedShipDate: e.target.value,
}))
}
disabled={form.expectedShipDateUndecided}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
@@ -640,7 +633,7 @@ export function OrderRegistration({
}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DELIVERY_METHODS.map((method) => (
@@ -652,7 +645,7 @@ export function OrderRegistration({
</Select>
</div>
{/* 운임비용 */}
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
<div className="space-y-2">
<Label></Label>
<Select
@@ -662,7 +655,7 @@ export function OrderRegistration({
}
>
<SelectTrigger>
<SelectValue placeholder="운임비용 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SHIPPING_COSTS.map((cost) => (
@@ -674,10 +667,9 @@ export function OrderRegistration({
</Select>
</div>
{/* 수신(반장/업체) */}
<div className="space-y-2">
<Label>
(/) <span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<Input
placeholder="수신자명 입력"
@@ -693,18 +685,17 @@ export function OrderRegistration({
)}
</div>
{/* 수신처 연락처 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<PhoneInput
placeholder="010-0000-0000"
<Input
placeholder="수신처 입력"
value={form.receiverContact}
onChange={(value) => {
onChange={(e) => {
setForm((prev) => ({
...prev,
receiverContact: value,
receiverContact: e.target.value,
}));
clearFieldError("receiverContact");
}}
@@ -714,43 +705,32 @@ export function OrderRegistration({
<p className="text-sm text-red-500">{fieldErrors.receiverContact}</p>
)}
</div>
</div>
</FormSection>
{/* 수신처 주소 섹션 */}
<FormSection
title="수신처 주소"
description="배송지 주소를 입력하세요"
icon={MapPin}
>
<div className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="우편번호"
value={form.zipCode}
onChange={(e) =>
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
}
className="w-32"
/>
<Button variant="outline" type="button" onClick={openPostcode}>
</Button>
{/* 주소 - 전체 너비 사용 */}
<div className="space-y-2 md:col-span-4">
<Label></Label>
<div className="flex gap-2">
<Button variant="outline" type="button" onClick={openPostcode}>
</Button>
<Input
placeholder="우편번호"
value={form.zipCode}
onChange={(e) =>
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
}
className="w-32"
/>
<Input
placeholder="주소"
value={form.address}
onChange={(e) =>
setForm((prev) => ({ ...prev, address: e.target.value }))
}
className="flex-1"
/>
</div>
</div>
<Input
placeholder="기본 주소"
value={form.address}
onChange={(e) =>
setForm((prev) => ({ ...prev, address: e.target.value }))
}
/>
<Input
placeholder="상세 주소 입력"
value={form.addressDetail}
onChange={(e) =>
setForm((prev) => ({ ...prev, addressDetail: e.target.value }))
}
/>
</div>
</FormSection>

View File

@@ -33,7 +33,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, ChevronsUpDown, Package } from "lucide-react";
import { toast } from "sonner";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { orderSalesConfig } from "./orderSalesConfig";
@@ -50,6 +50,7 @@ import {
interface EditFormData {
// 읽기전용 정보
lotNumber: string;
orderDate: string; // 접수일
quoteNumber: string;
client: string;
siteName: string;
@@ -75,6 +76,16 @@ interface EditFormData {
subtotal: number;
discountRate: number;
totalAmount: number;
// 제품 정보 (아코디언용)
products: Array<{
productName: string;
productCategory?: string;
openWidth?: string;
openHeight?: string;
quantity: number;
floor?: string;
code?: string;
}>;
}
// 배송방식 옵션
@@ -82,6 +93,8 @@ const DELIVERY_METHODS = [
{ value: "direct", label: "직접배차" },
{ value: "pickup", label: "상차" },
{ value: "courier", label: "택배" },
{ value: "self", label: "직접수령" },
{ value: "freight", label: "화물" },
];
// 운임비용 옵션
@@ -126,6 +139,77 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
const [form, setForm] = useState<EditFormData | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 제품-부품 트리 토글
const toggleProduct = (key: string) => {
setExpandedProducts((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// 모든 제품 확장
const expandAllProducts = () => {
if (form?.products) {
const allKeys = form.products.map((p) => `${p.floor || ""}-${p.code || ""}`);
allKeys.push("other-parts"); // 기타부품도 포함
setExpandedProducts(new Set(allKeys));
}
};
// 모든 제품 축소
const collapseAllProducts = () => {
setExpandedProducts(new Set());
};
// 제품별로 부품 그룹화 (floor_code, symbol_code 매칭)
const getItemsForProduct = (floor: string | undefined, code: string | undefined) => {
if (!form?.items) return [];
return form.items.filter((item) => {
const itemFloor = item.type || "";
const itemSymbol = item.symbol || "";
const productFloor = floor || "";
const productCode = code || "";
return itemFloor === productFloor && itemSymbol === productCode;
});
};
// 매칭되지 않은 부품 (orphan items)
const getUnmatchedItems = () => {
if (!form?.items || !form?.products) return form?.items || [];
const matchedIds = new Set<string>();
form.products.forEach((product) => {
const items = getItemsForProduct(product.floor, product.code);
items.forEach((item) => matchedIds.add(item.id));
});
return form.items.filter((item) => !matchedIds.has(item.id));
};
/**
* 수량 포맷 함수
* - EA, SET, PCS 등 개수 단위: 정수로 표시
* - M, M2, KG, L 등 측정 단위: 소수점 이하 불필요한 0 제거
*/
const formatQuantity = (quantity: number, unit?: string): string => {
const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"];
const upperUnit = (unit || "").toUpperCase();
if (countableUnits.includes(upperUnit)) {
return Math.round(quantity).toLocaleString();
}
const rounded = Math.round(quantity * 10000) / 10000;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 4
});
};
// 데이터 로드 (API)
useEffect(() => {
@@ -142,6 +226,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
// Order 데이터를 EditFormData로 변환
setForm({
lotNumber: order.lotNumber,
orderDate: order.orderDate || "",
quoteNumber: order.quoteNumber || "",
client: order.client,
siteName: order.siteName,
@@ -163,6 +248,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
subtotal: order.subtotal || order.amount,
discountRate: order.discountRate || 0,
totalAmount: order.amount,
products: order.products || [],
});
} else {
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
@@ -193,10 +279,10 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
return { success: false, error: "납품요청일을 입력해주세요." };
}
if (!form.receiver.trim()) {
return { success: false, error: "수신(반장/업체)을 입력해주세요." };
return { success: false, error: "수신자를 입력해주세요." };
}
if (!form.receiverContact.trim()) {
return { success: false, error: "수신처 연락처를 입력해주세요." };
return { success: false, error: "수신처를 입력해주세요." };
}
try {
@@ -279,30 +365,34 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.lotNumber}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.quoteNumber}</p>
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.orderDate || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.client}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.siteName}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.contact}</p>
<p className="font-medium">{form.contact || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<div className="mt-1">{getOrderStatusBadge(form.status)}</div>
</div>
</div>
</CardContent>
@@ -314,43 +404,17 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 출고예정일 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 첫 번째 줄: 수주일, 납품요청일, 출고예정일, 배송방식 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm({ ...form, expectedShipDate: e.target.value })
}
disabled={form.expectedShipDateUndecided}
className="flex-1"
/>
<div className="flex items-center gap-2">
<Checkbox
id="expectedShipDateUndecided"
checked={form.expectedShipDateUndecided}
onCheckedChange={(checked) =>
setForm({
...form,
expectedShipDateUndecided: checked as boolean,
expectedShipDate: checked ? "" : form.expectedShipDate,
})
}
/>
<Label
htmlFor="expectedShipDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
<Label className="text-muted-foreground"></Label>
<Input
value={form.orderDate || ""}
disabled
className="bg-muted"
/>
</div>
{/* 납품요청일 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
@@ -364,7 +428,18 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
/>
</div>
{/* 배송방식 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm({ ...form, expectedShipDate: e.target.value })
}
disabled={form.expectedShipDateUndecided}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
@@ -375,7 +450,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DELIVERY_METHODS.map((method) => (
@@ -387,7 +462,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</Select>
</div>
{/* 운임비용 */}
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
<div className="space-y-2">
<Label></Label>
<Select
@@ -398,7 +473,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
}
>
<SelectTrigger>
<SelectValue placeholder="운임비용 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SHIPPING_COSTS.map((cost) => (
@@ -410,50 +485,45 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</Select>
</div>
{/* 수신(반장/업체) */}
<div className="space-y-2">
<Label>
(/) <span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiver}
onChange={(e) =>
setForm({ ...form, receiver: e.target.value })
}
placeholder="수신자명 입력"
/>
</div>
{/* 수신처 연락처 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<PhoneInput
<Input
value={form.receiverContact}
onChange={(value) =>
setForm({ ...form, receiverContact: value })
onChange={(e) =>
setForm({ ...form, receiverContact: e.target.value })
}
placeholder="수신처 입력"
/>
</div>
{/* 수신처 주소 */}
<div className="space-y-2 md:col-span-2">
<Label> </Label>
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="mb-2"
/>
<Input
value={form.addressDetail}
onChange={(e) =>
setForm({ ...form, addressDetail: e.target.value })
}
placeholder="상세주소"
/>
{/* 주소 - 전체 너비 */}
<div className="space-y-2 md:col-span-4">
<Label></Label>
<div className="flex gap-2">
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
@@ -474,84 +544,182 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</CardContent>
</Card>
{/* 품목 내역 */}
{/* 제품내용 (아코디언) */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
)}
</CardTitle>
{form.products && form.products.length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={expandAllProducts}
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
<ChevronsUpDown className="h-3 w-3" />
</button>
<button
type="button"
onClick={collapseAllProducts}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>(mm)</TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{form.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.type || "-"}</TableCell>
<TableCell>{item.symbol || "-"}</TableCell>
<TableCell>{item.spec}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{form.products && form.products.length > 0 ? (
<div className="space-y-3">
{form.products.map((product, productIndex) => {
const productKey = `${product.floor || ""}-${product.code || ""}`;
const isExpanded = expandedProducts.has(productKey);
const productItems = getItemsForProduct(product.floor, product.code);
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">
{formatAmount(form.subtotal)}
</span>
return (
<div
key={productIndex}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* 제품 헤더 (클릭하면 확장/축소) */}
<button
type="button"
onClick={() => toggleProduct(productKey)}
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">
{isExpanded ? (
<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-blue-600" />
<div>
<span className="font-medium">{product.productName}</span>
{product.openWidth && product.openHeight && (
<span className="ml-2 text-sm text-gray-500">
({product.openWidth} × {product.openHeight})
</span>
)}
</div>
</div>
<span className="text-sm text-gray-500">
{productItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
{isExpanded && (
<div className="border-t">
{productItems.length > 0 ? (
<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>
{productItems.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 className="p-4 text-center text-gray-400 text-sm">
</div>
)}
</div>
)}
</div>
);
})}
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">{form.discountRate}%</span>
</div>
<div className="flex items-center gap-4 text-lg font-semibold">
<span>:</span>
<span className="w-32 text-right text-green-600">
{formatAmount(form.totalAmount)}
</span>
</div>
</div>
) : null}
</CardContent>
</Card>
{/* 기타부품 (아코디언) */}
{(() => {
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>
<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>
);
}, [form]);

View File

@@ -11,11 +11,12 @@ import { DocumentViewer } from "@/components/document-system";
import { ContractDocument } from "./ContractDocument";
import { TransactionDocument } from "./TransactionDocument";
import { PurchaseOrderDocument } from "./PurchaseOrderDocument";
import { SalesOrderDocument } from "./SalesOrderDocument";
import { OrderItem } from "../actions";
import { getCompanyInfo } from "@/components/settings/CompanyInfoManagement/actions";
// 문서 타입
export type OrderDocumentType = "contract" | "transaction" | "purchaseOrder";
export type OrderDocumentType = "contract" | "transaction" | "purchaseOrder" | "salesOrder";
// 제품 정보 타입 (견적의 calculation_inputs에서 추출)
export interface ProductInfo {
@@ -46,6 +47,13 @@ export interface OrderDocumentData {
discountRate?: number;
totalAmount?: number;
remarks?: string;
// 수주서 전용 필드
documentNumber?: string;
certificationNumber?: string;
recipientName?: string;
recipientContact?: string;
shutterCount?: number;
fee?: number;
}
interface OrderDocumentModalProps {
@@ -97,6 +105,8 @@ export function OrderDocumentModal({
return "거래명세서";
case "purchaseOrder":
return "발주서";
case "salesOrder":
return "수주서";
default:
return "문서";
}
@@ -154,6 +164,30 @@ export function OrderDocumentModal({
remarks={data.remarks}
/>
);
case "salesOrder":
return (
<SalesOrderDocument
documentNumber={data.documentNumber}
orderNumber={data.lotNumber}
certificationNumber={data.certificationNumber}
orderDate={data.orderDate}
client={data.client}
siteName={data.siteName}
manager={data.manager}
managerContact={data.managerContact}
deliveryRequestDate={data.deliveryRequestDate}
expectedShipDate={data.expectedShipDate}
deliveryMethod={data.deliveryMethod}
address={data.address}
recipientName={data.recipientName}
recipientContact={data.recipientContact}
shutterCount={data.shutterCount}
fee={data.fee}
items={data.items || []}
products={data.products}
remarks={data.remarks}
/>
);
default:
return null;
}

View File

@@ -0,0 +1,647 @@
"use client";
/**
* 수주서 문서 컴포넌트
* - 스크린샷 기반 디자인
* - 제목 좌측, 결재란 우측
* - 신청업체/신청내용/납품정보 3열 구조
* - 스크린, 모터, 절곡물 테이블
*/
import { getTodayString } from "@/utils/date";
import { OrderItem } from "../actions";
import { ProductInfo } from "./OrderDocumentModal";
interface SalesOrderDocumentProps {
documentNumber?: string;
orderNumber: string; // 로트번호
certificationNumber?: string; // 인정번호
orderDate?: string;
client: string;
siteName?: string;
manager?: string;
managerContact?: string;
deliveryRequestDate?: string;
expectedShipDate?: string;
deliveryMethod?: string;
address?: string;
recipientName?: string;
recipientContact?: string;
shutterCount?: number;
items?: OrderItem[];
products?: ProductInfo[];
remarks?: string;
}
/**
* 수량 포맷 함수
*/
function formatQuantity(quantity: number, unit?: string): string {
const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"];
const upperUnit = (unit || "").toUpperCase();
if (countableUnits.includes(upperUnit)) {
return Math.round(quantity).toLocaleString();
}
const rounded = Math.round(quantity * 10000) / 10000;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 4
});
}
export function SalesOrderDocument({
documentNumber = "ABC123",
orderNumber,
certificationNumber = "-",
orderDate = getTodayString(),
client,
siteName = "-",
manager = "-",
managerContact = "-",
deliveryRequestDate = "-",
expectedShipDate = "-",
deliveryMethod = "상차",
address = "-",
recipientName = "-",
recipientContact = "-",
shutterCount = 0,
items = [],
products = [],
remarks,
}: SalesOrderDocumentProps) {
// 스크린 제품만 필터링
const screenProducts = products.filter(p =>
p.productCategory?.includes("스크린") ||
p.productName?.includes("스크린") ||
p.productName?.includes("방화") ||
p.productName?.includes("셔터")
);
// 모터 아이템 필터링
const motorItems = items.filter(item =>
item.itemName?.toLowerCase().includes("모터") ||
item.type?.includes("모터") ||
item.itemCode?.startsWith("MT")
);
// 브라켓 아이템 필터링
const bracketItems = items.filter(item =>
item.itemName?.includes("브라켓") ||
item.type?.includes("브라켓")
);
// 가이드레일 아이템 필터링
const guideRailItems = items.filter(item =>
item.itemName?.includes("가이드") ||
item.itemName?.includes("레일") ||
item.type?.includes("가이드")
);
// 케이스 아이템 필터링
const caseItems = items.filter(item =>
item.itemName?.includes("케이스") ||
item.itemName?.includes("셔터박스") ||
item.type?.includes("케이스")
);
// 하단마감재 아이템 필터링
const bottomFinishItems = items.filter(item =>
item.itemName?.includes("하단") ||
item.itemName?.includes("마감") ||
item.type?.includes("하단마감")
);
return (
<div className="bg-white p-8 min-h-full text-[11px]">
{/* 헤더: 수주서 제목 (좌측) + 결재란 (우측) */}
<div className="flex justify-between items-start mb-4">
{/* 수주서 제목 (좌측) */}
<div>
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<div className="text-[10px] space-y-1">
<div className="flex gap-4">
<span>: <strong>{documentNumber}</strong></span>
<span>: <strong>{orderDate}</strong></span>
</div>
</div>
</div>
{/* 결재란 (우측) */}
<table className="border border-gray-400 text-[10px]">
<thead>
<tr className="bg-gray-100">
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="px-3 py-1"></th>
</tr>
</thead>
<tbody>
<tr className="border-t border-gray-400">
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center"></td>
<td className="h-6 w-12 text-center"></td>
</tr>
<tr className="border-t border-gray-400">
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]"></td>
<td className="h-6 w-12 text-center text-[9px]"></td>
</tr>
<tr className="border-t border-gray-400">
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500"></td>
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500"></td>
<td className="h-6 w-12 text-center text-[9px] text-gray-500"></td>
</tr>
</tbody>
</table>
</div>
{/* 로트번호 / 인정번호 */}
<div className="flex justify-end gap-8 mb-3 text-[10px]">
<div className="flex items-center gap-2 border border-gray-400 px-3 py-1">
<span className="bg-gray-100 px-2 py-0.5 font-medium"></span>
<span>{orderNumber}</span>
</div>
<div className="flex items-center gap-2 border border-gray-400 px-3 py-1">
<span className="bg-gray-100 px-2 py-0.5 font-medium"></span>
<span>{certificationNumber}</span>
</div>
</div>
{/* 상품명/제품명 라인 */}
<div className="flex gap-4 mb-3 text-[10px]">
<div className="flex items-center gap-2">
<span className="font-medium"></span>
<span>{products[0]?.productCategory || "screen"}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium"></span>
<span>{products[0]?.productName || "-"}</span>
</div>
</div>
{/* 3열 섹션: 신청업체 | 신청내용 | 납품정보 */}
<div className="border border-gray-400 mb-4">
<div className="grid grid-cols-3">
{/* 신청업체 */}
<div className="border-r border-gray-400">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"></div>
<table className="w-full">
<tbody>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 w-20 font-medium"></td>
<td className="px-2 py-1">{orderDate}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{client}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"> </td>
<td className="px-2 py-1">{manager}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"> </td>
<td className="px-2 py-1">{managerContact}</td>
</tr>
<tr>
<td className="bg-gray-100 px-2 py-1 font-medium"> </td>
<td className="px-2 py-1">{address}</td>
</tr>
</tbody>
</table>
</div>
{/* 신청내용 */}
<div className="border-r border-gray-400">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"></div>
<table className="w-full">
<tbody>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 w-20 font-medium"></td>
<td className="px-2 py-1">{siteName}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{deliveryRequestDate}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{expectedShipDate}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{shutterCount}</td>
</tr>
<tr>
<td className="bg-gray-100 px-2 py-1 font-medium">&nbsp;</td>
<td className="px-2 py-1">&nbsp;</td>
</tr>
</tbody>
</table>
</div>
{/* 납품정보 */}
<div>
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"></div>
<table className="w-full">
<tbody>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 w-20 font-medium"></td>
<td className="px-2 py-1">{siteName}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{recipientName}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{recipientContact}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 px-2 py-1 font-medium"></td>
<td className="px-2 py-1">{deliveryMethod}</td>
</tr>
<tr>
<td className="bg-gray-100 px-2 py-1 font-medium">&nbsp;</td>
<td className="px-2 py-1">&nbsp;</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<p className="text-[10px] mb-4"> .</p>
{/* 1. 스크린 테이블 */}
<div className="mb-4">
<p className="font-bold mb-2">1. </p>
<div className="border border-gray-400">
<table className="w-full">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-1 py-1 w-8" rowSpan={2}>No</th>
<th className="border-r border-gray-400 px-1 py-1 w-16" rowSpan={2}></th>
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}></th>
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}></th>
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}></th>
<th className="border-r border-gray-400 px-1 py-1 w-24" rowSpan={2}><br/></th>
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}><br/>()</th>
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}><br/>()</th>
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}></th>
<th className="px-1 py-1 w-16" rowSpan={2}></th>
</tr>
<tr className="bg-gray-50 border-b border-gray-400 text-[9px]">
<th className="border-r border-gray-400 px-1"></th>
<th className="border-r border-gray-400 px-1"></th>
<th className="border-r border-gray-400 px-1"></th>
<th className="border-r border-gray-400 px-1"></th>
<th className="border-r border-gray-400 px-1"></th>
<th className="border-r border-gray-400 px-1">Kg</th>
</tr>
</thead>
<tbody>
{screenProducts.length > 0 ? (
screenProducts.map((product, index) => (
<tr key={index} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-1 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.productCategory || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.code || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.openWidth || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.openHeight || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.openWidth || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">{product.openHeight || "-"}</td>
<td className="border-r border-gray-300 px-1 py-1 text-center text-[9px]"><br/>(120X70)</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">5</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">5</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">380X180</td>
<td className="border-r border-gray-300 px-1 py-1 text-center">300</td>
<td className="px-1 py-1 text-center">SUS마감</td>
</tr>
))
) : (
<tr>
<td colSpan={13} className="px-2 py-3 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 2. 모터 테이블 */}
<div className="mb-4">
<p className="font-bold mb-2">2. </p>
<div className="border border-gray-400">
<table className="w-full">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-2 py-1 w-28"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-28"></th>
<th className="border-r border-gray-400 px-2 py-1 w-14"></th>
<th className="border-r border-gray-400 px-2 py-1 w-28"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-28"></th>
<th className="px-2 py-1 w-14"></th>
</tr>
</thead>
<tbody>
{(motorItems.length > 0 || bracketItems.length > 0) ? (
<>
{/* 모터 행 */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1">(380V )</td>
<td className="border-r border-gray-300 px-2 py-1"> </td>
<td className="border-r border-gray-300 px-2 py-1">{motorItems[0]?.spec || "KD-150K"}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{motorItems[0] ? formatQuantity(motorItems[0].quantity, motorItems[0].unit) : "6"}</td>
<td className="border-r border-gray-300 px-2 py-1">(380V )</td>
<td className="border-r border-gray-300 px-2 py-1"> </td>
<td className="border-r border-gray-300 px-2 py-1">{motorItems[1]?.spec || "KD-150K"}</td>
<td className="px-2 py-1 text-center">{motorItems[1] ? formatQuantity(motorItems[1].quantity, motorItems[1].unit) : "6"}</td>
</tr>
{/* 브라켓트 행 */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[0]?.spec || "380X180 [2-4\"]"}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{bracketItems[0] ? formatQuantity(bracketItems[0].quantity, bracketItems[0].unit) : "6"}</td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[1]?.spec || "380X180 [2-4\"]"}</td>
<td className="px-2 py-1 text-center">{bracketItems[1] ? formatQuantity(bracketItems[1].quantity, bracketItems[1].unit) : "6"}</td>
</tr>
{/* 브라켓트 추가 행 (밑침통 영금) */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"> </td>
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[2]?.spec || "∠40-40 L380"}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{bracketItems[2] ? formatQuantity(bracketItems[2].quantity, bracketItems[2].unit) : "44"}</td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="px-2 py-1 text-center"></td>
</tr>
</>
) : (
<tr>
<td colSpan={8} className="px-2 py-3 text-center text-gray-400">
/
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 3. 절곡물 */}
<div className="mb-4">
<p className="font-bold mb-2">3. </p>
{/* 3-1. 가이드레일 */}
<div className="mb-3">
<p className="text-[10px] font-medium mb-1">3-1. - EGI 1.5ST + EGI 1.1ST + SUS 1.1ST</p>
<div className="border border-gray-400">
<table className="w-full">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-2 py-1 w-24"> (120X70)</th>
<th className="border-r border-gray-400 px-2 py-1 w-16"></th>
<th className="border-r border-gray-400 px-2 py-1 w-12"></th>
<th className="border-r border-gray-400 px-2 py-1 w-24"> (120X120)</th>
<th className="border-r border-gray-400 px-2 py-1 w-16"></th>
<th className="px-2 py-1 w-12"></th>
</tr>
</thead>
<tbody>
{guideRailItems.length > 0 ? (
<>
{/* 1행: L: 3,000 / 22 */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={4}>
<div className="flex items-center justify-center h-20 border border-dashed border-gray-300">
IMG
</div>
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 3,000</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">22</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={4}>
<div className="flex items-center justify-center h-20 border border-dashed border-gray-300">
IMG
</div>
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 3,000</td>
<td className="px-2 py-1 text-center">22</td>
</tr>
{/* 2행: 하부BASE */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">BASE<br/>[130X80]</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">22</td>
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
<td className="px-2 py-1 text-center"></td>
</tr>
{/* 3행: 빈 행 */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
<td className="px-2 py-1 text-center"></td>
</tr>
{/* 4행: 제품명 */}
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center bg-gray-100"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center">KSS01</td>
<td className="border-r border-gray-300 px-2 py-1 text-center bg-gray-100"></td>
<td className="px-2 py-1 text-center">KSS01</td>
</tr>
</>
) : (
<tr>
<td colSpan={6} className="px-2 py-2 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 연기차단재 정보 */}
<div className="mt-2 border border-gray-400">
<table className="w-full text-[10px]">
<tbody>
<tr>
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
<div className="font-medium">(W50)</div>
<div> </div>
<div className="text-red-600 font-medium"> </div>
</td>
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
<div>EGI 0.8T +</div>
<div></div>
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400 w-20" rowSpan={2}>
<div className="flex items-center justify-center h-10 border border-dashed border-gray-300">
IMG
</div>
</td>
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center">3,000</td>
<td className="px-2 py-1 text-center">4,000</td>
</tr>
<tr>
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center">44</td>
<td className="px-2 py-1 text-center">1</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-2 text-[10px]">
<span className="font-medium"> </span>
</div>
</div>
{/* 3-2. 케이스(셔터박스) */}
<div className="mb-3">
<p className="text-[10px] font-medium mb-1">3-2. () - EGI 1.5ST</p>
<div className="border border-gray-400">
<table className="w-full">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-2 py-1 w-24">&nbsp;</th>
<th className="border-r border-gray-400 px-2 py-1 w-24"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-12"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="px-2 py-1 w-12"></th>
</tr>
</thead>
<tbody>
{caseItems.length > 0 ? (
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={3}>
<div className="flex items-center justify-center h-16 border border-dashed border-gray-300">
IMG
</div>
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
500X330<br/>(150X300,<br/>400K원)
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
L: 4,000<br/>L: 5,000<br/><br/>(1219X389)
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
3<br/>4<br/>55
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">500X355</td>
<td className="px-2 py-1 text-center">22</td>
</tr>
) : (
<tr>
<td colSpan={6} className="px-2 py-2 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 연기차단재 정보 */}
<div className="mt-2 border border-gray-400">
<table className="w-full text-[10px]">
<tbody>
<tr>
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
<div className="font-medium">(W50)</div>
<div> , </div>
<div className="text-red-600 font-medium"> </div>
</td>
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
<div>EGI 0.8T +</div>
<div></div>
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400 w-20" rowSpan={2}>
<div className="flex items-center justify-center h-10 border border-dashed border-gray-300">
IMG
</div>
</td>
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100"></td>
<td className="px-2 py-1 text-center">3,000</td>
</tr>
<tr>
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100"></td>
<td className="px-2 py-1 text-center">44</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 3-3. 하단마감재 */}
<div className="mb-3">
<p className="text-[10px] font-medium mb-1">3-3. - (EGI 1.5ST) + (EGI 1.5ST) + (EGI 1.1ST) + (50X12T)</p>
<div className="border border-gray-400">
<table className="w-full">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16"></th>
<th className="border-r border-gray-400 px-2 py-1 w-12"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16"></th>
<th className="border-r border-gray-400 px-2 py-1 w-12"></th>
<th className="border-r border-gray-400 px-2 py-1 w-20"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16"></th>
<th className="px-2 py-1 w-12"></th>
</tr>
</thead>
<tbody>
{bottomFinishItems.length > 0 ? (
<tr className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-[9px]"><br/>(60X40)</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">11</td>
<td className="border-r border-gray-300 px-2 py-1 text-[9px]"><br/>(60X17)</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">11</td>
<td className="border-r border-gray-300 px-2 py-1 text-[9px]"><br/>[50X12T]</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
<td className="px-2 py-1 text-center">11</td>
</tr>
) : (
<tr>
<td colSpan={9} className="px-2 py-2 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* 특이사항 */}
{remarks && (
<div className="mb-4">
<p className="font-bold mb-2"> </p>
<div className="border border-gray-400 p-3 min-h-[40px]">
{remarks}
</div>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@
export { ContractDocument } from "./ContractDocument";
export { TransactionDocument } from "./TransactionDocument";
export { PurchaseOrderDocument } from "./PurchaseOrderDocument";
export { SalesOrderDocument } from "./SalesOrderDocument";
export {
OrderDocumentModal,
type OrderDocumentType,

View File

@@ -12,6 +12,7 @@ export {
deleteOrders,
updateOrderStatus,
getOrderStats,
createProductionOrder,
revertProductionOrder,
revertOrderConfirmation,
getQuoteByIdForSelect,

View File

@@ -4,9 +4,10 @@
* 거래명세서 보기 모달
*
* 견적 데이터를 거래명세서 양식으로 표시
* - 공급자/공급받는자 정보
* - 결재라인
* - 수요자/공급자 정보
* - 품목내역
* - 금액 계산 (공급가액, 할인, 부가세, 합계)
* - 금액 계산 (소계, 할인율, 할인금액, 할인 후 금액, 부가세, 합계)
* - 증명 문구 + 인감
*/
@@ -35,18 +36,24 @@ export function QuoteTransactionModal({
// locations 배열 (undefined 방어)
const locations = quoteData.locations || [];
// 금액 계산
const subtotal = locations.reduce((sum, loc) => {
const locationTotal = (loc.items || []).reduce((itemSum, item) => itemSum + (item.amount || 0), 0);
return sum + locationTotal * (loc.quantity || 1);
}, 0);
// 소계 (할인 전 금액)
const subtotal = locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
// 할인 적용 후 금액
const afterDiscount = subtotal - discountAmount;
// 부가세
const vat = Math.round(afterDiscount * 0.1);
const finalTotal = afterDiscount + vat;
// 부가세 포함 여부
const vatIncluded = quoteData.vatType === 'included';
// 총 금액 (부가세 포함 여부에 따라)
const grandTotal = vatIncluded ? afterDiscount + vat : afterDiscount;
// 할인 적용 여부
const hasDiscount = discountAmount > 0;
// 오늘 날짜
const today = new Date().toISOString().split('T')[0];
@@ -57,165 +64,261 @@ export function QuoteTransactionModal({
open={open}
onOpenChange={onOpenChange}
>
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {quoteData.quoteNumber || '-'} | : {quoteData.receiptDate || today}
</p>
<div className="max-w-[210mm] mx-auto bg-white p-6 text-sm">
{/* 헤더: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-4">
{/* 왼쪽: 제목 */}
<div className="flex-1">
<h1 className="text-2xl font-bold text-center tracking-[0.5em] mb-1">
</h1>
<p className="text-xs text-gray-500 text-center">
: {quoteData.quoteNumber || 'ABC123'} | : {quoteData.registrationDate || today}
</p>
</div>
{/* 오른쪽: 결재란 */}
<div className="border border-gray-400">
<table className="text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="px-3 py-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-gray-400 px-3 py-3 text-center"></td>
</tr>
<tr>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 공급자/공급받는자 정보 */}
{/* 수요자 / 공급자 정보 (좌우 배치) */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 공급자 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>123-12-12345</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
{/* 수요자 */}
<div className="border border-gray-400">
<div className="bg-gray-200 px-2 py-1 font-semibold text-center border-b border-gray-400">
</div>
<table className="w-full text-xs">
<tbody>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1 w-20"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.clientName || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{locations[0]?.productCode || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.siteName || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.manager || '-'}</td>
</tr>
<tr>
<td className="border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="px-2 py-1">{quoteData.contact || '-'}</td>
</tr>
</tbody>
</table>
</div>
{/* 공급받는자 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.clientName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.managerName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.contact || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.siteName || '-'}</span>
</div>
{/* 공급자 */}
<div className="border border-gray-400">
<div className="bg-gray-200 px-2 py-1 font-semibold text-center border-b border-gray-400">
</div>
<table className="w-full text-xs">
<tbody>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1 w-20"></td>
<td colSpan={3} className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-r border-gray-300 px-2 py-1">123-12-12345</td>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1 w-16"></td>
<td className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td colSpan={3} className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-r border-gray-300 px-2 py-1"></td>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">, </td>
</tr>
<tr>
<td className="border-r border-gray-300 bg-gray-50 px-2 py-1">TEL</td>
<td className="border-r border-gray-300 px-2 py-1">031-123-1234</td>
<td className="border-r border-gray-300 bg-gray-50 px-2 py-1">FAX</td>
<td className="px-2 py-1">02-1234-1234</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 품목내역 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
{/* 내역 테이블 */}
<div className="border border-gray-400 mb-4">
<div className="bg-gray-800 text-white px-2 py-1 font-semibold text-center">
</div>
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-28"></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-right font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-right font-medium w-24"></th>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-300 px-2 py-1">No.</th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1" colSpan={2}></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="px-2 py-1"></th>
</tr>
<tr className="bg-gray-50 border-b border-gray-300 text-[10px] text-gray-500">
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-1 py-0.5"></th>
<th className="border-r border-gray-300 px-1 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="px-2 py-0.5"></th>
</tr>
</thead>
<tbody>
{locations.length > 0 ? (
locations.map((location, index) => (
<tr key={location.id} className="border-b border-gray-300">
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
<td className="p-2 text-center border-r border-gray-300">{location.productCode || '-'}</td>
<td className="p-2 border-r border-gray-300">{location.floor} / {location.symbol}</td>
<td className="p-2 text-center border-r border-gray-300">{location.width}x{location.height}</td>
<td className="p-2 text-center border-r border-gray-300">{location.quantity || 1}</td>
<td className="p-2 text-center border-r border-gray-300">SET</td>
<td className="p-2 text-right border-r border-gray-300">
{(location.items || []).reduce((sum, item) => sum + (item.amount || 0), 0).toLocaleString()}
locations.map((loc, index) => (
<tr key={loc.id} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.floor || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.code || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1">{loc.productCode || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.openWidth || 0}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.openHeight || 0}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.quantity || 1}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">SET</td>
<td className="border-r border-gray-300 px-2 py-1 text-right">
{(loc.unitPrice || 0).toLocaleString()}
</td>
<td className="p-2 text-right">
{((location.items || []).reduce((sum, item) => sum + (item.amount || 0), 0) * (location.quantity || 1)).toLocaleString()}
<td className="px-2 py-1 text-right">
{(loc.totalPrice || 0).toLocaleString()}
</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={8} className="p-4 text-center text-gray-400">
<td colSpan={10} className="p-4 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 금액 계산 */}
<div className="border border-gray-300 mb-6">
<table className="w-full text-sm">
<tbody>
<tfoot>
{/* 소계 */}
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32"></td>
<td className="p-2 text-right">{subtotal.toLocaleString()}</td>
<td colSpan={6} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50">
</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">
{locations.reduce((sum, loc) => sum + (loc.quantity || 0), 0)}
</td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="px-2 py-1 text-right">{subtotal.toLocaleString()}</td>
</tr>
{/* 할인 적용 시에만 표시 */}
{(discountRate > 0 || discountAmount > 0) && (
{/* 할인율 */}
<tr className="border-b border-gray-300">
<td colSpan={9} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50">
</td>
<td className="px-2 py-1 text-right">
{hasDiscount ? `${discountRate.toFixed(1)} %` : '0.0 %'}
</td>
</tr>
{/* 할인금액 */}
<tr className="border-b border-gray-300">
<td colSpan={9} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50">
</td>
<td className="px-2 py-1 text-right">
{hasDiscount ? discountAmount.toLocaleString() : '0'}
</td>
</tr>
{/* 할인 후 금액 */}
<tr className="border-b border-gray-300">
<td colSpan={9} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50">
</td>
<td className="px-2 py-1 text-right">{afterDiscount.toLocaleString()}</td>
</tr>
{/* 부가세 포함일 때 추가 행들 */}
{vatIncluded && (
<>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right">{discountRate.toFixed(2)}%</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right text-red-600">
-{discountAmount.toLocaleString()}
<td colSpan={9} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50">
</td>
<td className="px-2 py-1 text-right">{vat.toLocaleString()}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> </td>
<td className="p-2 text-right">{afterDiscount.toLocaleString()}</td>
<tr>
<td colSpan={9} className="border-r border-gray-300 px-2 py-1 text-center bg-gray-50 font-semibold">
</td>
<td className="px-2 py-1 text-right font-semibold">{grandTotal.toLocaleString()}</td>
</tr>
</>
)}
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> (10%)</td>
<td className="p-2 text-right">{vat.toLocaleString()}</td>
</tr>
<tr>
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium"> </td>
<td className="p-2 text-right font-bold text-lg"> {finalTotal.toLocaleString()}</td>
</tr>
</tbody>
</tfoot>
</table>
</div>
{/* 증명 문구 */}
<div className="text-center py-6 border-t border-gray-300">
<p className="text-sm mb-4"> .</p>
<p className="text-sm text-gray-600 mb-4">{quoteData.receiptDate || today}</p>
<div className="flex justify-center">
<div className="w-12 h-12 border-2 border-red-400 rounded-full flex items-center justify-center text-red-400 text-xs">
{/* 합계금액 박스 */}
<div className="border-2 border-gray-800 p-3 mb-4 flex justify-between items-center">
<span className="font-semibold text-red-600">
({vatIncluded ? '부가세 포함' : '부가세 별도'})
</span>
<span className="text-xl font-bold">
{grandTotal.toLocaleString()}
</span>
</div>
{/* 증명 문구 + 인감 */}
<div className="text-center py-4">
<p className="text-sm mb-2"> .</p>
<div className="flex justify-end items-center gap-2 pr-8">
<span className="border border-red-400 rounded-full w-10 h-10 flex items-center justify-center text-red-400 text-xs">
</div>
</span>
</div>
</div>
</div>
</DocumentViewer>
);
}
}

View File

@@ -50,7 +50,8 @@ export function UniversalListPage<T>({
// UI 상태
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(!initialData);
const [searchValue, setSearchValue] = useState('');
const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
const [activeTab, setActiveTab] = useState(
@@ -86,6 +87,15 @@ export function UniversalListPage<T>({
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
// ===== 검색 Debounce (300ms) =====
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchValue(searchValue);
}, 300);
return () => clearTimeout(timer);
}, [searchValue]);
// ===== ID 추출 헬퍼 =====
const getItemId = useCallback(
(item: T): string => {
@@ -115,10 +125,10 @@ export function UniversalListPage<T>({
filtered = filtered.filter((item) => config.tabFilter!(item, activeTab));
}
// 검색 필터
if (searchValue && config.searchFilter) {
// 검색 필터 (debounced 값 사용)
if (debouncedSearchValue && config.searchFilter) {
filtered = filtered.filter((item) =>
config.searchFilter!(item, searchValue)
config.searchFilter!(item, debouncedSearchValue)
);
}
@@ -168,7 +178,7 @@ export function UniversalListPage<T>({
}
return filtered;
}, [rawData, activeTab, searchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
}, [rawData, activeTab, debouncedSearchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
// 클라이언트 사이드 페이지네이션
const paginatedData = useMemo(() => {
@@ -222,7 +232,7 @@ export function UniversalListPage<T>({
: {
page: currentPage,
pageSize: itemsPerPage,
search: searchValue,
search: debouncedSearchValue,
filters,
tab: activeTab,
}
@@ -249,7 +259,7 @@ export function UniversalListPage<T>({
setIsLoading(false);
setIsMobileLoading(false);
}
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]);
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
useEffect(() => {
@@ -289,6 +299,12 @@ export function UniversalListPage<T>({
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
const [prevPage, setPrevPage] = useState(1);
// 날짜 범위 변경 감지용 (서버 사이드 필터링에서 날짜 변경 시 데이터 새로고침)
const dateRangeKey = config.dateRangeSelector?.enabled
? `${config.dateRangeSelector.startDate || ''}-${config.dateRangeSelector.endDate || ''}`
: '';
useEffect(() => {
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
@@ -296,7 +312,7 @@ export function UniversalListPage<T>({
fetchData(isMobileAppend);
setPrevPage(currentPage);
}
}, [currentPage, searchValue, filters, activeTab]);
}, [currentPage, debouncedSearchValue, filters, activeTab, dateRangeKey]);
// 동적 탭 로딩
useEffect(() => {
@@ -459,12 +475,14 @@ export function UniversalListPage<T>({
// ===== 검색 핸들러 =====
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
setSelectedItems(new Set());
// 외부 콜백 호출 (서버 사이드 검색용)
onSearchChange?.(value);
}, [onSearchChange]);
setSearchValue(value); // UI 즉시 반영
// 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
}, []);
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)
useEffect(() => {
onSearchChange?.(debouncedSearchValue);
}, [debouncedSearchValue, onSearchChange]);
// ===== 필터 핸들러 =====
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
@@ -514,7 +532,7 @@ export function UniversalListPage<T>({
const additionalParams = fetchAllParams({
activeTab,
filters,
searchValue,
searchValue: debouncedSearchValue,
});
Object.entries(additionalParams).forEach(([key, value]) => {
if (value) params.append(key, value);
@@ -559,7 +577,7 @@ export function UniversalListPage<T>({
} finally {
setIsExcelDownloading(false);
}
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, searchValue]);
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]);
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = useCallback(() => {