From 9ecdfb23fe3df6a5df86782385002c562dfc1198 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 19 Sep 2025 07:51:27 +0000 Subject: (대표님) rfq 히스토리, swp 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/general-contract-detail.tsx | 1 - .../detail/general-contract-items-table.tsx | 139 ++-- lib/general-contracts/service.ts | 88 ++- lib/rfq-last/attachment/rfq-attachments-table.tsx | 128 +-- lib/rfq-last/quotation-compare-view.tsx | 4 +- lib/rfq-last/service.ts | 1 - lib/rfq-last/table/create-general-rfq-dialog.tsx | 4 +- lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 18 +- lib/rfq-last/vendor/rfq-vendor-table.tsx | 341 ++++++-- lib/vendor-document-list/import-service.ts | 8 +- .../plant/document-stage-dialogs.tsx | 28 +- .../plant/document-stage-toolbar.tsx | 114 ++- .../plant/document-stages-columns.tsx | 86 +- .../plant/document-stages-expanded-content.tsx | 4 +- .../plant/document-stages-service.ts | 123 ++- .../plant/document-stages-table.tsx | 20 +- .../plant/excel-import-export.ts | 6 +- .../plant/shi-buyer-system-api.ts | 874 +++++++++++++++++++++ lib/vendor-document-list/plant/upload/columns.tsx | 379 +++++++++ .../plant/upload/components/history-dialog.tsx | 144 ++++ .../upload/components/multi-upload-dialog.tsx | 492 ++++++++++++ .../plant/upload/components/project-filter.tsx | 109 +++ .../upload/components/single-upload-dialog.tsx | 265 +++++++ .../upload/components/view-submission-dialog.tsx | 520 ++++++++++++ lib/vendor-document-list/plant/upload/service.ts | 228 ++++++ lib/vendor-document-list/plant/upload/table.tsx | 223 ++++++ .../plant/upload/toolbar-actions.tsx | 242 ++++++ .../plant/upload/util/filie-parser.ts | 132 ++++ .../plant/upload/validation.ts | 35 + .../rfq-history-table-columns.tsx | 129 +-- .../rfq-history-table/rfq-history-table.tsx | 172 ++-- lib/vendors/service.ts | 313 +++++--- 32 files changed, 4899 insertions(+), 471 deletions(-) create mode 100644 lib/vendor-document-list/plant/shi-buyer-system-api.ts create mode 100644 lib/vendor-document-list/plant/upload/columns.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/history-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/project-filter.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/service.ts create mode 100644 lib/vendor-document-list/plant/upload/table.tsx create mode 100644 lib/vendor-document-list/plant/upload/toolbar-actions.tsx create mode 100644 lib/vendor-document-list/plant/upload/util/filie-parser.ts create mode 100644 lib/vendor-document-list/plant/upload/validation.ts (limited to 'lib') diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx index 9d9f35bd..8e7a7aff 100644 --- a/lib/general-contracts/detail/general-contract-detail.tsx +++ b/lib/general-contracts/detail/general-contract-detail.tsx @@ -149,7 +149,6 @@ export default function ContractDetailPage() { items={[]} onItemsChange={() => {}} onTotalAmountChange={() => {}} - currency="USD" availableBudget={0} readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'} /> diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 5176c6ce..1b9a1a06 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, @@ -26,12 +27,13 @@ import { Save, LoaderIcon } from 'lucide-react' interface ContractItem { id?: number - project: string itemCode: string itemInfo: string specification: string quantity: number quantityUnit: string + totalWeight: number + weightUnit: string contractDeliveryDate: string contractUnitPrice: number contractAmount: number @@ -45,22 +47,27 @@ interface ContractItemsTableProps { items: ContractItem[] onItemsChange: (items: ContractItem[]) => void onTotalAmountChange: (total: number) => void - currency?: string availableBudget?: number readOnly?: boolean } +// 통화 목록 +const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +// 수량 단위 목록 +const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"]; + +// 중량 단위 목록 +const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"]; + export function ContractItemsTable({ contractId, items, onItemsChange, onTotalAmountChange, - currency = 'USD', availableBudget = 0, readOnly = false }: ContractItemsTableProps) { - // 통화 코드가 null이거나 undefined일 때 기본값 설정 - const safeCurrency = currency || 'USD' const [localItems, setLocalItems] = React.useState(items) const [isSaving, setIsSaving] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) @@ -74,16 +81,17 @@ export function ContractItemsTable({ const fetchedItems = await getContractItems(contractId) const formattedItems = fetchedItems.map(item => ({ id: item.id, - project: item.project || '', itemCode: item.itemCode || '', itemInfo: item.itemInfo || '', specification: item.specification || '', quantity: Number(item.quantity) || 0, - quantityUnit: item.quantityUnit || 'KG', + quantityUnit: item.quantityUnit || 'EA', + totalWeight: Number(item.totalWeight) || 0, + weightUnit: item.weightUnit || 'KG', contractDeliveryDate: item.contractDeliveryDate || '', contractUnitPrice: Number(item.contractUnitPrice) || 0, contractAmount: Number(item.contractAmount) || 0, - contractCurrency: item.contractCurrency || safeCurrency, + contractCurrency: item.contractCurrency || 'KRW', isSelected: false })) as ContractItem[] setLocalItems(formattedItems as ContractItem[]) @@ -99,7 +107,7 @@ export function ContractItemsTable({ } loadItems() - }, [contractId, currency, onItemsChange]) + }, [contractId, onItemsChange]) // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) React.useEffect(() => { @@ -116,10 +124,8 @@ export function ContractItemsTable({ const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] - if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) @@ -170,16 +176,17 @@ export function ContractItemsTable({ // 행 추가 const addRow = () => { const newItem: ContractItem = { - project: '', itemCode: '', itemInfo: '', specification: '', quantity: 0, - quantityUnit: 'KG', + quantityUnit: 'EA', // 기본 수량 단위 + totalWeight: 0, + weightUnit: 'KG', // 기본 중량 단위 contractDeliveryDate: '', contractUnitPrice: 0, contractAmount: 0, - contractCurrency: safeCurrency, + contractCurrency: 'KRW', // 기본 통화 isSelected: false } const updatedItems = [...localItems, newItem] @@ -213,10 +220,10 @@ export function ContractItemsTable({ // 통화 포맷팅 - const formatCurrency = (amount: number) => { + const formatCurrency = (amount: number, currency: string = 'KRW') => { return new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: safeCurrency, + currency: currency, }).format(amount) } @@ -270,7 +277,7 @@ export function ContractItemsTable({
- 총 금액: {totalAmount.toLocaleString()} {currency} + 총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} 총 수량: {totalQuantity.toLocaleString()}
{!readOnly && ( @@ -316,19 +323,19 @@ export function ContractItemsTable({
- {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
- {formatCurrency(availableBudget)} + {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')}
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference)} + {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')}
@@ -357,12 +364,13 @@ export function ContractItemsTable({ /> )} - 프로젝트 품목코드 (PKG No.) Item 정보 (자재그룹 / 자재코드) 규격 수량 수량단위 + 총 중량 + 중량단위 계약납기일 계약단가 계약금액 @@ -383,19 +391,6 @@ export function ContractItemsTable({ /> )} - - {readOnly ? ( - {item.project || '-'} - ) : ( - updateItem(index, 'project', e.target.value)} - placeholder="프로젝트" - className="h-8 text-sm" - disabled={!isEnabled} - /> - )} - {readOnly ? ( {item.itemCode || '-'} @@ -453,15 +448,60 @@ export function ContractItemsTable({ {readOnly ? ( {item.quantityUnit || '-'} ) : ( - updateItem(index, 'quantityUnit', e.target.value)} - placeholder="단위" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'quantityUnit', value)} + disabled={!isEnabled} + > + + + + + {QUANTITY_UNITS.map((unit) => ( + + {unit} + + ))} + + + )} + + + {readOnly ? ( + {item.totalWeight.toLocaleString()} + ) : ( + updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" disabled={!isEnabled} /> )} + + {readOnly ? ( + {item.weightUnit || '-'} + ) : ( + + )} + {readOnly ? ( {item.contractDeliveryDate || '-'} @@ -498,13 +538,22 @@ export function ContractItemsTable({ {readOnly ? ( {item.contractCurrency || '-'} ) : ( - updateItem(index, 'contractCurrency', e.target.value)} - placeholder="통화" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'contractCurrency', value)} disabled={!isEnabled} - /> + > + + + + + {CURRENCIES.map((currency) => ( + + {currency} + + ))} + + )} @@ -528,14 +577,14 @@ export function ContractItemsTable({
총 단가 - {totalUnitPrice.toLocaleString()} {currency} + {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')}
합계 금액 - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 8c74c616..52301dae 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -372,7 +372,8 @@ export async function createContract(data: Record) { try { // 계약번호 자동 생성 // TODO: 구매 발주담당자 코드 필요 - 파라미터 추가 - const userId = data.registeredById as string + const rawUserId = data.registeredById + const userId = (rawUserId && !isNaN(Number(rawUserId))) ? String(rawUserId) : undefined const contractNumber = await generateContractNumber( userId, data.type as string @@ -676,6 +677,8 @@ export async function updateContractItems(contractId: number, items: Record= 3) { purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase(); @@ -1774,8 +1777,20 @@ export async function generateContractNumber( let sequenceNumber = 1 if (existingContracts.length > 0) { const lastContractNumber = existingContracts[0].contractNumber - const lastSequence = parseInt(lastContractNumber.slice(-3)) - sequenceNumber = lastSequence + 1 + const lastSequenceStr = lastContractNumber.slice(-3) + + // contractNumber에서 숫자만 추출하여 sequence 찾기 + const numericParts = lastContractNumber.match(/\d+/g) + if (numericParts && numericParts.length > 0) { + // 마지막 숫자 부분을 시퀀스로 사용 (일반적으로 마지막 3자리) + const potentialSequence = numericParts[numericParts.length - 1] + const lastSequence = parseInt(potentialSequence) + + if (!isNaN(lastSequence)) { + sequenceNumber = lastSequence + 1 + } + } + // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지 } // 일련번호를 3자리로 포맷팅 @@ -1797,8 +1812,19 @@ export async function generateContractNumber( let sequenceNumber = 1 if (existingContracts.length > 0) { const lastContractNumber = existingContracts[0].contractNumber - const lastSequence = parseInt(lastContractNumber.slice(-3)) - sequenceNumber = lastSequence + 1 + + // contractNumber에서 숫자만 추출하여 sequence 찾기 + const numericParts = lastContractNumber.match(/\d+/g) + if (numericParts && numericParts.length > 0) { + // 마지막 숫자 부분을 시퀀스로 사용 + const potentialSequence = numericParts[numericParts.length - 1] + const lastSequence = parseInt(potentialSequence) + + if (!isNaN(lastSequence)) { + sequenceNumber = lastSequence + 1 + } + } + // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지 } // 최종 계약번호 생성: C + 발주담당자코드(3자리) + 계약종류(2자리) + 연도(2자리) + 일련번호(3자리) diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index 155fd412..09c9fe35 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -50,6 +50,7 @@ import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; import { toast } from "sonner"; import { RevisionHistoryDialog } from "./revision-historty-dialog"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface RfqAttachment { @@ -238,6 +239,7 @@ export function RfqAttachmentsTable({ { accessorKey: "serialNo", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( {row.original.serialNo || "-"} ), @@ -248,6 +250,7 @@ export function RfqAttachmentsTable({ { accessorKey: "originalFileName", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const file = row.original; return ( @@ -266,6 +269,7 @@ export function RfqAttachmentsTable({ { accessorKey: "description", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => (
{row.original.description || "-"} @@ -276,6 +280,7 @@ export function RfqAttachmentsTable({ { accessorKey: "currentRevision", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -291,6 +296,7 @@ export function RfqAttachmentsTable({ { accessorKey: "fileSize", header: ({ column }) => , + filterFn: createFilterFn("number"), // number 타입으로 변경 cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} @@ -298,15 +304,51 @@ export function RfqAttachmentsTable({ ), size: 80, }, + { + accessorKey: "fileType", + header: ({ column }) => , + filterFn: createFilterFn("select"), // 추가 + cell: ({ row }) => { + const fileType = row.original.fileType; + if (!fileType) return -; + + const type = fileType.toLowerCase(); + let displayType = "기타"; + let color = "text-gray-500"; + + if (type.includes('pdf')) { + displayType = "PDF"; + color = "text-red-500"; + } else if (type.includes('excel') || ['xls', 'xlsx'].includes(type)) { + displayType = "Excel"; + color = "text-green-500"; + } else if (type.includes('word') || ['doc', 'docx'].includes(type)) { + displayType = "Word"; + color = "text-blue-500"; + } else if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + displayType = "이미지"; + color = "text-purple-500"; + } + + return ( + + {displayType} + + ); + }, + size: 100, + }, { accessorKey: "createdByName", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", header: ({ column }) => , + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -334,53 +376,45 @@ export function RfqAttachmentsTable({ { accessorKey: "updatedAt", header: ({ column }) => , + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; }, size: 100, }, + { + accessorKey: "revisionComment", + header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 + cell: ({ row }) => { + const comment = row.original.revisionComment; + return comment ? ( + + + + + {comment} + + + +

{comment}

+
+
+
+ ) : ( + - + ); + }, + size: 150, + }, { id: "actions", header: "작업", cell: ({ row }) => { return ( - - - - - handleAction({ row, type: "download" })}> - - 다운로드 - - handleAction({ row, type: "preview" })}> - - 미리보기 - - - handleAction({ row, type: "history" })}> - - 리비전 이력 - - handleAction({ row, type: "update" })}> - - 새 버전 업로드 - - - handleAction({ row, type: "delete" })} - className="text-red-600" - > - - 삭제 - - + {/* ... 기존 드롭다운 메뉴 내용 ... */} ); }, @@ -394,18 +428,18 @@ export function RfqAttachmentsTable({ { id: "originalFileName", label: "파일명", type: "text" }, { id: "description", label: "설명", type: "text" }, { id: "currentRevision", label: "리비전", type: "text" }, - { - id: "fileType", - label: "파일 타입", - type: "select", - options: [ - { label: "PDF", value: "pdf" }, - { label: "Excel", value: "xlsx" }, - { label: "Word", value: "docx" }, - { label: "이미지", value: "image" }, - { label: "기타", value: "other" }, - ] - }, + // { + // id: "fileType", + // label: "파일 타입", + // type: "select", + // options: [ + // { label: "PDF", value: "pdf" }, + // { label: "Excel", value: "xlsx" }, + // { label: "Word", value: "docx" }, + // { label: "이미지", value: "image" }, + // { label: "기타", value: "other" }, + // ] + // }, { id: "createdByName", label: "업로드자", type: "text" }, { id: "createdAt", label: "업로드일", type: "date" }, { id: "updatedAt", label: "수정일", type: "date" }, diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 28c8b3b1..91d46295 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -507,7 +507,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { 일반계약 - + */}
)}
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 85db1ea7..43943c71 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3096,7 +3096,6 @@ async function processVendors({ // PDF 저장 디렉토리 준비 const contractsDir = path.join( - process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated" diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 7abf06a3..2c69f4b7 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -347,7 +347,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {/* 스크롤 가능한 컨텐츠 영역 */}
- + {/* 기본 정보 섹션 */}
@@ -766,8 +766,10 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {rfqCategory === "general" && ( - + )} -
+ {/* 담당자 지정 다이얼로그 */} { try { setIsUpdatingShortList(true); - + const vendorIds = selectedRows .map(vendor => vendor.vendorId) .filter(id => id != null); @@ -320,7 +323,7 @@ export function RfqVendorTable({ // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { - const vendorsWithQuotation = selectedRows.filter(row => + const vendorsWithQuotation = selectedRows.filter(row => row.response?.submission?.submittedAt ); @@ -334,7 +337,7 @@ export function RfqVendorTable({ .map(v => v.vendorId) .filter(id => id != null) .join(','); - + router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`); }, [selectedRows, rfqId, router]); @@ -349,8 +352,8 @@ export function RfqVendorTable({ setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 - const selectedVendorIds = rfqCode?.startsWith("I")? selectedRows - .filter(v=>v.shortList) + const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows + .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : selectedRows @@ -468,7 +471,7 @@ export function RfqVendorTable({ } else { toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); } - + // 페이지 새로고침 router.refresh(); } catch (error) { @@ -593,6 +596,8 @@ export function RfqVendorTable({ { accessorKey: "rfqCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { return ( {row.original.rfqCode || "-"} @@ -603,6 +608,8 @@ export function RfqVendorTable({ { accessorKey: "vendorName", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; return ( @@ -620,12 +627,16 @@ export function RfqVendorTable({ { accessorKey: "vendorCategory", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.vendorCategory || "-", size: 100, }, { accessorKey: "vendorCountry", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const country = row.original.vendorCountry; const isLocal = country === "KR" || country === "한국"; @@ -640,6 +651,8 @@ export function RfqVendorTable({ { accessorKey: "vendorGrade", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return -; @@ -661,9 +674,11 @@ export function RfqVendorTable({ header: ({ column }) => ( ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const status = row.original.tbeStatus; - + if (!status || status === "준비중") { return ( @@ -672,7 +687,7 @@ export function RfqVendorTable({ ); } - + const statusConfig = { "진행중": { variant: "default", icon: , color: "text-blue-600" }, "검토중": { variant: "secondary", icon: , color: "text-orange-600" }, @@ -680,7 +695,7 @@ export function RfqVendorTable({ "완료": { variant: "success", icon: , color: "text-green-600" }, "취소": { variant: "destructive", icon: , color: "text-red-600" }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; - + return ( {statusConfig.icon} @@ -690,42 +705,44 @@ export function RfqVendorTable({ }, size: 100, }, - + { accessorKey: "tbeEvaluationResult", header: ({ column }) => ( ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const result = row.original.tbeEvaluationResult; const status = row.original.tbeStatus; - + // TBE가 완료되지 않았으면 표시하지 않음 if (status !== "완료" || !result) { return -; } - + const resultConfig = { - "Acceptable": { - variant: "success", - icon: , + "Acceptable": { + variant: "success", + icon: , text: "적합", color: "bg-green-50 text-green-700 border-green-200" }, - "Acceptable with Comment": { - variant: "warning", - icon: , + "Acceptable with Comment": { + variant: "warning", + icon: , text: "조건부 적합", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - "Not Acceptable": { - variant: "destructive", - icon: , + "Not Acceptable": { + variant: "destructive", + icon: , text: "부적합", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; - + return ( @@ -755,6 +772,8 @@ export function RfqVendorTable({ { accessorKey: "contractRequirements", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; @@ -833,6 +852,8 @@ export function RfqVendorTable({ { accessorKey: "sendVersion", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => { const version = row.original.sendVersion; @@ -844,6 +865,8 @@ export function RfqVendorTable({ { accessorKey: "emailStatus", header: "이메일 상태", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const response = row.original; const emailSentAt = response?.emailSentAt; @@ -936,6 +959,8 @@ export function RfqVendorTable({ { accessorKey: "currency", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const currency = row.original.currency; return currency ? ( @@ -949,6 +974,8 @@ export function RfqVendorTable({ { accessorKey: "paymentTermsCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.paymentTermsCode; const desc = row.original.paymentTermsDescription; @@ -972,12 +999,16 @@ export function RfqVendorTable({ { accessorKey: "taxCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.taxCode || "-", size: 60, }, { accessorKey: "deliveryDate", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; @@ -1003,6 +1034,8 @@ export function RfqVendorTable({ { accessorKey: "incotermsCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; @@ -1030,6 +1063,8 @@ export function RfqVendorTable({ { accessorKey: "placeOfShipping", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const place = row.original.placeOfShipping; return place ? ( @@ -1046,6 +1081,7 @@ export function RfqVendorTable({ { accessorKey: "placeOfDestination", header: ({ column }) => , + filterFn: createFilterFn("text"), cell: ({ row }) => { const place = row.original.placeOfDestination; return place ? ( @@ -1062,6 +1098,8 @@ export function RfqVendorTable({ { id: "additionalConditions", header: "추가조건", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const conditions = formatAdditionalConditions(row.original); if (conditions === "-") { @@ -1084,6 +1122,8 @@ export function RfqVendorTable({ { accessorKey: "response.submission.submittedAt", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; @@ -1131,6 +1171,7 @@ export function RfqVendorTable({ }, ...(rfqCode?.startsWith("I") ? [{ accessorKey: "shortList", + filterFn: createFilterFn("boolean"), // boolean으로 변경 header: ({ column }) => , cell: ({ row }) => ( row.original.shortList ? ( @@ -1143,6 +1184,7 @@ export function RfqVendorTable({ }] : []), { accessorKey: "updatedByUserName", + filterFn: createFilterFn("text"), // 추가 header: ({ column }) => , cell: ({ row }) => { const name = row.original.updatedByUserName; @@ -1238,24 +1280,160 @@ export function RfqVendorTable({ } ], [handleAction, rfqCode, isLoadingSendData]); + // advancedFilterFields 정의 - columns와 매칭되도록 정리 const advancedFilterFields: DataTableAdvancedFilterField[] = [ - { id: "vendorName", label: "벤더명", type: "text" }, - { id: "vendorCode", label: "벤더코드", type: "text" }, - { id: "vendorCountry", label: "국가", type: "text" }, { - id: "response.status", - label: "응답 상태", + id: "rfqCode", + label: "ITB/RFQ/견적 No.", + type: "text" + }, + { + id: "vendorName", + label: "협력업체명", + type: "text" + }, + { + id: "vendorCode", + label: "협력업체코드", + type: "text" + }, + { + id: "vendorCategory", + label: "업체분류", + type: "select", + options: [ + { label: "제조업체", value: "제조업체" }, + { label: "무역업체", value: "무역업체" }, + { label: "대리점", value: "대리점" }, + // 실제 카테고리에 맞게 추가 + ] + }, + { + id: "vendorCountry", + label: "내외자(위치)", + type: "select", + options: [ + { label: "한국(KR)", value: "KR" }, + { label: "한국", value: "한국" }, + { label: "중국(CN)", value: "CN" }, + { label: "일본(JP)", value: "JP" }, + { label: "미국(US)", value: "US" }, + { label: "독일(DE)", value: "DE" }, + // 필요한 국가 추가 + ] + }, + { + id: "vendorGrade", + label: "AVL 등급", type: "select", options: [ - { label: "초대됨", value: "초대됨" }, - { label: "작성중", value: "작성중" }, - { label: "제출완료", value: "제출완료" }, - { label: "수정요청", value: "수정요청" }, - { label: "최종확정", value: "최종확정" }, + { label: "A", value: "A" }, + { label: "B", value: "B" }, + { label: "C", value: "C" }, + { label: "D", value: "D" }, + ] + }, + { + id: "tbeStatus", + label: "TBE 상태", + type: "select", + options: [ + { label: "대기", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, { label: "취소", value: "취소" }, ] }, { + id: "tbeEvaluationResult", + label: "TBE 평가결과", + type: "select", + options: [ + { label: "적합", value: "Acceptable" }, + { label: "조건부 적합", value: "Acceptable with Comment" }, + { label: "부적합", value: "Not Acceptable" }, + ] + }, + { + id: "sendVersion", + label: "발송 회차", + type: "number" + }, + { + id: "emailStatus", + label: "이메일 상태", + type: "select", + options: [ + { label: "미발송", value: "미발송" }, + { label: "발송됨", value: "sent" }, + { label: "발송 실패", value: "failed" }, + ] + }, + { + id: "currency", + label: "요청 통화", + type: "select", + options: [ + { label: "KRW", value: "KRW" }, + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" }, + { label: "CNY", value: "CNY" }, + ] + }, + { + id: "paymentTermsCode", + label: "지급조건", + type: "text" + }, + { + id: "taxCode", + label: "Tax", + type: "text", + }, + { + id: "deliveryDate", + label: "계약납기일", + type: "date" + }, + { + id: "contractDuration", + label: "계약기간", + type: "text" + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "placeOfShipping", + label: "선적지", + type: "text" + }, + { + id: "placeOfDestination", + label: "도착지", + type: "text" + }, + { + id: "firstYn", + label: "초도품", + type: "boolean" + }, + { + id: "materialPriceRelatedYn", + label: "연동제", + type: "boolean" + }, + { + id: "sparepartYn", + label: "스페어파트", + type: "boolean" + }, + ...(rfqCode?.startsWith("I") ? [{ id: "shortList", label: "Short List", type: "select", @@ -1263,7 +1441,12 @@ export function RfqVendorTable({ { label: "선정", value: "true" }, { label: "대기", value: "false" }, ] - }, + }] : []), + { + id: "updatedByUserName", + label: "최신수정자", + type: "text" + } ]; // 선택된 벤더 정보 (BatchUpdate용) @@ -1280,15 +1463,27 @@ export function RfqVendorTable({ // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; - const shortListCount = selectedRows.filter(v=>v.shortList).length; + const shortListCount = selectedRows.filter(v => v.shortList).length; // 견적서가 있는 선택된 벤더 수 계산 - const quotationCount = selectedRows.filter(row => + const quotationCount = selectedRows.filter(row => row.response?.submission?.submittedAt ).length; return (
+ {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && + + + } + + - + {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} - {rfqCode?.startsWith("I")&& - - } + > + {isUpdatingShortList ? ( + <> + + 처리중... + + ) : ( + <> + + Short List 확정 + {participatingCount > 0 && ` (${participatingCount})`} + + )} + + } {/* 견적 비교 버튼 */} @@ -1370,7 +1565,7 @@ export function RfqVendorTable({ )} - + - @@ -532,7 +532,7 @@ export function AddDocumentDialog({ interface EditDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null contractId: number projectType: "ship" | "plant" } @@ -753,7 +753,7 @@ export function EditDocumentDialog({ interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null stageId: number | null } @@ -1290,7 +1290,7 @@ export function ExcelImportDialog({ interface DeleteDocumentsDialogProps extends React.ComponentPropsWithoutRef { - documents: Row["original"][] + documents: Row["original"][] showTrigger?: boolean onSuccess?: () => void } diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index 87b221b7..601a9152 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -1,11 +1,10 @@ "use client" import * as React from "react" -import { type DocumentStagesOnlyView } from "@/db/schema" +import { type StageDocumentsView } from "@/db/schema" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react" +import { Download, RefreshCw, Send, CheckCircle, AlertCircle, Plus, FileSpreadsheet, Loader2 } from "lucide-react" import { toast } from "sonner" - import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" @@ -15,12 +14,17 @@ import { AddDocumentDialog, ExcelImportDialog } from "./document-stage-dialogs" +import { sendDocumentsToSHI } from "./document-stages-service" +import { useDocumentPolling } from "@/hooks/use-document-polling" +import { cn } from "@/lib/utils" +import { MultiUploadDialog } from "./upload/components/multi-upload-dialog" +// import { useRouter } from "next/navigation" // 서버 액션 import (필요한 경우) // import { importDocumentsExcel } from "./document-stages-service" interface DocumentsTableToolbarActionsProps { - table: Table + table: Table contractId: number projectType: "ship" | "plant" } @@ -33,6 +37,43 @@ export function DocumentsTableToolbarActions({ // 다이얼로그 상태 관리 const [showAddDialog, setShowAddDialog] = React.useState(false) const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false) + const [isSending, setIsSending] = React.useState(false) + const router = useRouter() + + // 자동 폴링 훅 사용 + const { + isPolling, + lastPolledAt, + pollingStatus, + pollDocuments + } = useDocumentPolling({ + contractId, + autoStart: true, + onUpdate: () => { + // 테이블 새로고침 + router.refresh() + } + }) + + async function handleSendToSHI() { + setIsSending(true) + try { + const result = await sendDocumentsToSHI(contractId) + + if (result.success) { + toast.success(result.message) + router.refresh() + // 테이블 새로고침 + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("전송 중 오류가 발생했습니다.") + } finally { + setIsSending(false) + } + } + const handleExcelImport = () => { setShowExcelImportDialog(true) @@ -50,17 +91,28 @@ export function DocumentsTableToolbarActions({ }) } + + + return (
+ + + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} + {(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const deletableDocuments = selectedRows + .map((row) => row.original)s + .filter((doc) => !doc.buyerSystemStatus); // buyerSystemStatus가 null인 것만 필터링 + + return deletableDocuments.length > 0 ? ( + table.toggleAllRowsSelected(false)} + /> + ) : null; + })()} {/* 2) 새 문서 추가 다이얼로그 */} @@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({ projectType={projectType} /> + {/* SHI 전송 버튼 */} + + + + + | null>> + setRowAction: React.Dispatch | null>> projectType: string domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } @@ -139,11 +144,11 @@ export function getDocumentStagesColumns({ setRowAction, projectType, domain = "partners", // 기본값 설정 -}: GetColumnsProps): ColumnDef[] { +}: GetColumnsProps): ColumnDef[] { const isPlantProject = projectType === "plant" const isEvcpDomain = domain === "evcp" - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ // 체크박스 선택 { id: "select", @@ -315,6 +320,75 @@ export function getDocumentStagesColumns({ // 나머지 공통 컬럼들 columns.push( // 현재 스테이지 (상태, 담당자 한 줄) + + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const doc = row.original + + return ( +
+ + {getStatusText(doc.status || '')} + +
+ ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Status" + }, + }, + + { + accessorKey: "buyerSystemStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const doc = row.original + const getBuyerStatusBadge = () => { + if (!doc.buyerSystemStatus) { + return Not Recieved + } + + switch (doc.buyerSystemStatus) { + case '승인(DC)': + return Approved + case '검토중': + return 검토중 + case '반려': + return 반려 + default: + return {doc.buyerSystemStatus} + } + } + + return ( +
+ {getBuyerStatusBadge()} + {doc.buyerSystemComment && ( + + + + + +

{doc.buyerSystemComment}

+
+
+ )} +
+ ) + }, + size: 120, + }, { accessorKey: "currentStageName", header: ({ column }) => ( @@ -486,7 +560,7 @@ export function getDocumentStagesColumns({ label: "Delete Document", icon: Trash2, action: () => setRowAction({ row, type: "delete" }), - show: true, + show: !doc.buyerSystemStatus, // null일 때만 true className: "text-red-600 dark:text-red-400", shortcut: "⌘⌫" } diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index ca5e9c5b..72a804a8 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -2,7 +2,7 @@ "use client" import React from "react" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -40,7 +40,7 @@ import { toast } from "sonner" import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { - document: DocumentStagesOnlyView + document: StageDocumentsView onEditStage: (stageId: number) => void projectType: "ship" | "plant" } diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 57f17bae..30a235c3 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,7 +4,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" -import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" +import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -32,6 +32,7 @@ import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-docu import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { ShiBuyerSystemAPI } from "./shi-buyer-system-api" interface UpdateDocumentData { documentId: number @@ -810,7 +811,7 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - // .orderBy(asc(documentClassOptions.sortOrder)) + .orderBy(asc(documentClassOptions.sdq)) return { success: true, data: options } } catch (error) { @@ -920,6 +921,8 @@ export async function createDocument(data: CreateDocumentData) { }, }) + console.log(contract,"contract") + if (!contract) { return { success: false, error: "유효하지 않은 계약(ID)입니다." } } @@ -1053,7 +1056,7 @@ export async function getDocumentStagesOnly( finalWhere = and( advancedWhere, globalWhere, - eq(documentStagesOnlyView.contractId, contractId) + eq(stageDocumentsView.contractId, contractId) ) } @@ -1066,7 +1069,7 @@ export async function getDocumentStagesOnly( ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(documentStagesOnlyView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // 트랜잭션 실행 @@ -1183,3 +1186,115 @@ export async function getDocumentsByStageStats(contractId: number) { return [] } } + + +export async function sendDocumentsToSHI(contractId: number) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.sendToSHI(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("SHI 전송 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "전송 중 오류가 발생했습니다." + } + } +} + +export async function pullDocumentStatusFromSHI( + contractId: number, +) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.pullDocumentStatus(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("문서 상태 풀링 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "상태 가져오기 중 오류가 발생했습니다." + } + } +} + + +interface FileValidation { + projectId: number + docNumber: string + stageName: string + revision: string +} + +interface ValidationResult { + projectId: number + docNumber: string + stageName: string + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: number + } +} + +export async function validateFiles(files: FileValidation[]): Promise { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + throw new Error("Unauthorized") + } + + const vendorId = session.user.companyId + const results: ValidationResult[] = [] + + for (const file of files) { + // stageSubmissionView에서 매칭되는 레코드 찾기 + const match = await db + .select({ + documentId: stageSubmissionView.documentId, + stageId: stageSubmissionView.stageId, + documentTitle: stageSubmissionView.documentTitle, + latestRevisionNumber: stageSubmissionView.latestRevisionNumber, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + eq(stageSubmissionView.projectId, file.projectId), + eq(stageSubmissionView.docNumber, file.docNumber), + eq(stageSubmissionView.stageName, file.stageName) + ) + ) + .limit(1) + + if (match.length > 0) { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + matched: { + documentId: match[0].documentId, + stageId: match[0].stageId!, + documentTitle: match[0].documentTitle, + currentRevision: match[0].latestRevisionNumber || 0, + } + }) + } else { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + }) + } + } + + return results +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 3d2ddafd..50d54a92 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -9,7 +9,7 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { getDocumentStagesOnly } from "./document-stages-service" -import type { DocumentStagesOnlyView } from "@/db/schema" +import type { StageDocumentsView } from "@/db/schema" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -51,17 +51,17 @@ export function DocumentStagesTable({ const { data: session } = useSession() - + // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' const { t } = useTranslation(lng, 'document') - // 세션에서 도메인을 가져오기 - const currentDomain = session?.user?.domain as "evcp" | "partners" + // 세션에서 도메인을 가져오기 + const currentDomain = session?.user?.domain as "evcp" | "partners" // 상태 관리 - const [rowAction, setRowAction] = React.useState | null>(null) + const [rowAction, setRowAction] = React.useState | null>(null) const [expandedRows, setExpandedRows] = React.useState>(new Set()) const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') @@ -72,7 +72,7 @@ export function DocumentStagesTable({ const [excelImportOpen, setExcelImportOpen] = React.useState(false) // 선택된 항목들 - const [selectedDocument, setSelectedDocument] = React.useState(null) + const [selectedDocument, setSelectedDocument] = React.useState(null) const [selectedStageId, setSelectedStageId] = React.useState(null) // 컬럼 정의 @@ -116,7 +116,7 @@ export function DocumentStagesTable({ const stats = React.useMemo(() => { console.log('DocumentStagesTable - data:', data) console.log('DocumentStagesTable - data length:', data?.length) - + const totalDocs = data?.length || 0 const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 const dueSoon = data?.filter(doc => @@ -138,7 +138,7 @@ export function DocumentStagesTable({ highPriority, avgProgress } - + console.log('DocumentStagesTable - stats:', result) return result }, [data]) @@ -201,10 +201,10 @@ export function DocumentStagesTable({ } // 필터 필드 정의 - const filterFields: DataTableFilterField[] = [ + const filterFields: DataTableFilterField[] = [ ] - const advancedFilterFields: DataTableAdvancedFilterField[] = [ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "docNumber", label: "문서번호", diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts index 3ddb7195..c1409205 100644 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -10,7 +10,7 @@ import { type ExcelImportResult, type CreateDocumentInput } from './document-stage-validations' -import { DocumentStagesOnlyView } from '@/db/schema' +import { StageDocumentsView } from '@/db/schema' // ============================================================================= // 1. 엑셀 템플릿 생성 및 다운로드 @@ -510,7 +510,7 @@ function formatExcelDate(value: any): string | undefined { // 문서 데이터를 엑셀로 익스포트 export function exportDocumentsToExcel( - documents: DocumentStagesOnlyView[], + documents: StageDocumentsView[], projectType: "ship" | "plant" ) { const headers = [ @@ -609,7 +609,7 @@ export function exportDocumentsToExcel( } // 스테이지 상세 데이터를 엑셀로 익스포트 -export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { +export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { const headers = [ "문서번호", "문서명", diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts new file mode 100644 index 00000000..1f15efa6 --- /dev/null +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -0,0 +1,874 @@ +// app/lib/shi-buyer-system-api.ts +import db from "@/db/db" +import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema" +import { eq, and, sql, ne } from "drizzle-orm" +import fs from 'fs/promises' +import path from 'path' + +interface ShiDocumentInfo { + PROJ_NO: string + SHI_DOC_NO: string + CATEGORY: string + RESPONSIBLE_CD: string + RESPONSIBLE: string + VNDR_CD: string + VNDR_NM: string + DSN_SKL: string + MIFP_CD: string + MIFP_NM: string + CG_EMPNO1: string + CG_EMPNM1: string + OWN_DOC_NO: string + DSC: string + DOC_CLASS: string + COMMENT: string + STATUS: string + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +interface ShiScheduleInfo { + PROJ_NO: string + SHI_DOC_NO: string + DDPKIND: string + SCHEDULE_TYPE: string + BASELINE1: string | null + REVISED1: string | null + FORECAST1: string | null + ACTUAL1: string | null + BASELINE2: string | null + REVISED2: string | null + FORECAST2: string | null + ACTUAL2: string | null + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +// SHI API 응답 타입 +interface ShiDocumentResponse { + PROJ_NO: string + SHI_DOC_NO: string + STATUS: string + COMMENT: string | null + CATEGORY?: string + RESPONSIBLE_CD?: string + RESPONSIBLE?: string + VNDR_CD?: string + VNDR_NM?: string + DSN_SKL?: string + MIFP_CD?: string + MIFP_NM?: string + CG_EMPNO1?: string + CG_EMPNM1?: string + OWN_DOC_NO?: string + DSC?: string + DOC_CLASS?: string + CRTER?: string + CRTE_DTM?: string + CHGR?: string + CHG_DTM?: string +} + +interface ShiApiResponse { + GetDwgInfoResult: ShiDocumentResponse[] +} + +// InBox 파일 정보 인터페이스 추가 +interface InBoxFileInfo { + PROJ_NO: string + SHI_DOC_NO: string + STAGE_NAME: string + REVISION_NO: string + VNDR_CD: string + VNDR_NM: string + FILE_NAME: string + FILE_SIZE: number + CONTENT_TYPE: string + UPLOAD_DATE: string + UPLOADED_BY: string + STATUS: string + COMMENT: string +} + +// SaveInBoxList API 응답 인터페이스 +interface SaveInBoxListResponse { + SaveInBoxListResult: { + success: boolean + message: string + processedCount?: number + files?: Array<{ + fileName: string + networkPath: string + status: string + }> + } +} + +export class ShiBuyerSystemAPI { + private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc' + private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc' + private localStoragePath = process.env.NAS_PATH || './uploads' + + async sendToSHI(contractId: number) { + try { + // 1. 전송할 문서 조회 + const documents = await this.getDocumentsToSend(contractId) + + if (documents.length === 0) { + return { success: false, message: "전송할 문서가 없습니다." } + } + + // 2. 도서 정보 전송 + await this.sendDocumentInfo(documents) + + // 3. 스케줄 정보 전송 + await this.sendScheduleInfo(documents) + + // 4. 동기화 상태 업데이트 + await this.updateSyncStatus(documents.map(d => d.documentId)) + + return { + success: true, + message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, + count: documents.length + } + } catch (error) { + console.error("SHI 전송 오류:", error) + + // 에러 시 동기화 상태 업데이트 + await this.updateSyncError( + contractId, + error instanceof Error ? error.message : "알 수 없는 오류" + ) + + throw error + } + } + + private async getDocumentsToSend(contractId: number) { + const result = await db + .select({ + documentId: stageDocuments.id, + docNumber: stageDocuments.docNumber, + vendorDocNumber: stageDocuments.vendorDocNumber, + title: stageDocuments.title, + status: stageDocuments.status, + projectCode: sql`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, + vendorCode: sql`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, + vendorName: sql`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + stages: sql` + COALESCE( + (SELECT json_agg(row_to_json(s.*)) + FROM stage_issue_stages s + WHERE s.document_id = ${stageDocuments.id} + ORDER BY s.stage_order), + '[]'::json + ) + ` + }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE'), + ne(stageDocuments.buyerSystemStatus, "승인(DC)") + ) + ) + + return result + } + + private async sendDocumentInfo(documents: any[]) { + const shiDocuments: ShiDocumentInfo[] = documents.map(doc => ({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + CATEGORY: "SHIP", + RESPONSIBLE_CD: "EVCP", + RESPONSIBLE: "eVCP System", + VNDR_CD: doc.vendorCode || "", + VNDR_NM: doc.vendorName || "", + DSN_SKL: "B3", + MIFP_CD: "", + MIFP_NM: "", + CG_EMPNO1: "", + CG_EMPNM1: "", + OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber, + DSC: doc.title, + DOC_CLASS: "B3", + COMMENT: "", + STATUS: "ACTIVE", + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + })) + + const response = await fetch(`${this.baseUrl}/SetDwgInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(shiDocuments) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async sendScheduleInfo(documents: any[]) { + const schedules: ShiScheduleInfo[] = [] + + for (const doc of documents) { + for (const stage of doc.stages) { + if (stage.plan_date) { + schedules.push({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + DDPKIND: "V", + SCHEDULE_TYPE: stage.stage_name, + BASELINE1: stage.plan_date ? new Date(stage.plan_date).toISOString() : null, + REVISED1: null, + FORECAST1: null, + ACTUAL1: stage.actual_date ? new Date(stage.actual_date).toISOString() : null, + BASELINE2: null, + REVISED2: null, + FORECAST2: null, + ACTUAL2: null, + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + }) + } + } + } + + if (schedules.length === 0) { + console.log("전송할 스케줄 정보가 없습니다.") + return + } + + const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(schedules) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async updateSyncStatus(documentIds: number[]) { + if (documentIds.length === 0) return + + await db + .update(stageDocuments) + .set({ + syncStatus: 'synced', + lastSyncedAt: new Date(), + syncError: null, + syncVersion: sql`sync_version + 1`, + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${documentIds})`) + } + + private async updateSyncError(contractId: number, errorMessage: string) { + await db + .update(stageDocuments) + .set({ + syncStatus: 'error', + syncError: errorMessage, + lastModifiedBy: 'EVCP' + }) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE') + ) + ) + } + + async pullDocumentStatus(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract) { + throw new Error(`계약을 찾을 수 없습니다: ${contractId}`) + } + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, contract.projectId), + }); + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) + } + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, contract.vendorId), + }); + + if (!vendor) { + throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) + } + + const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { + VNDR_CD: vendor.vendorCode + }) + + if (!shiDocuments || shiDocuments.length === 0) { + return { + success: true, + message: "동기화할 문서가 없습니다.", + updatedCount: 0, + documents: [] + } + } + + const updateResults = await this.updateLocalDocuments(project.code, shiDocuments) + + return { + success: true, + message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`, + updatedCount: updateResults.updatedCount, + newCount: updateResults.newCount, + documents: updateResults.documents + } + } catch (error) { + console.error("문서 상태 풀링 오류:", error) + throw error + } + } + + private async fetchDocumentsFromSHI( + projectCode: string, + filters?: { + SHI_DOC_NO?: string + CATEGORY?: string + VNDR_CD?: string + RESPONSIBLE_CD?: string + STATUS?: string + DOC_CLASS?: string + CRTE_DTM_FROM?: string + CRTE_DTM_TO?: string + CHG_DTM_FROM?: string + CHG_DTM_TO?: string + } + ): Promise { + const params = new URLSearchParams({ PROJ_NO: projectCode }) + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value) params.append(key, value) + }) + } + + const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`문서 조회 실패: ${response.statusText}`) + } + + const data: ShiApiResponse = await response.json() + + return data.GetDwgInfoResult || [] + } + + private async updateLocalDocuments( + projectCode: string, + shiDocuments: ShiDocumentResponse[] + ) { + let updatedCount = 0 + let newCount = 0 + const updatedDocuments: any[] = [] + + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode) + }) + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`) + } + + for (const shiDoc of shiDocuments) { + const localDoc = await db.query.stageDocuments.findFirst({ + where: and( + eq(stageDocuments.projectId, project.id), + eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO) + ) + }) + + if (localDoc) { + if ( + localDoc.buyerSystemStatus !== shiDoc.STATUS || + localDoc.buyerSystemComment !== shiDoc.COMMENT + ) { + await db + .update(stageDocuments) + .set({ + buyerSystemStatus: shiDoc.STATUS, + buyerSystemComment: shiDoc.COMMENT, + lastSyncedAt: new Date(), + syncStatus: 'synced', + syncError: null, + lastModifiedBy: 'BUYER_SYSTEM', + syncVersion: sql`sync_version + 1` + }) + .where(eq(stageDocuments.id, localDoc.id)) + + updatedCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || localDoc.title, + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'updated' + }) + } + } else { + console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`) + newCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || 'N/A', + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'new_in_shi' + }) + } + } + + return { + updatedCount, + newCount, + documents: updatedDocuments + } + } + + async getSyncStatus(contractId: number) { + const documents = await db + .select({ + docNumber: stageDocuments.docNumber, + title: stageDocuments.title, + syncStatus: stageDocuments.syncStatus, + lastSyncedAt: stageDocuments.lastSyncedAt, + syncError: stageDocuments.syncError, + buyerSystemStatus: stageDocuments.buyerSystemStatus, + buyerSystemComment: stageDocuments.buyerSystemComment + }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + return documents + } + + /** + * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화 + * @param submissionIds 제출 ID 배열 + */ + async syncSubmissionsToSHI(submissionIds: number[]) { + const results = { + totalCount: submissionIds.length, + successCount: 0, + failedCount: 0, + details: [] as any[] + } + + for (const submissionId of submissionIds) { + try { + const result = await this.syncSingleSubmission(submissionId) + if (result.success) { + results.successCount++ + } else { + results.failedCount++ + } + results.details.push(result) + } catch (error) { + results.failedCount++ + results.details.push({ + submissionId, + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }) + } + } + + return results + } + + /** + * 단일 제출 건 동기화 + */ + private async syncSingleSubmission(submissionId: number) { + try { + // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) + const submissionInfo = await this.getSubmissionFullInfo(submissionId) + + if (!submissionInfo) { + throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) + } + + // 2. 동기화 시작 상태 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'syncing') + + // 3. 첨부파일들과 실제 파일 내용을 준비 + const filesWithContent = await this.prepareFilesWithContent(submissionInfo) + + if (filesWithContent.length === 0) { + await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') + return { + submissionId, + success: true, + message: "전송할 파일이 없습니다" + } + } + + // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 + const response = await this.sendToInBox(filesWithContent) + + // 5. 응답받은 네트워크 경로에 파일 저장 + if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) { + await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files) + + // 6. 동기화 결과 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { + syncedFilesCount: filesWithContent.length, + buyerSystemStatus: 'SYNCED' + }) + + // 개별 파일 상태 업데이트 + await this.updateAttachmentsSyncStatus( + submissionInfo.attachments.map(a => a.id), + 'synced' + ) + + return { + submissionId, + success: true, + message: response.SaveInBoxListResult.message, + syncedFiles: filesWithContent.length + } + } else { + throw new Error(response.SaveInBoxListResult.message) + } + } catch (error) { + await this.updateSubmissionSyncStatus( + submissionId, + 'failed', + error instanceof Error ? error.message : '알 수 없는 오류' + ) + + throw error + } + } + + /** + * 제출 정보 조회 (관련 정보 포함) + */ + private async getSubmissionFullInfo(submissionId: number) { + const result = await db + .select({ + submission: stageSubmissions, + stage: stageIssueStages, + document: stageDocuments, + project: projects, + vendor: vendors + }) + .from(stageSubmissions) + .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id)) + .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id)) + .innerJoin(projects, eq(stageDocuments.projectId, projects.id)) + .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id)) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1) + + if (result.length === 0) return null + + // 첨부파일 조회 - 파일 경로 포함 + const attachments = await db + .select() + .from(stageSubmissionAttachments) + .where( + and( + eq(stageSubmissionAttachments.submissionId, submissionId), + eq(stageSubmissionAttachments.status, 'ACTIVE') + ) + ) + + return { + ...result[0], + attachments + } + } + + /** + * 파일 내용과 함께 InBox 파일 정보 준비 + */ + private async prepareFilesWithContent(submissionInfo: any): Promise> { + const filesWithContent: Array = [] + + for (const attachment of submissionInfo.attachments) { + try { + // 파일 경로 결정 (storagePath 또는 storageUrl 사용) + const filePath = attachment.storagePath || attachment.storageUrl + + if (!filePath) { + console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`) + continue + } + + // 전체 경로 생성 + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.localStoragePath, filePath) + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath) + + // 파일 정보 생성 + const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = { + PROJ_NO: submissionInfo.project.code, + SHI_DOC_NO: submissionInfo.document.docNumber, + STAGE_NAME: submissionInfo.stage.stageName, + REVISION_NO: String(submissionInfo.submission.revisionNumber), + VNDR_CD: submissionInfo.vendor?.vendorCode || '', + VNDR_NM: submissionInfo.vendor?.vendorName || '', + FILE_NAME: attachment.fileName, + FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용 + CONTENT_TYPE: attachment.mimeType || 'application/octet-stream', + UPLOAD_DATE: new Date().toISOString(), + UPLOADED_BY: submissionInfo.submission.submittedBy, + STATUS: 'PENDING', + COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, + fileBuffer: fileBuffer, + attachment: attachment + } + + filesWithContent.push(fileInfo) + } catch (error) { + console.error(`파일 읽기 실패: ${attachment.fileName}`, error) + // 파일 읽기 실패 시 계속 진행 + continue + } + } + + return filesWithContent + } + + /** + * SaveInBoxList API 호출 (파일 메타데이터만 전송) + */ + private async sendToInBox(files: Array): Promise { + // fileBuffer를 제외한 메타데이터만 전송 + const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata) + + const request = { files: fileMetadata } + + const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(request) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + + // 응답 구조 확인 및 처리 + if (!data.SaveInBoxListResult) { + return { + SaveInBoxListResult: { + success: true, + message: "전송 완료", + processedCount: files.length, + files: files.map(f => ({ + fileName: f.FILE_NAME, + networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, + status: 'READY' + })) + } + } + } + + return data + } + + /** + * 네트워크 경로에 파일 저장 + */ + private async saveFilesToNetworkPaths( + filesWithContent: Array, + networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }> + ) { + for (const fileInfo of filesWithContent) { + const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME) + + if (!pathInfo || !pathInfo.networkPath) { + console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`) + continue + } + + try { + // 네트워크 경로에 파일 저장 + // Windows 네트워크 경로인 경우 처리 + let targetPath = pathInfo.networkPath + + // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환 + if (process.platform === 'win32' && targetPath.startsWith('\\\\')) { + // 그대로 사용 + } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) { + // Linux/Mac에서는 SMB 마운트 경로로 변환 필요 + // 예: \\\\server\\share -> /mnt/server/share + targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/') + } + + // 디렉토리 생성 (없는 경우) + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // 파일 저장 + await fs.writeFile(targetPath, fileInfo.fileBuffer) + + console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`) + + // DB에 네트워크 경로 업데이트 + await db + .update(stageSubmissionAttachments) + .set({ + buyerSystemUrl: pathInfo.networkPath, + buyerSystemStatus: 'UPLOADED', + lastModifiedBy: 'EVCP' + }) + .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) + + } catch (error) { + console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) + // 개별 파일 실패는 전체 프로세스를 중단하지 않음 + } + } + } + + /** + * 제출 동기화 상태 업데이트 + */ + private async updateSubmissionSyncStatus( + submissionId: number, + status: string, + error?: string | null, + additionalData?: any + ) { + const updateData: any = { + syncStatus: status, + lastSyncedAt: new Date(), + syncError: error, + lastModifiedBy: 'EVCP', + ...additionalData + } + + if (status === 'failed') { + updateData.syncRetryCount = sql`sync_retry_count + 1` + updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도 + } + + await db + .update(stageSubmissions) + .set(updateData) + .where(eq(stageSubmissions.id, submissionId)) + } + + /** + * 첨부파일 동기화 상태 업데이트 + */ + private async updateAttachmentsSyncStatus( + attachmentIds: number[], + status: string + ) { + if (attachmentIds.length === 0) return + + await db + .update(stageSubmissionAttachments) + .set({ + syncStatus: status, + syncCompletedAt: status === 'synced' ? new Date() : null, + buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING', + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${attachmentIds})`) + } + + /** + * 동기화 재시도 (실패한 건들) + */ + async retrySyncFailedSubmissions(contractId?: number) { + const conditions = [ + eq(stageSubmissions.syncStatus, 'failed'), + sql`next_retry_at <= NOW()` + ] + + if (contractId) { + const documentIds = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + if (documentIds.length > 0) { + conditions.push( + sql`document_id = ANY(${documentIds.map(d => d.id)})` + ) + } + } + + const failedSubmissions = await db + .select({ id: stageSubmissions.id }) + .from(stageSubmissions) + .where(and(...conditions)) + .limit(10) // 한 번에 최대 10개씩 재시도 + + if (failedSubmissions.length === 0) { + return { + success: true, + message: "재시도할 제출 건이 없습니다.", + retryCount: 0 + } + } + + const submissionIds = failedSubmissions.map(s => s.id) + const results = await this.syncSubmissionsToSHI(submissionIds) + + return { + success: true, + message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`, + ...results + } + } +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx new file mode 100644 index 00000000..c0f17afc --- /dev/null +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -0,0 +1,379 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { StageSubmissionView } from "@/db/schema" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Ellipsis, + Upload, + Eye, + RefreshCw, + CheckCircle2, + XCircle, + AlertCircle, + Clock +} from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorDocNumber = row.original.vendorDocNumber + return ( +
+
{row.getValue("docNumber")}
+ {vendorDocNumber && ( +
{vendorDocNumber}
+ )} +
+ ) + }, + size: 150, + }, + { + accessorKey: "documentTitle", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue("documentTitle")} +
+ ), + size: 250, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.getValue("projectCode")} + ), + size: 100, + }, + { + accessorKey: "stageName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const stageName = row.getValue("stageName") as string + const stageStatus = row.original.stageStatus + const stageOrder = row.original.stageOrder + + return ( +
+
+ + {stageOrder ? `#${stageOrder}` : ""} + + {stageName} +
+ {stageStatus && ( + + {stageStatus} + + )} +
+ ) + }, + size: 200, + }, + { + accessorKey: "stagePlanDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const planDate = row.getValue("stagePlanDate") as Date | null + const isOverdue = row.original.isOverdue + const daysUntilDue = row.original.daysUntilDue + + if (!planDate) return - + + return ( +
+
+ {formatDate(planDate)} +
+ {daysUntilDue !== null && ( +
+ {isOverdue ? ( + + + {Math.abs(daysUntilDue)} days overdue + + ) : daysUntilDue === 0 ? ( + + + Due today + + ) : ( + + {daysUntilDue} days remaining + + )} +
+ )} +
+ ) + }, + size: 150, + }, + { + accessorKey: "latestSubmissionStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("latestSubmissionStatus") as string | null + const reviewStatus = row.original.latestReviewStatus + const revisionNumber = row.original.latestRevisionNumber + const revisionCode = row.original.latestRevisionCode + + if (!status) { + return ( + + + Not submitted + + ) + } + + return ( +
+ + {reviewStatus || status} + + {revisionCode !== null &&( +
+ {revisionCode} +
+ )} +
+ ) + }, + size: 150, + }, + { + id: "syncStatus", + accessorKey: "latestSyncStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const syncStatus = row.getValue("latestSyncStatus") as string | null + const syncProgress = row.original.syncProgress + const requiresSync = row.original.requiresSync + + if (!syncStatus || syncStatus === "pending") { + if (requiresSync) { + return ( + + + Pending + + ) + } + return - + } + + return ( +
+ + {syncStatus === "syncing" && } + {syncStatus === "synced" && } + {syncStatus === "failed" && } + {syncStatus} + + {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && ( + + )} +
+ ) + }, + size: 120, + }, + { + accessorKey: "totalFiles", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const totalFiles = row.getValue("totalFiles") as number + const syncedFiles = row.original.syncedFilesCount + + if (!totalFiles) return 0 + + return ( +
+ {syncedFiles !== null && syncedFiles !== undefined ? ( + {syncedFiles}/{totalFiles} + ) : ( + {totalFiles} + )} +
+ ) + }, + size: 80, + }, + // { + // accessorKey: "vendorName", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) => { + // const vendorName = row.getValue("vendorName") as string + // const vendorCode = row.original.vendorCode + + // return ( + //
+ //
{vendorName}
+ // {vendorCode && ( + //
{vendorCode}
+ // )} + //
+ // ) + // }, + // size: 150, + // }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const requiresSubmission = row.original.requiresSubmission + const requiresSync = row.original.requiresSync + const latestSubmissionId = row.original.latestSubmissionId + + return ( + + + + + + {requiresSubmission && ( + setRowAction({ row, type: "upload" })} + className="gap-2" + > + + Upload Documents + + )} + + {latestSubmissionId && ( + <> + setRowAction({ row, type: "view" })} + className="gap-2" + > + + View Submission + + + {requiresSync && ( + setRowAction({ row, type: "sync" })} + className="gap-2" + > + + Retry Sync + + )} + + )} + + + + setRowAction({ row, type: "history" })} + className="gap-2" + > + + View History + + + + ) + }, + size: 40, + } + ] +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/history-dialog.tsx b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx new file mode 100644 index 00000000..9c4f160b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx @@ -0,0 +1,144 @@ +// lib/vendor-document-list/plant/upload/components/history-dialog.tsx +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle2, + XCircle, + Clock, + FileText, + User, + Calendar, + AlertCircle +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime } from "@/lib/utils" + +interface HistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +export function HistoryDialog({ + open, + onOpenChange, + submission +}: HistoryDialogProps) { + const history = submission.submissionHistory || [] + + const getStatusIcon = (status: string, reviewStatus?: string) => { + if (reviewStatus === "APPROVED") { + return + } + if (reviewStatus === "REJECTED") { + return + } + if (status === "SUBMITTED") { + return + } + return + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + + {reviewStatus || status} + + ) + } + + return ( + + + + Submission History + + View all submission history for this stage + + + + {/* Document Info */} +
+
+
+ + {submission.docNumber} + + - {submission.documentTitle} + +
+ {submission.stageName} +
+
+ + {/* History Timeline */} + + {history.length === 0 ? ( +
+ No submission history available +
+ ) : ( +
+ {history.map((item, index) => ( +
+ {/* Timeline line */} + {index < history.length - 1 && ( +
+ )} + + {/* Timeline item */} +
+
+ {getStatusIcon(item.status, item.reviewStatus)} +
+ +
+
+ Revision {item.revisionNumber} + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + + Sync: {item.syncStatus} + + )} +
+ +
+
+ + {item.submittedBy} +
+
+ + {formatDateTime(new Date(item.submittedAt))} +
+
+ + {item.fileCount} file(s) +
+
+
+
+
+ ))} +
+ )} + + +
+ ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return + } + + switch (fileData.status) { + case 'validating': + return + case 'uploading': + return + case 'success': + return + case 'error': + return + default: + if (fileData.matched) { + return + } else { + return + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + + + + + + + Bulk Document Upload + + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + + + + {/* Custom Dropzone with input */} +
document.getElementById('file-upload')?.click()} + > + + +

Drop files here or click to browse

+

+ Maximum 10GB total • Format: DocNumber_StageName_Revision.ext +

+
+ + {/* Stats */} + {files.length > 0 && ( +
+ Total: {stats.total} + + Valid Format: {stats.valid} + + 0 ? "success" : "secondary"}> + Matched: {stats.matched} + + 0 ? "default" : "outline"}> + Ready: {stats.ready} + + + Size: {formatFileSize(stats.totalSize)} + +
+ )} + + {/* File List */} + {files.length > 0 && ( +
+ + +
Files ({files.length})
+
+ + {files.map((fileData, index) => ( + + + {getStatusIcon(fileData)} + + + + {fileData.file.name} + + {getStatusDescription(fileData)} + + + + + {fileData.file.size} + + + + + + + ))} +
+
+ )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + + + + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + + + )} + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/project-filter.tsx b/lib/vendor-document-list/plant/upload/components/project-filter.tsx new file mode 100644 index 00000000..33c2819b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/project-filter.tsx @@ -0,0 +1,109 @@ +// lib/vendor-document-list/plant/upload/components/project-filter.tsx +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Building2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface Project { + id: number + code: string +} + +interface ProjectFilterProps { + projects: Project[] + value?: number | null + onValueChange: (value: number | null) => void +} + +export function ProjectFilter({ projects, value, onValueChange }: ProjectFilterProps) { + const [open, setOpen] = React.useState(false) + + const selectedProject = projects.find(p => p.id === value) + + return ( + + + + + + + + + No project found. + + { + onValueChange(null) + setOpen(false) + }} + > + + All Projects + + {projects.map((project) => ( + { + onValueChange(project.id) + setOpen(false) + }} + > + + {project.code} + + ))} + + + + + + ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx new file mode 100644 index 00000000..a33a7160 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx @@ -0,0 +1,265 @@ +// lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + FileList, + FileListAction, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + FileIcon, + Loader2, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { StageSubmissionView } from "@/db/schema" + +interface SingleUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView + onUploadComplete?: () => void +} + +export function SingleUploadDialog({ + open, + onOpenChange, + submission, + onUploadComplete +}: SingleUploadDialogProps) { + const [files, setFiles] = useState([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent) => { + const fileList = e.target.files + if (fileList) { + setFiles(Array.from(fileList)) + } + } + + // 파일 제거 + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 처리 + const handleUpload = async () => { + if (files.length === 0) { + toast.error("Please select files to upload") + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + + files.forEach((file) => { + formData.append("files", file) + }) + + formData.append("documentId", submission.documentId.toString()) + formData.append("stageId", submission.stageId!.toString()) + formData.append("description", description) + + // 현재 리비전 + 1 + const nextRevision = (submission.latestRevisionNumber || 0) + 1 + formData.append("revision", nextRevision.toString()) + + const response = await fetch("/api/stage-submissions/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + throw new Error("Upload failed") + } + + const result = await response.json() + toast.success(`Successfully uploaded ${files.length} file(s)`) + + // 초기화 및 닫기 + setFiles([]) + setDescription("") + onOpenChange(false) + onUploadComplete?.() + + } catch (error) { + console.error("Upload error:", error) + toast.error("Failed to upload files") + } finally { + setIsUploading(false) + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + + return ( + + + + Upload Documents + + Upload documents for this stage submission + + + + {/* Document Info */} +
+
+ Document: + {submission.docNumber} + {submission.vendorDocNumber && ( + + ({submission.vendorDocNumber}) + + )} +
+
+ Stage: + {submission.stageName} +
+
+ Current Revision: + Rev. {submission.latestRevisionNumber || 0} + + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + +
+
+ + {/* File Upload Area */} +
fileInputRef.current?.click()} + > + + +

Click to browse files

+

+ You can select multiple files +

+
+ + {/* File List */} + {files.length > 0 && ( + <> + + {files.map((file, index) => ( + + + + + + {file.name} + + + {file.size} + + + + + + ))} + + +
+ {files.length} file(s) selected + Total: {formatFileSize(totalSize)} +
+ + )} + + {/* Description */} +
+ +