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:
@@ -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} />;
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
생산관리 > 작업지시 관리에서 확인하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="justify-center">
|
||||
<Button
|
||||
onClick={handleProductionSuccessConfirm}
|
||||
className="bg-gray-900 hover:bg-gray-800 min-w-[120px]"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -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; // 금일입고
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
|
||||
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal file
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
647
src/components/orders/documents/SalesOrderDocument.tsx
Normal file
647
src/components/orders/documents/SalesOrderDocument.tsx
Normal 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"> </td>
|
||||
<td className="px-2 py-1"> </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"> </td>
|
||||
<td className="px-2 py-1"> </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"> </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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
export { ContractDocument } from "./ContractDocument";
|
||||
export { TransactionDocument } from "./TransactionDocument";
|
||||
export { PurchaseOrderDocument } from "./PurchaseOrderDocument";
|
||||
export { SalesOrderDocument } from "./SalesOrderDocument";
|
||||
export {
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
deleteOrders,
|
||||
updateOrderStatus,
|
||||
getOrderStats,
|
||||
createProductionOrder,
|
||||
revertProductionOrder,
|
||||
revertOrderConfirmation,
|
||||
getQuoteByIdForSelect,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user