diff options
Diffstat (limited to 'lib/vendor-document-list')
12 files changed, 612 insertions, 3804 deletions
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index d2a14980..344597fa 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -183,7 +183,6 @@ class ImportService { .where(eq(contracts.id, contractId)) .limit(1) - return result?.projectCode && result?.vendorCode ? { projectCode: result.projectCode, vendorCode: result.vendorCode } : null @@ -608,6 +607,9 @@ class ImportService { eq(documents.externalSystemType, sourceSystem) )) + console.log(contractId, "contractId") + + // 프로젝트 코드와 벤더 코드 조회 const contractInfo = await this.getContractInfoById(contractId) diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx index b80c0869..ad184378 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx @@ -16,123 +16,36 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { Ellipsis, - Calendar, - CalendarClock, - User, FileText, Eye, Edit, Trash2, - Building, - Code, - Settings } from "lucide-react" import { cn } from "@/lib/utils" import { SimplifiedDocumentsView } from "@/db/schema" +// DocumentSelectionContext를 import (실제 파일 경로에 맞게 수정 필요) +// 예: import { DocumentSelectionContext } from "../user-vendor-document-display" +// 또는: import { DocumentSelectionContext } from "./user-vendor-document-display" +import { DocumentSelectionContext } from "@/components/ship-vendor-document/user-vendor-document-table-container" + interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<SimplifiedDocumentsView> | null>> } -// 유틸리티 함수들 -const getDrawingKindText = (drawingKind: string) => { - switch (drawingKind) { - case 'B3': return 'B3 도면' - case 'B4': return 'B4 도면' - case 'B5': return 'B5 도면' - default: return drawingKind - } -} - -const getDrawingKindColor = (drawingKind: string) => { - switch (drawingKind) { - case 'B3': return 'bg-blue-100 text-blue-800' - case 'B4': return 'bg-green-100 text-green-800' - case 'B5': return 'bg-purple-100 text-purple-800' - default: return 'bg-gray-100 text-gray-800' - } -} - -// 스테이지별 이름 표시 컴포넌트 -const StageNameDisplay = ({ - stageName, - drawingKind, - isFirst = true -}: { - stageName: string | null, - drawingKind: string | null, - isFirst?: boolean -}) => { - if (!stageName) return <span className="text-gray-400">-</span> - - const stageType = isFirst ? "1차" : "2차" - const getExpectedStage = () => { - if (drawingKind === 'B4') return isFirst ? 'Pre' : 'Work' - if (drawingKind === 'B3') return isFirst ? 'Approval' : 'Work' - if (drawingKind === 'B5') return isFirst ? 'First' : 'Second' - return '' - } - - return ( - <div className="flex flex-col gap-1"> - <div className="text-xs text-gray-500">{stageType} 스테이지</div> - <div className="text-sm font-medium">{stageName}</div> - {getExpectedStage() && ( - <div className="text-xs text-gray-400">({getExpectedStage()})</div> - )} - </div> - ) -} - -// 날짜 정보 표시 컴포넌트 -const StageDateInfo = ({ - planDate, - actualDate, - stageName -}: { - planDate: string | null - actualDate: string | null - stageName: string | null -}) => { - if (!planDate && !actualDate) { - return <span className="text-gray-400">날짜 미설정</span> - } - - const isCompleted = !!actualDate - const isLate = actualDate && planDate && new Date(actualDate) > new Date(planDate) - +// 날짜 표시 컴포넌트 (간단 버전) +const DateDisplay = ({ date, isSelected = false }: { date: string | null, isSelected?: boolean }) => { + if (!date) return <span className="text-gray-400">-</span> + return ( - <div className="flex flex-col gap-1"> - {planDate && ( - <div className="text-sm"> - <span className="text-gray-500">계획: </span> - <span>{formatDate(planDate)}</span> - </div> - )} - {actualDate && ( - <div className="text-sm"> - <span className="text-gray-500">실제: </span> - <span className={cn( - isLate ? "text-red-600 font-medium" : "text-green-600 font-medium" - )}> - {formatDate(actualDate)} - </span> - </div> - )} - {!actualDate && planDate && ( - <div className="text-xs text-orange-600"> - 진행중 - </div> - )} - {isCompleted && ( - <div className="text-xs text-green-600"> - ✓ 완료 - </div> - )} - </div> + <span className={cn( + "text-sm", + isSelected && "text-blue-600 font-semibold" + )}> + {formatDate(date)} + </span> ) } @@ -140,36 +53,28 @@ export function getSimplifiedDocumentColumns({ setRowAction, }: GetColumnsProps): ColumnDef<SimplifiedDocumentsView>[] { - // 기본 컬럼들 - const baseColumns: ColumnDef<SimplifiedDocumentsView>[] = [ - // 체크박스 선택 + const columns: ColumnDef<SimplifiedDocumentsView>[] = [ + // 라디오 버튼 같은 체크박스 선택 { id: "select", header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> + <div className="flex items-center justify-center"> + <span className="text-xs text-gray-500">선택</span> + </div> ), + cell: ({ row }) => { + const doc = row.original + + return ( + <SelectCell documentId={doc.documentId} /> + ) + }, size: 40, enableSorting: false, enableHiding: false, }, - // 문서번호 + Drawing Kind + // 문서번호 (선택된 행 하이라이트 적용) { accessorKey: "docNumber", header: ({ column }) => ( @@ -177,33 +82,19 @@ export function getSimplifiedDocumentColumns({ ), cell: ({ row }) => { const doc = row.original + return ( - <div className="flex flex-col gap-1 items-start"> - <span className="font-mono text-sm font-medium">{doc.docNumber}</span> - {doc.vendorDocNumber && ( - <span className="font-mono text-xs text-gray-500"> - 벤더: {doc.vendorDocNumber} - </span> - )} - {doc.drawingKind && ( - <Badge - variant="outline" - className={cn("text-xs", getDrawingKindColor(doc.drawingKind))} - > - {getDrawingKindText(doc.drawingKind)} - </Badge> - )} - </div> + <DocNumberCell doc={doc} /> ) }, - size: 140, + size: 120, enableResizing: true, meta: { excelHeader: "문서번호" }, }, - // 문서명 + 프로젝트/벤더 정보 + // 문서명 (선택된 행 하이라이트 적용) { accessorKey: "title", header: ({ column }) => ( @@ -211,148 +102,136 @@ export function getSimplifiedDocumentColumns({ ), cell: ({ row }) => { const doc = row.original + return ( - <div className="min-w-0 flex-1"> - <div className="font-medium text-gray-900 truncate" title={doc.title}> - {doc.title} - </div> - <div className="flex items-center gap-2 text-sm text-gray-500 mt-1"> - {doc.pic && ( - <span className="text-xs bg-gray-100 px-2 py-0.5 rounded"> - PIC: {doc.pic} - </span> - )} - {doc.projectCode && ( - <div className="flex items-center gap-1"> - <Building className="w-3 h-3" /> - <span>{doc.projectCode}</span> - </div> - )} - {doc.vendorName && ( - <div className="flex items-center gap-1"> - <Code className="w-3 h-3" /> - <span className="truncate max-w-[100px]">{doc.vendorName}</span> - </div> - )} - </div> - </div> + <TitleCell doc={doc} /> ) }, - size: 200, enableResizing: true, meta: { excelHeader: "문서명" }, }, - // 첫 번째 스테이지 정보 + // 프로젝트 코드 { - accessorKey: "firstStageName", + accessorKey: "projectCode", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="1차 스테이지" /> + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> ), cell: ({ row }) => { - const doc = row.original + const projectCode = row.original.projectCode + return ( - <StageNameDisplay - stageName={doc.firstStageName} - drawingKind={doc.drawingKind} - isFirst={true} - /> + <ProjectCodeCell projectCode={projectCode} documentId={row.original.documentId} /> ) }, - size: 130, enableResizing: true, meta: { - excelHeader: "1차 스테이지" + excelHeader: "프로젝트" }, }, - // 첫 번째 스테이지 날짜 + // 1차 스테이지 그룹 { - accessorKey: "firstStagePlanDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="1차 일정" /> - ), - cell: ({ row }) => { - const doc = row.original + id: "firstStageGroup", + header: ({ table }) => { + // 첫 번째 행의 firstStageName을 그룹 헤더로 사용 + const firstRow = table.getRowModel().rows[0]?.original + const stageName = firstRow?.firstStageName || "1차 스테이지" return ( - <StageDateInfo - planDate={doc.firstStagePlanDate} - actualDate={doc.firstStageActualDate} - stageName={doc.firstStageName} - /> - ) - }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "1차 일정" - }, - }, - - // 두 번째 스테이지 정보 - { - accessorKey: "secondStageName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="2차 스테이지" /> - ), - cell: ({ row }) => { - const doc = row.original - return ( - <StageNameDisplay - stageName={doc.secondStageName} - drawingKind={doc.drawingKind} - isFirst={false} - /> + <div className="text-center font-medium text-gray-700"> + {stageName} + </div> ) }, - size: 130, - enableResizing: true, - meta: { - excelHeader: "2차 스테이지" - }, + columns: [ + { + accessorKey: "firstStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계획일" /> + ), + cell: ({ row }) => { + return <FirstStagePlanDateCell row={row} /> + }, + enableResizing: true, + meta: { + excelHeader: "1차 계획일" + }, + }, + { + accessorKey: "firstStageActualDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="실제일" /> + ), + cell: ({ row }) => { + return <FirstStageActualDateCell row={row} /> + }, + enableResizing: true, + meta: { + excelHeader: "1차 실제일" + }, + }, + ], }, - // 두 번째 스테이지 날짜 + // 2차 스테이지 그룹 { - accessorKey: "secondStagePlanDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="2차 일정" /> - ), - cell: ({ row }) => { - const doc = row.original + id: "secondStageGroup", + header: ({ table }) => { + // 첫 번째 행의 secondStageName을 그룹 헤더로 사용 + const firstRow = table.getRowModel().rows[0]?.original + const stageName = firstRow?.secondStageName || "2차 스테이지" return ( - <StageDateInfo - planDate={doc.secondStagePlanDate} - actualDate={doc.secondStageActualDate} - stageName={doc.secondStageName} - /> + <div className="text-center font-medium text-gray-700"> + {stageName} + </div> ) }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "2차 일정" - }, + columns: [ + { + accessorKey: "secondStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계획일" /> + ), + cell: ({ row }) => { + return <SecondStagePlanDateCell row={row} /> + }, + enableResizing: true, + meta: { + excelHeader: "2차 계획일" + }, + }, + { + accessorKey: "secondStageActualDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="실제일" /> + ), + cell: ({ row }) => { + return <SecondStageActualDateCell row={row} /> + }, + enableResizing: true, + meta: { + excelHeader: "2차 실제일" + }, + }, + ], }, // 첨부파일 수 { accessorKey: "attachmentCount", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + <DataTableColumnHeaderSimple column={column} title="파일" /> ), cell: ({ row }) => { const count = row.original.attachmentCount || 0 + return ( - <div className="flex items-center gap-1"> - <FileText className="w-4 h-4 text-gray-400" /> - <span className="text-sm font-medium">{count}</span> - </div> + <AttachmentCountCell count={count} documentId={row.original.documentId} /> ) }, - size: 80, + size: 60, enableResizing: true, meta: { excelHeader: "첨부파일" @@ -365,12 +244,11 @@ export function getSimplifiedDocumentColumns({ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="업데이트" /> ), - cell: ({ cell }) => ( - <span className="text-sm text-gray-600"> - {formatDateTime(cell.getValue() as Date)} - </span> - ), - size: 140, + cell: ({ cell, row }) => { + return ( + <UpdatedAtCell updatedAt={cell.getValue() as Date} documentId={row.original.documentId} /> + ) + }, enableResizing: true, meta: { excelHeader: "업데이트" @@ -378,50 +256,208 @@ export function getSimplifiedDocumentColumns({ }, // 액션 버튼 - { - id: "actions", - header: () => <span className="sr-only">Actions</span>, - cell: ({ row }) => { - const doc = row.original - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">Open menu</span> - <Ellipsis className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => setRowAction({ type: "view", row: doc })} - > - <Eye className="mr-2 h-4 w-4" /> - 보기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => setRowAction({ type: "edit", row: doc })} - > - <Edit className="mr-2 h-4 w-4" /> - 편집 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => setRowAction({ type: "delete", row: doc })} - className="text-red-600" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 - <DropdownMenuShortcut>⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 50, - enableSorting: false, - enableHiding: false, - }, + // { + // id: "actions", + // header: () => <span className="sr-only">Actions</span>, + // cell: ({ row }) => { + // const doc = row.original + // return ( + // <DropdownMenu> + // <DropdownMenuTrigger asChild> + // <Button variant="ghost" className="h-8 w-8 p-0"> + // <span className="sr-only">Open menu</span> + // <Ellipsis className="h-4 w-4" /> + // </Button> + // </DropdownMenuTrigger> + // <DropdownMenuContent align="end"> + // <DropdownMenuItem + // onClick={() => setRowAction({ type: "view", row: doc })} + // > + // <Eye className="mr-2 h-4 w-4" /> + // 보기 + // </DropdownMenuItem> + // <DropdownMenuItem + // onClick={() => setRowAction({ type: "edit", row: doc })} + // > + // <Edit className="mr-2 h-4 w-4" /> + // 편집 + // </DropdownMenuItem> + // <DropdownMenuSeparator /> + // <DropdownMenuItem + // onClick={() => setRowAction({ type: "delete", row: doc })} + // className="text-red-600" + // > + // <Trash2 className="mr-2 h-4 w-4" /> + // 삭제 + // <DropdownMenuShortcut>⌫</DropdownMenuShortcut> + // </DropdownMenuItem> + // </DropdownMenuContent> + // </DropdownMenu> + // ) + // }, + // size: 50, + // enableSorting: false, + // enableHiding: false, + // }, ] - return baseColumns + return columns +} + +// 개별 셀 컴포넌트들 (Context 사용) +function SelectCell({ documentId }: { documentId: number }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( + <div className="flex items-center justify-center"> + <input + type="radio" + checked={isSelected} + onChange={() => { + const newSelection = isSelected ? null : documentId; + setSelectedDocumentId(newSelection); + }} + className="cursor-pointer w-4 h-4" + /> + </div> + ); +} + +function DocNumberCell({ doc }: { doc: SimplifiedDocumentsView }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === doc.documentId; + + return ( + <div + className={cn( + "font-mono text-sm font-medium cursor-pointer px-2 py-1 rounded transition-colors", + isSelected + ? "text-blue-600 font-bold bg-blue-50" + : "hover:bg-gray-50" + )} + onClick={() => { + const newSelection = isSelected ? null : doc.documentId; + setSelectedDocumentId(newSelection); + }} + > + {doc.docNumber} + </div> + ); +} + +function TitleCell({ doc }: { doc: SimplifiedDocumentsView }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === doc.documentId; + + return ( + <div + className={cn( + "font-medium text-gray-900 truncate max-w-[300px] cursor-pointer px-2 py-1 rounded transition-colors", + isSelected + ? "text-blue-600 font-bold bg-blue-50" + : "hover:bg-gray-50" + )} + title={doc.title} + onClick={() => { + const newSelection = isSelected ? null : doc.documentId; + setSelectedDocumentId(newSelection); + }} + > + {doc.title} + </div> + ); +} + +function ProjectCodeCell({ projectCode, documentId }: { projectCode: string | null, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + if (!projectCode) return <span className="text-gray-400">-</span>; + + return ( + <span className={cn( + "text-sm font-medium", + isSelected && "text-blue-600 font-bold" + )}> + {projectCode} + </span> + ); +} + +function FirstStagePlanDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + + return <DateDisplay date={row.original.firstStagePlanDate} isSelected={isSelected} />; +} + +function FirstStageActualDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + const date = row.original.firstStageActualDate; + + return ( + <div className={cn( + date ? "text-green-600 font-medium" : "", + isSelected && date && "text-green-700 font-bold" + )}> + <DateDisplay date={date} isSelected={isSelected && !date} /> + {date && <span className="text-xs block">✓ 완료</span>} + </div> + ); +} + +function SecondStagePlanDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + + return <DateDisplay date={row.original.secondStagePlanDate} isSelected={isSelected} />; +} + +function SecondStageActualDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + const date = row.original.secondStageActualDate; + + return ( + <div className={cn( + date ? "text-green-600 font-medium" : "", + isSelected && date && "text-green-700 font-bold" + )}> + <DateDisplay date={date} isSelected={isSelected && !date} /> + {date && <span className="text-xs block">✓ 완료</span>} + </div> + ); +} + +function AttachmentCountCell({ count, documentId }: { count: number, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( + <div className="flex items-center justify-center gap-1"> + <FileText className="w-4 h-4 text-gray-400" /> + <span className={cn( + "text-sm font-medium", + isSelected && "text-blue-600 font-bold" + )}> + {count} + </span> + </div> + ); +} + +function UpdatedAtCell({ updatedAt, documentId }: { updatedAt: Date, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( + <span className={cn( + "text-sm text-gray-600", + isSelected && "text-blue-600 font-semibold" + )}> + {formatDateTime(updatedAt)} + </span> + ); }
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx index 3960bbce..508d8c91 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx @@ -6,29 +6,18 @@ import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" -import { AddDocumentListDialog } from "./add-doc-dialog" -import { DeleteDocumentsDialog } from "./delete-docs-dialog" -import { BulkUploadDialog } from "./bulk-upload-dialog" -import type { EnhancedDocument } from "@/types/enhanced-documents" +import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu" import { SendToSHIButton } from "./send-to-shi-button" import { ImportFromDOLCEButton } from "./import-from-dolce-button" -import { SWPWorkflowPanel } from "./swp-workflow-panel" interface EnhancedDocTableToolbarActionsProps { - table: Table<EnhancedDocument> + table: Table<SimplifiedDocumentsView> projectType: "ship" | "plant" - selectedPackageId: number - onNewDocument: () => void - onBulkAction: (action: string, selectedRows: any[]) => Promise<void> } export function EnhancedDocTableToolbarActions({ table, projectType, - selectedPackageId, - onNewDocument, - onBulkAction }: EnhancedDocTableToolbarActionsProps) { const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false) @@ -61,45 +50,15 @@ export function EnhancedDocTableToolbarActions({ return ( <div className="flex items-center gap-2"> - {/* 삭제 버튼 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteDocumentsDialog - documents={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - {/* projectType에 따른 조건부 렌더링 */} - {projectType === "ship" ? ( + <> {/* SHIP: DOLCE에서 목록 가져오기 */} <ImportFromDOLCEButton - contractId={selectedPackageId} + allDocuments={allDocuments} onImportComplete={handleImportComplete} /> </> - ) : ( - <> - {/* PLANT: 수동 문서 추가 */} - <AddDocumentListDialog - projectType={projectType} - contractId={selectedPackageId} - onSuccess={handleDocumentAdded} - /> - </> - )} - - {/* 일괄 업로드 버튼 (공통) */} - <Button - variant="outline" - onClick={() => setBulkUploadDialogOpen(true)} - className="flex items-center gap-2" - > - <Files className="w-4 h-4" /> - 일괄 업로드 - </Button> + {/* Export 버튼 (공통) */} <Button @@ -118,30 +77,14 @@ export function EnhancedDocTableToolbarActions({ </Button> {/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */} - <SendToSHIButton + {/* <SendToSHIButton contractId={selectedPackageId} documents={allDocuments} onSyncComplete={handleSyncComplete} projectType={projectType} - /> - - {/* SWP 전용 워크플로우 패널 */} - {projectType === "plant" && ( - <SWPWorkflowPanel - contractId={selectedPackageId} - documents={allDocuments} - onWorkflowUpdate={handleSyncComplete} - /> - )} + /> */} - {/* 일괄 업로드 다이얼로그 */} - <BulkUploadDialog - open={bulkUploadDialogOpen} - onOpenChange={setBulkUploadDialogOpen} - documents={allDocuments} - projectType={projectType} - contractId={selectedPackageId} - /> + </div> ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx b/lib/vendor-document-list/ship/enhanced-document-sheet.tsx deleted file mode 100644 index 88e342c8..00000000 --- a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx +++ /dev/null @@ -1,939 +0,0 @@ -// enhanced-document-sheet.tsx -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" -import { useRouter } from "next/navigation" -import { - Loader, - Save, - Upload, - Calendar, - User, - FileText, - AlertTriangle, - CheckCircle, - Clock, - Plus, - X -} from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Calendar as CalendarComponent } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { format } from "date-fns" -import { ko } from "date-fns/locale" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" - -// 드롭존과 파일 관련 컴포넌트들 -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 prettyBytes from "pretty-bytes" - -// 스키마 정의 -const enhancedDocumentSchema = z.object({ - // 기본 문서 정보 - docNumber: z.string().min(1, "문서번호는 필수입니다"), - title: z.string().min(1, "제목은 필수입니다"), - pic: z.string().optional(), - status: z.string().min(1, "상태는 필수입니다"), - issuedDate: z.date().optional(), - - // 스테이지 관리 (plant 타입에서만 수정 가능) - stages: z.array(z.object({ - id: z.number().optional(), - stageName: z.string().min(1, "스테이지명은 필수입니다"), - stageOrder: z.number(), - priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), - planDate: z.date().optional(), - assigneeName: z.string().optional(), - description: z.string().optional(), - })).optional(), - - // 리비전 업로드 (현재 스테이지에 대한) - newRevision: z.object({ - stage: z.string().optional(), - revision: z.string().optional(), - uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), - uploaderName: z.string().optional(), - comment: z.string().optional(), - attachments: z.array(z.instanceof(File)).optional(), - }).optional(), -}) - -type EnhancedDocumentSchema = z.infer<typeof enhancedDocumentSchema> - -// 상태 옵션 정의 -const statusOptions = [ - { value: "ACTIVE", label: "활성" }, - { value: "INACTIVE", label: "비활성" }, - { value: "COMPLETED", label: "완료" }, - { value: "CANCELLED", label: "취소" }, -] - -const priorityOptions = [ - { value: "HIGH", label: "높음" }, - { value: "MEDIUM", label: "보통" }, - { value: "LOW", label: "낮음" }, -] - -const stageStatusOptions = [ - { value: "PLANNED", label: "계획됨" }, - { value: "IN_PROGRESS", label: "진행중" }, - { value: "SUBMITTED", label: "제출됨" }, - { value: "APPROVED", label: "승인됨" }, - { value: "REJECTED", label: "반려됨" }, - { value: "COMPLETED", label: "완료됨" }, -] - -interface EnhancedDocumentSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - document: EnhancedDocumentsView | null - projectType: "ship" | "plant" - mode: "view" | "edit" | "upload" | "schedule" | "approve" -} - -export function EnhancedDocumentSheet({ - document, - projectType, - mode = "view", - ...props -}: EnhancedDocumentSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) - const [uploadProgress, setUploadProgress] = React.useState(0) - const [activeTab, setActiveTab] = React.useState("info") - const router = useRouter() - - // 권한 계산 - const permissions = React.useMemo(() => { - const canEdit = projectType === "plant" || mode === "edit" - const canUpload = mode === "upload" || mode === "edit" - const canApprove = mode === "approve" && projectType === "ship" - const canSchedule = mode === "schedule" || (projectType === "plant" && mode === "edit") - - return { canEdit, canUpload, canApprove, canSchedule } - }, [projectType, mode]) - - const form = useForm<EnhancedDocumentSchema>({ - resolver: zodResolver(enhancedDocumentSchema), - defaultValues: { - docNumber: "", - title: "", - pic: "", - status: "ACTIVE", - issuedDate: undefined, - stages: [], - newRevision: { - stage: "", - revision: "", - uploaderType: "vendor", - uploaderName: "", - comment: "", - attachments: [], - }, - }, - }) - - // 폼 초기화 - React.useEffect(() => { - if (document) { - form.reset({ - docNumber: document.docNumber, - title: document.title, - pic: document.pic || "", - status: document.status, - issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, - stages: document.allStages?.map((stage, index) => ({ - id: stage.id, - stageName: stage.stageName, - stageOrder: stage.stageOrder || index, - priority: stage.priority as "HIGH" | "MEDIUM" | "LOW" || "MEDIUM", - planDate: stage.planDate ? new Date(stage.planDate) : undefined, - assigneeName: stage.assigneeName || "", - description: "", - })) || [], - newRevision: { - stage: document.currentStageName || "", - revision: "", - uploaderType: "vendor", - uploaderName: "", - comment: "", - attachments: [], - }, - }) - - // 모드에 따른 기본 탭 설정 - if (mode === "upload") { - setActiveTab("upload") - } else if (mode === "schedule") { - setActiveTab("schedule") - } else if (mode === "approve") { - setActiveTab("approve") - } - } - }, [document, form, mode]) - - // 파일 처리 - const handleDropAccepted = (acceptedFiles: File[]) => { - const newFiles = [...selectedFiles, ...acceptedFiles] - setSelectedFiles(newFiles) - form.setValue('newRevision.attachments', newFiles) - } - - const removeFile = (index: number) => { - const updatedFiles = [...selectedFiles] - updatedFiles.splice(index, 1) - setSelectedFiles(updatedFiles) - form.setValue('newRevision.attachments', updatedFiles) - } - - // 스테이지 추가/제거 - const addStage = () => { - const currentStages = form.getValues("stages") || [] - const newStage = { - stageName: "", - stageOrder: currentStages.length, - priority: "MEDIUM" as const, - planDate: undefined, - assigneeName: "", - description: "", - } - form.setValue("stages", [...currentStages, newStage]) - } - - const removeStage = (index: number) => { - const currentStages = form.getValues("stages") || [] - const updatedStages = currentStages.filter((_, i) => i !== index) - form.setValue("stages", updatedStages) - } - - // 제출 처리 - function onSubmit(input: EnhancedDocumentSchema) { - startUpdateTransition(async () => { - if (!document) return - - try { - // 모드에 따른 다른 처리 - switch (mode) { - case "edit": - // 문서 정보 업데이트 + 스테이지 관리 - await updateDocumentInfo(input) - break - case "upload": - // 리비전 업로드 - await uploadRevision(input) - break - case "approve": - // 승인 처리 - await approveRevision(input) - break - case "schedule": - // 스케줄 관리 - await updateSchedule(input) - break - } - - form.reset() - setSelectedFiles([]) - props.onOpenChange?.(false) - toast.success("성공적으로 처리되었습니다") - router.refresh() - } catch (error) { - toast.error("처리 중 오류가 발생했습니다") - console.error(error) - } - }) - } - - // 개별 처리 함수들 - const updateDocumentInfo = async (input: EnhancedDocumentSchema) => { - // 문서 기본 정보 업데이트 API 호출 - console.log("문서 정보 업데이트:", input) - } - - const uploadRevision = async (input: EnhancedDocumentSchema) => { - if (!input.newRevision?.attachments?.length) { - throw new Error("파일을 선택해주세요") - } - - // 파일 업로드 처리 - const formData = new FormData() - formData.append("documentId", String(document?.documentId)) - formData.append("stage", input.newRevision.stage || "") - formData.append("revision", input.newRevision.revision || "") - formData.append("uploaderType", input.newRevision.uploaderType) - - input.newRevision.attachments.forEach((file) => { - formData.append("attachments", file) - }) - - // API 호출 - console.log("리비전 업로드:", formData) - } - - const approveRevision = async (input: EnhancedDocumentSchema) => { - // 승인 처리 API 호출 - console.log("리비전 승인:", input) - } - - const updateSchedule = async (input: EnhancedDocumentSchema) => { - // 스케줄 업데이트 API 호출 - console.log("스케줄 업데이트:", input) - } - - // 제목 및 설명 생성 - const getSheetTitle = () => { - switch (mode) { - case "edit": return "문서 정보 수정" - case "upload": return "리비전 업로드" - case "approve": return "문서 승인" - case "schedule": return "일정 관리" - default: return "문서 상세" - } - } - - const getSheetDescription = () => { - const docInfo = document ? `${document.docNumber} - ${document.title}` : "" - switch (mode) { - case "edit": return `문서 정보를 수정합니다. ${docInfo}` - case "upload": return `새 리비전을 업로드합니다. ${docInfo}` - case "approve": return `문서를 검토하고 승인 처리합니다. ${docInfo}` - case "schedule": return `문서의 일정을 관리합니다. ${docInfo}` - default: return docInfo - } - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-2xl w-full"> - <SheetHeader className="text-left"> - <SheetTitle className="flex items-center gap-2"> - {mode === "upload" && <Upload className="w-5 h-5" />} - {mode === "approve" && <CheckCircle className="w-5 h-5" />} - {mode === "schedule" && <Calendar className="w-5 h-5" />} - {mode === "edit" && <FileText className="w-5 h-5" />} - {getSheetTitle()} - </SheetTitle> - <SheetDescription> - {getSheetDescription()} - </SheetDescription> - - {/* 프로젝트 타입 및 권한 표시 */} - <div className="flex items-center gap-2 pt-2"> - <Badge variant={projectType === "ship" ? "default" : "secondary"}> - {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} - </Badge> - {document?.isOverdue && ( - <Badge variant="destructive" className="flex items-center gap-1"> - <AlertTriangle className="w-3 h-3" /> - 지연 - </Badge> - )} - {document?.currentStagePriority === "HIGH" && ( - <Badge variant="destructive">높은 우선순위</Badge> - )} - </div> - </SheetHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col"> - <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col"> - <TabsList className="grid w-full grid-cols-4"> - <TabsTrigger value="info">기본정보</TabsTrigger> - <TabsTrigger value="schedule" disabled={!permissions.canSchedule}> - 일정관리 - </TabsTrigger> - <TabsTrigger value="upload" disabled={!permissions.canUpload}> - 리비전업로드 - </TabsTrigger> - <TabsTrigger value="approve" disabled={!permissions.canApprove}> - 승인처리 - </TabsTrigger> - </TabsList> - - {/* 기본 정보 탭 */} - <TabsContent value="info" className="flex-1 space-y-4"> - <ScrollArea className="h-full pr-4"> - <div className="space-y-4"> - <FormField - control={form.control} - name="docNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>문서번호</FormLabel> - <FormControl> - <Input {...field} disabled={!permissions.canEdit} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>제목</FormLabel> - <FormControl> - <Input {...field} disabled={!permissions.canEdit} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="pic" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자 (PIC)</FormLabel> - <FormControl> - <Input {...field} disabled={!permissions.canEdit} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>상태</FormLabel> - <Select - onValueChange={field.onChange} - value={field.value} - disabled={!permissions.canEdit} - > - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - {statusOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="issuedDate" - render={({ field }) => ( - <FormItem> - <FormLabel>발행일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - disabled={!permissions.canEdit} - > - {field.value ? ( - format(field.value, "yyyy년 MM월 dd일", { locale: ko }) - ) : ( - <span>날짜를 선택하세요</span> - )} - <Calendar className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <CalendarComponent - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => date > new Date()} - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 현재 상태 정보 표시 */} - {document && ( - <div className="space-y-3 p-4 bg-gray-50 rounded-lg"> - <h4 className="font-medium flex items-center gap-2"> - <Clock className="w-4 h-4" /> - 현재 진행 상황 - </h4> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <span className="text-gray-500">현재 스테이지:</span> - <p className="font-medium">{document.currentStageName || "-"}</p> - </div> - <div> - <span className="text-gray-500">진행률:</span> - <p className="font-medium">{document.progressPercentage || 0}%</p> - </div> - <div> - <span className="text-gray-500">최신 리비전:</span> - <p className="font-medium">{document.latestRevision || "-"}</p> - </div> - <div> - <span className="text-gray-500">담당자:</span> - <p className="font-medium">{document.currentStageAssigneeName || "-"}</p> - </div> - </div> - </div> - )} - </div> - </ScrollArea> - </TabsContent> - - {/* 일정 관리 탭 */} - <TabsContent value="schedule" className="flex-1 space-y-4"> - <ScrollArea className="h-full pr-4"> - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h4 className="font-medium">스테이지 일정 관리</h4> - {projectType === "plant" && ( - <Button - type="button" - variant="outline" - size="sm" - onClick={addStage} - className="flex items-center gap-1" - > - <Plus className="w-4 h-4" /> - 스테이지 추가 - </Button> - )} - </div> - - {form.watch("stages")?.map((stage, index) => ( - <div key={index} className="p-4 border rounded-lg space-y-3"> - <div className="flex items-center justify-between"> - <h5 className="font-medium">스테이지 {index + 1}</h5> - {projectType === "plant" && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeStage(index)} - > - <X className="w-4 h-4" /> - </Button> - )} - </div> - - <div className="grid grid-cols-2 gap-3"> - <FormField - control={form.control} - name={`stages.${index}.stageName`} - render={({ field }) => ( - <FormItem> - <FormLabel>스테이지명</FormLabel> - <FormControl> - <Input {...field} disabled={projectType === "ship"} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`stages.${index}.priority`} - render={({ field }) => ( - <FormItem> - <FormLabel>우선순위</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - {priorityOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`stages.${index}.planDate`} - render={({ field }) => ( - <FormItem> - <FormLabel>계획일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "MM/dd", { locale: ko }) - ) : ( - <span>날짜 선택</span> - )} - <Calendar className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <CalendarComponent - mode="single" - selected={field.value} - onSelect={field.onChange} - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`stages.${index}.assigneeName`} - render={({ field }) => ( - <FormItem> - <FormLabel>담당자</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - ))} - </div> - </ScrollArea> - </TabsContent> - - {/* 리비전 업로드 탭 */} - <TabsContent value="upload" className="flex-1 space-y-4"> - <ScrollArea className="h-full pr-4"> - <div className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="newRevision.stage" - render={({ field }) => ( - <FormItem> - <FormLabel>스테이지</FormLabel> - <FormControl> - <Input {...field} placeholder="예: Issued for Review" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="newRevision.revision" - render={({ field }) => ( - <FormItem> - <FormLabel>리비전</FormLabel> - <FormControl> - <Input {...field} placeholder="예: A, B, 1, 2..." /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="newRevision.uploaderName" - render={({ field }) => ( - <FormItem> - <FormLabel>업로더명 (선택)</FormLabel> - <FormControl> - <Input {...field} placeholder="업로더 이름을 입력하세요" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="newRevision.comment" - render={({ field }) => ( - <FormItem> - <FormLabel>코멘트 (선택)</FormLabel> - <FormControl> - <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 업로드 드롭존 */} - <FormField - control={form.control} - name="newRevision.attachments" - render={() => ( - <FormItem> - <FormLabel>파일 첨부</FormLabel> - <Dropzone - maxSize={3e9} // 3GB - multiple={true} - onDropAccepted={handleDropAccepted} - disabled={isUpdatePending} - > - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> - <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요 - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - </Dropzone> - <FormMessage /> - </FormItem> - )} - /> - - {/* 선택된 파일 목록 */} - {selectedFiles.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) - </h6> - </div> - <FileList className="max-h-[200px]"> - {selectedFiles.map((file, index) => ( - <FileListItem key={index} className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListDescription> - {prettyBytes(file.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => removeFile(index)} - disabled={isUpdatePending} - > - <X className="h-4 w-4" /> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </FileList> - </div> - )} - - {/* 업로드 진행 상태 */} - {isUpdatePending && uploadProgress > 0 && ( - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <Loader className="h-4 w-4 animate-spin" /> - <span className="text-sm">{uploadProgress}% 업로드 중...</span> - </div> - <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> - <div - className="h-full bg-primary rounded-full transition-all" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - </div> - </ScrollArea> - </TabsContent> - - {/* 승인 처리 탭 */} - <TabsContent value="approve" className="flex-1 space-y-4"> - <ScrollArea className="h-full pr-4"> - <div className="space-y-4"> - <div className="p-4 bg-blue-50 rounded-lg"> - <h4 className="font-medium mb-2 flex items-center gap-2"> - <CheckCircle className="w-4 h-4 text-blue-600" /> - 승인 대상 문서 - </h4> - <div className="text-sm space-y-1"> - <p><span className="font-medium">문서:</span> {document?.docNumber} - {document?.title}</p> - <p><span className="font-medium">현재 스테이지:</span> {document?.currentStageName}</p> - <p><span className="font-medium">최신 리비전:</span> {document?.latestRevision}</p> - <p><span className="font-medium">업로더:</span> {document?.latestRevisionUploaderName}</p> - </div> - </div> - - <div className="space-y-3"> - <div className="flex gap-3"> - <Button - type="button" - className="flex-1 bg-green-600 hover:bg-green-700" - onClick={() => { - // 승인 처리 로직 - console.log("승인 처리") - }} - > - <CheckCircle className="w-4 h-4 mr-2" /> - 승인 - </Button> - <Button - type="button" - variant="destructive" - className="flex-1" - onClick={() => { - // 반려 처리 로직 - console.log("반려 처리") - }} - > - <X className="w-4 h-4 mr-2" /> - 반려 - </Button> - </div> - - <FormField - control={form.control} - name="newRevision.comment" - render={({ field }) => ( - <FormItem> - <FormLabel>검토 의견</FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="승인/반려 사유를 입력하세요" - rows={4} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - </ScrollArea> - </TabsContent> - </Tabs> - - <Separator /> - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button - type="submit" - disabled={isUpdatePending} - className={mode === "approve" ? "bg-green-600 hover:bg-green-700" : ""} - > - {isUpdatePending && <Loader className="mr-2 size-4 animate-spin" />} - {mode === "upload" && <Upload className="mr-2 size-4" />} - {mode === "approve" && <CheckCircle className="mr-2 size-4" />} - {mode === "schedule" && <Calendar className="mr-2 size-4" />} - {mode === "edit" && <Save className="mr-2 size-4" />} - - {mode === "upload" ? "업로드" : - mode === "approve" ? "승인 처리" : - mode === "schedule" ? "일정 저장" : "저장"} - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx index 47bce275..2354a9be 100644 --- a/lib/vendor-document-list/ship/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx @@ -9,36 +9,69 @@ import type { } from "@/types/table" import { useDataTable } from "@/hooks/use-data-table" -import { getEnhancedDocumentsShip } from "../enhanced-document-service" +import { getUserVendorDocuments, getUserVendorDocumentStats } from "@/lib/vendor-document-list/enhanced-document-service" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" +import { FileText } from "lucide-react" import { Label } from "@/components/ui/label" import { DataTable } from "@/components/data-table/data-table" import { SimplifiedDocumentsView } from "@/db/schema" import { getSimplifiedDocumentColumns } from "./enhanced-doc-table-columns" +import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" + +// DrawingKind별 설명 매핑 +const DRAWING_KIND_INFO = { + B3: { + title: "B3 Vendor", + description: "Approval → Work 단계로 진행되는 승인 중심 도면", + color: "bg-blue-50 text-blue-700 border-blue-200" + }, + B4: { + title: "B4 GTT", + description: "Pre → Work 단계로 진행되는 DOLCE 연동 도면", + color: "bg-green-50 text-green-700 border-green-200" + }, + B5: { + title: "B5 FMEA", + description: "First → Second 단계로 진행되는 순차적 도면", + color: "bg-purple-50 text-purple-700 border-purple-200" + } +} as const interface SimplifiedDocumentsTableProps { - promises: Promise<{ - data: SimplifiedDocumentsView[], - pageCount: number, - total: number - }> + allPromises: Promise<[ + Awaited<ReturnType<typeof getUserVendorDocuments>>, + Awaited<ReturnType<typeof getUserVendorDocumentStats>> + ]> + onDataLoaded?: (data: SimplifiedDocumentsView[]) => void } export function SimplifiedDocumentsTable({ - promises, + allPromises, + onDataLoaded, }: SimplifiedDocumentsTableProps) { // React.use()로 Promise 결과를 받고, 그 다음에 destructuring - const result = React.use(promises) - const { data, pageCount, total } = result + const [documentResult, statsResult] = React.use(allPromises) + const { data, pageCount, total, drawingKind, vendorInfo } = documentResult + const { stats, totalDocuments, primaryDrawingKind } = statsResult + + // 데이터가 로드되면 콜백 호출 + React.useEffect(() => { + if (onDataLoaded && data) { + onDataLoaded(data) + } + }, [data, onDataLoaded]) // 기존 상태들 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null) // ✅ 타입 변경 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null) const [expandedRows,] = React.useState<Set<string>>(new Set()) const columns = React.useMemo( - () => getSimplifiedDocumentColumns({ setRowAction }), + () => getSimplifiedDocumentColumns({ + setRowAction, + }), [setRowAction] ) @@ -51,7 +84,7 @@ export function SimplifiedDocumentsTable({ }, { id: "vendorDocNumber", - label: "벤더 문서번호", + label: "벤더 문서번호", type: "text", }, { @@ -107,7 +140,7 @@ export function SimplifiedDocumentsTable({ }, { id: "secondStageName", - label: "2차 스테이지", + label: "2차 스테이지", type: "text", }, { @@ -155,14 +188,14 @@ export function SimplifiedDocumentsTable({ type: "text", }, { - id: "dGbn", + id: "dGbn", label: "D 구분", type: "text", }, { id: "degreeGbn", label: "Degree 구분", - type: "text", + type: "text", }, { id: "deptGbn", @@ -171,7 +204,7 @@ export function SimplifiedDocumentsTable({ }, { id: "jGbn", - label: "J 구분", + label: "J 구분", type: "text", }, { @@ -183,7 +216,7 @@ export function SimplifiedDocumentsTable({ // B4 문서가 있는지 확인하여 B4 전용 필드 추가 const hasB4Documents = data.some(doc => doc.drawingKind === 'B4') - const finalFilterFields = hasB4Documents + const finalFilterFields = hasB4Documents ? [...advancedFilterFields, ...b4FilterFields] : advancedFilterFields @@ -203,36 +236,48 @@ export function SimplifiedDocumentsTable({ columnResizeMode: "onEnd", }) - // ✅ 행 액션 처리 (필요에 따라 구현) - React.useEffect(() => { - if (rowAction?.type === "view") { - toast.info(`문서 조회: ${rowAction.row.docNumber}`) - setRowAction(null) - } else if (rowAction?.type === "edit") { - toast.info(`문서 편집: ${rowAction.row.docNumber}`) - setRowAction(null) - } else if (rowAction?.type === "delete") { - toast.error(`문서 삭제: ${rowAction.row.docNumber}`) - setRowAction(null) - } - }, [rowAction]) + // 실제 데이터의 drawingKind 또는 주요 drawingKind 사용 + const activeDrawingKind = drawingKind || primaryDrawingKind + const kindInfo = activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null return ( - <div className="w-full" style={{maxWidth:'100%'}}> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={finalFilterFields} - shallow={false} - > - {/* ✅ 추가 툴바 컨텐츠 (필요시) */} - <div className="flex items-center gap-2"> - <Label className="text-sm font-medium"> - 총 {total}개 문서 - </Label> + <div className="w-full space-y-4"> + {/* DrawingKind 정보 간단 표시 */} + {kindInfo && ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Badge variant="default" className="flex items-center gap-1 text-sm"> + <FileText className="w-4 h-4" /> + {kindInfo.title} + </Badge> + <span className="text-sm text-muted-foreground"> + {kindInfo.description} + </span> + </div> + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {total}개 문서 + </Badge> + </div> </div> - </DataTableAdvancedToolbar> - </DataTable> + )} + + {/* 테이블 */} + <div className="overflow-x-auto"> + <DataTable table={table} compact> + <DataTableAdvancedToolbar + table={table} + filterFields={finalFilterFields} + shallow={false} + > + <EnhancedDocTableToolbarActions + table={table} + projectType="ship" + /> + + </DataTableAdvancedToolbar> + </DataTable> + </div> </div> ) -} +}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx index 519d40cb..23d80981 100644 --- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx +++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx @@ -20,52 +20,87 @@ import { import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Separator } from "@/components/ui/separator" +import { SimplifiedDocumentsView } from "@/db/schema" +import { ImportStatus } from "../import-service" interface ImportFromDOLCEButtonProps { - contractId: number + allDocuments: SimplifiedDocumentsView[] // contractId 대신 문서 배열 onImportComplete?: () => void } -interface ImportStatus { - lastImportAt?: string - availableDocuments: number - newDocuments: number - updatedDocuments: number - importEnabled: boolean -} - export function ImportFromDOLCEButton({ - contractId, + allDocuments, onImportComplete }: ImportFromDOLCEButtonProps) { const [isDialogOpen, setIsDialogOpen] = React.useState(false) const [importProgress, setImportProgress] = React.useState(0) const [isImporting, setIsImporting] = React.useState(false) - const [importStatus, setImportStatus] = React.useState<ImportStatus | null>(null) + const [importStatusMap, setImportStatusMap] = React.useState<Map<number, ImportStatus>>(new Map()) const [statusLoading, setStatusLoading] = React.useState(false) - // DOLCE 상태 조회 - const fetchImportStatus = async () => { + // 문서들에서 contractId들 추출 + const contractIds = React.useMemo(() => { + const uniqueIds = [...new Set(allDocuments.map(doc => doc.contractId).filter(Boolean))] + return uniqueIds.sort() + }, [allDocuments]) + + console.log(contractIds, "contractIds") + + // 주요 contractId (가장 많이 나타나는 것) + const primaryContractId = React.useMemo(() => { + if (contractIds.length === 1) return contractIds[0] + + const counts = allDocuments.reduce((acc, doc) => { + const id = doc.contractId || 0 + acc[id] = (acc[id] || 0) + 1 + return acc + }, {} as Record<number, number>) + + return Number(Object.entries(counts) + .sort(([,a], [,b]) => b - a)[0]?.[0] || contractIds[0] || 0) + }, [contractIds, allDocuments]) + + // 모든 contractId에 대한 상태 조회 + const fetchAllImportStatus = async () => { setStatusLoading(true) + const statusMap = new Map<number, ImportStatus>() + try { - const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.message || 'Failed to fetch import status') - } + // 각 contractId별로 상태 조회 + const statusPromises = contractIds.map(async (contractId) => { + try { + const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Failed to fetch import status') + } + + const status = await response.json() + if (status.error) { + console.warn(`Status error for contract ${contractId}:`, status.error) + return { contractId, status: null } + } + + return { contractId, status } + } catch (error) { + console.error(`Failed to fetch status for contract ${contractId}:`, error) + return { contractId, status: null } + } + }) + + const results = await Promise.all(statusPromises) + + results.forEach(({ contractId, status }) => { + if (status) { + statusMap.set(contractId, status) + } + }) - const status = await response.json() - setImportStatus(status) + setImportStatusMap(statusMap) - // 프로젝트 코드가 없는 경우 에러 처리 - if (status.error) { - toast.error(`상태 확인 실패: ${status.error}`) - setImportStatus(null) - } } catch (error) { - console.error('Failed to fetch import status:', error) - toast.error('DOLCE 상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.') - setImportStatus(null) + console.error('Failed to fetch import statuses:', error) + toast.error('상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.') } finally { setStatusLoading(false) } @@ -73,11 +108,32 @@ export function ImportFromDOLCEButton({ // 컴포넌트 마운트 시 상태 조회 React.useEffect(() => { - fetchImportStatus() - }, [contractId]) + if (contractIds.length > 0) { + fetchAllImportStatus() + } + }, [contractIds]) + + // 주요 contractId의 상태 + const primaryImportStatus = importStatusMap.get(primaryContractId) + + // 전체 통계 계산 + const totalStats = React.useMemo(() => { + const statuses = Array.from(importStatusMap.values()) + return statuses.reduce((acc, status) => ({ + availableDocuments: acc.availableDocuments + (status.availableDocuments || 0), + newDocuments: acc.newDocuments + (status.newDocuments || 0), + updatedDocuments: acc.updatedDocuments + (status.updatedDocuments || 0), + importEnabled: acc.importEnabled || status.importEnabled + }), { + availableDocuments: 0, + newDocuments: 0, + updatedDocuments: 0, + importEnabled: false + }) + }, [importStatusMap]) const handleImport = async () => { - if (!contractId) return + if (contractIds.length === 0) return setImportProgress(0) setIsImporting(true) @@ -85,51 +141,68 @@ export function ImportFromDOLCEButton({ try { // 진행률 시뮬레이션 const progressInterval = setInterval(() => { - setImportProgress(prev => Math.min(prev + 15, 90)) - }, 300) - - const response = await fetch('/api/sync/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contractId, - sourceSystem: 'DOLCE' + setImportProgress(prev => Math.min(prev + 10, 85)) + }, 500) + + // 여러 contractId에 대해 순차적으로 가져오기 실행 + const importPromises = contractIds.map(async (contractId) => { + const response = await fetch('/api/sync/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contractId, + sourceSystem: 'DOLCE' + }) }) - }) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || 'Import failed') - } + if (!response.ok) { + const errorData = await response.json() + throw new Error(`Contract ${contractId}: ${errorData.message || 'Import failed'}`) + } + + return response.json() + }) - const result = await response.json() + const results = await Promise.all(importPromises) clearInterval(progressInterval) setImportProgress(100) + // 결과 집계 + const totalResult = results.reduce((acc, result) => ({ + newCount: acc.newCount + (result.newCount || 0), + updatedCount: acc.updatedCount + (result.updatedCount || 0), + skippedCount: acc.skippedCount + (result.skippedCount || 0), + success: acc.success && result.success + }), { + newCount: 0, + updatedCount: 0, + skippedCount: 0, + success: true + }) + setTimeout(() => { setImportProgress(0) setIsDialogOpen(false) setIsImporting(false) - if (result?.success) { - const { newCount = 0, updatedCount = 0, skippedCount = 0 } = result + if (totalResult.success) { toast.success( `DOLCE 가져오기 완료`, { - description: `신규 ${newCount}건, 업데이트 ${updatedCount}건, 건너뜀 ${skippedCount}건 (B3/B4/B5 포함)` + description: `신규 ${totalResult.newCount}건, 업데이트 ${totalResult.updatedCount}건, 건너뜀 ${totalResult.skippedCount}건 (${contractIds.length}개 계약)` } ) } else { toast.error( `DOLCE 가져오기 부분 실패`, { - description: result?.message || '일부 DrawingKind에서 가져오기에 실패했습니다.' + description: '일부 계약에서 가져오기에 실패했습니다.' } ) } - fetchImportStatus() // 상태 갱신 + fetchAllImportStatus() // 상태 갱신 onImportComplete?.() }, 500) @@ -148,19 +221,19 @@ export function ImportFromDOLCEButton({ return <Badge variant="secondary">DOLCE 연결 확인 중...</Badge> } - if (!importStatus) { + if (importStatusMap.size === 0) { return <Badge variant="destructive">DOLCE 연결 오류</Badge> } - if (!importStatus.importEnabled) { + if (!totalStats.importEnabled) { return <Badge variant="secondary">DOLCE 가져오기 비활성화</Badge> } - if (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) { + if (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) { return ( <Badge variant="default" className="gap-1 bg-blue-500 hover:bg-blue-600"> <AlertTriangle className="w-3 h-3" /> - 업데이트 가능 (B3/B4/B5) + 업데이트 가능 ({contractIds.length}개 계약) </Badge> ) } @@ -173,8 +246,12 @@ export function ImportFromDOLCEButton({ ) } - const canImport = importStatus?.importEnabled && - (importStatus?.newDocuments > 0 || importStatus?.updatedDocuments > 0) + const canImport = totalStats.importEnabled && + (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) + + if (contractIds.length === 0) { + return null // 계약이 없으면 버튼을 표시하지 않음 + } return ( <> @@ -193,19 +270,19 @@ export function ImportFromDOLCEButton({ <Download className="w-4 h-4" /> )} <span className="hidden sm:inline">DOLCE에서 가져오기</span> - {importStatus && (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) && ( + {totalStats.newDocuments + totalStats.updatedDocuments > 0 && ( <Badge variant="default" className="h-5 w-5 p-0 text-xs flex items-center justify-center bg-blue-500" > - {importStatus.newDocuments + importStatus.updatedDocuments} + {totalStats.newDocuments + totalStats.updatedDocuments} </Badge> )} </Button> </div> </PopoverTrigger> - <PopoverContent className="w-80"> + <PopoverContent className="w-96"> <div className="space-y-4"> <div className="space-y-2"> <h4 className="font-medium">DOLCE 가져오기 상태</h4> @@ -215,33 +292,61 @@ export function ImportFromDOLCEButton({ </div> </div> - {importStatus && ( + {/* 다중 계약 정보 표시 */} + {contractIds.length > 1 && ( + <div className="text-sm"> + <div className="text-muted-foreground">대상 계약</div> + <div className="font-medium">{contractIds.length}개 계약</div> + <div className="text-xs text-muted-foreground"> + Contract IDs: {contractIds.join(', ')} + </div> + </div> + )} + + {totalStats && ( <div className="space-y-3"> <Separator /> <div className="grid grid-cols-2 gap-4 text-sm"> <div> <div className="text-muted-foreground">신규 문서</div> - <div className="font-medium">{importStatus.newDocuments || 0}건</div> + <div className="font-medium">{totalStats.newDocuments || 0}건</div> </div> <div> <div className="text-muted-foreground">업데이트</div> - <div className="font-medium">{importStatus.updatedDocuments || 0}건</div> + <div className="font-medium">{totalStats.updatedDocuments || 0}건</div> </div> </div> <div className="text-sm"> <div className="text-muted-foreground">DOLCE 전체 문서 (B3/B4/B5)</div> - <div className="font-medium">{importStatus.availableDocuments || 0}건</div> + <div className="font-medium">{totalStats.availableDocuments || 0}건</div> </div> - {importStatus.lastImportAt && ( - <div className="text-sm"> - <div className="text-muted-foreground">마지막 가져오기</div> - <div className="font-medium"> - {new Date(importStatus.lastImportAt).toLocaleString()} + {/* 각 계약별 세부 정보 (펼치기/접기 가능) */} + {contractIds.length > 1 && ( + <details className="text-sm"> + <summary className="cursor-pointer text-muted-foreground hover:text-foreground"> + 계약별 세부 정보 + </summary> + <div className="mt-2 space-y-2 pl-2 border-l-2 border-muted"> + {contractIds.map(contractId => { + const status = importStatusMap.get(contractId) + return ( + <div key={contractId} className="text-xs"> + <div className="font-medium">Contract {contractId}</div> + {status ? ( + <div className="text-muted-foreground"> + 신규 {status.newDocuments}건, 업데이트 {status.updatedDocuments}건 + </div> + ) : ( + <div className="text-destructive">상태 확인 실패</div> + )} + </div> + ) + })} </div> - </div> + </details> )} </div> )} @@ -271,7 +376,7 @@ export function ImportFromDOLCEButton({ <Button variant="outline" size="sm" - onClick={fetchImportStatus} + onClick={fetchAllImportStatus} disabled={statusLoading} > {statusLoading ? ( @@ -292,16 +397,17 @@ export function ImportFromDOLCEButton({ <DialogTitle>DOLCE에서 문서 목록 가져오기</DialogTitle> <DialogDescription> 삼성중공업 DOLCE 시스템에서 최신 문서 목록을 가져옵니다. + {contractIds.length > 1 && ` (${contractIds.length}개 계약 대상)`} </DialogDescription> </DialogHeader> <div className="space-y-4"> - {importStatus && ( + {totalStats && ( <div className="rounded-lg border p-4 space-y-3"> <div className="flex items-center justify-between text-sm"> <span>가져올 항목</span> <span className="font-medium"> - {(importStatus.newDocuments || 0) + (importStatus.updatedDocuments || 0)}건 + {totalStats.newDocuments + totalStats.updatedDocuments}건 </span> </div> @@ -309,6 +415,12 @@ export function ImportFromDOLCEButton({ 신규 문서와 업데이트된 문서가 포함됩니다. (B3, B4, B5) <br /> B4 문서의 경우 GTTPreDwg, GTTWorkingDwg 이슈 스테이지가 자동 생성됩니다. + {contractIds.length > 1 && ( + <> + <br /> + {contractIds.length}개 계약에서 순차적으로 가져옵니다. + </> + )} </div> {isImporting && ( diff --git a/lib/vendor-document-list/ship/revision-upload-dialog.tsx b/lib/vendor-document-list/ship/revision-upload-dialog.tsx deleted file mode 100644 index 16fc9fbb..00000000 --- a/lib/vendor-document-list/ship/revision-upload-dialog.tsx +++ /dev/null @@ -1,629 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" -import { mutate } from "swr" // ✅ SWR mutate import 추가 - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Upload, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" - -// 리비전 업로드 스키마 -const revisionUploadSchema = z.object({ - stage: z.string().min(1, "스테이지는 필수입니다"), - revision: z.string().min(1, "리비전은 필수입니다"), - uploaderName: z.string().optional(), - comment: z.string().optional(), - attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), - // ✅ B3 문서용 usage 필드 추가 - usage: z.string().optional(), -}).refine((data) => { - // B3 문서이고 특정 stage인 경우 usage 필수 - // 이 검증은 컴포넌트 내에서 조건부로 처리 - return true; -}, { - message: "Usage는 필수입니다", - path: ["usage"], -}); - -const getUsageOptions = (stageName: string): string[] => { - const stageNameLower = stageName.toLowerCase(); - - if (stageNameLower.includes('approval')) { - return ['Approval (Partial)', 'Approval (Full)']; - } else if (stageNameLower.includes('working')) { - return ['Working (Partial)', 'Working (Full)']; - } - - return []; -}; - - -type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> - -interface RevisionUploadDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - document: EnhancedDocumentsView | null - projectType: "ship" | "plant" - presetStage?: string - presetRevision?: string - mode?: 'new' | 'append' - onUploadComplete?: () => void // ✅ 업로드 완료 콜백 추가 -} - -function getTargetSystem(projectType: "ship" | "plant") { - return projectType === "ship" ? "DOLCE" : "SWP" -} - -export function RevisionUploadDialog({ - open, - onOpenChange, - document, - projectType, - presetStage, - presetRevision, - mode = 'new', - onUploadComplete, -}: RevisionUploadDialogProps) { - - const targetSystem = React.useMemo( - () => getTargetSystem(projectType), - [projectType] - ) - - const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) - const [isUploading, setIsUploading] = React.useState(false) - const [uploadProgress, setUploadProgress] = React.useState(0) - const router = useRouter() - - const { data: session } = useSession() - - // 사용 가능한 스테이지 옵션 - const stageOptions = React.useMemo(() => { - if (document?.allStages) { - return document.allStages.map(stage => stage.stageName) - } - return ["Issued for Review", "AFC", "Final Issue"] - }, [document]) - - const form = useForm<RevisionUploadSchema>({ - resolver: zodResolver(revisionUploadSchema), - defaultValues: { - stage: presetStage || document?.currentStageName || "", - revision: presetRevision || "", - uploaderName: session?.user?.name || "", - comment: "", - attachments: [], - usage: "", // ✅ usage 기본값 추가 - }, - }) - - // ✅ 현재 선택된 stage 값을 watch - const currentStage = form.watch('stage') - - // ✅ B3 문서 여부 확인 - const isB3Document = document?.drawingKind === 'B3' - - // ✅ 현재 stage에 따른 usage 옵션 - const usageOptions = React.useMemo(() => { - if (!isB3Document || !currentStage) return [] - return getUsageOptions(currentStage) - }, [isB3Document, currentStage]) - - // ✅ usage 필드가 필요한지 확인 - const isUsageRequired = isB3Document && usageOptions.length > 0 - - // session이 로드되면 uploaderName 업데이트 - React.useEffect(() => { - if (session?.user?.name) { - form.setValue('uploaderName', session.user.name) - } - }, [session?.user?.name, form]) - - // presetStage와 presetRevision이 변경될 때 폼 값 업데이트 - React.useEffect(() => { - if (presetStage) { - form.setValue('stage', presetStage) - } - if (presetRevision) { - form.setValue('revision', presetRevision) - } - }, [presetStage, presetRevision, form]) - - // ✅ stage가 변경될 때 usage 값 리셋 - React.useEffect(() => { - if (isB3Document) { - const newUsageOptions = getUsageOptions(currentStage) - if (newUsageOptions.length === 0) { - form.setValue('usage', '') - } else { - // 기존 값이 새로운 옵션에 없으면 리셋 - const currentUsage = form.getValues('usage') - if (currentUsage && !newUsageOptions.includes(currentUsage)) { - form.setValue('usage', '') - } - } - } - }, [currentStage, isB3Document, form]) - - // 파일 드롭 처리 - const handleDropAccepted = (acceptedFiles: File[]) => { - const newFiles = [...selectedFiles, ...acceptedFiles] - setSelectedFiles(newFiles) - form.setValue('attachments', newFiles, { shouldValidate: true }) - } - - const removeFile = (index: number) => { - const updatedFiles = [...selectedFiles] - updatedFiles.splice(index, 1) - setSelectedFiles(updatedFiles) - form.setValue('attachments', updatedFiles, { shouldValidate: true }) - } - - // 캐시 갱신 함수 - const refreshCaches = async () => { - try { - router.refresh() - - if (document?.contractId) { - await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`) - console.log('✅ Sync status cache refreshed') - } - - await mutate(key => - typeof key === 'string' && - key.includes('sync') && - key.includes(String(document?.contractId)) - ) - - onUploadComplete?.() - - console.log('✅ All caches refreshed after upload') - } catch (error) { - console.error('❌ Cache refresh failed:', error) - } - } - - // ✅ 업로드 처리 - usage 필드 검증 및 전송 - async function onSubmit(data: RevisionUploadSchema) { - if (!document) return - - // ✅ B3 문서에서 usage가 필요한 경우 검증 - if (isUsageRequired && !data.usage) { - form.setError('usage', { - type: 'required', - message: 'Usage 선택은 필수입니다' - }) - return - } - - setIsUploading(true) - setUploadProgress(0) - - try { - const formData = new FormData() - formData.append("documentId", String(document.documentId)) - formData.append("stage", data.stage) - formData.append("revision", data.revision) - formData.append("mode", mode) - formData.append("targetSystem", targetSystem) - - if (data.uploaderName) { - formData.append("uploaderName", data.uploaderName) - } - - if (data.comment) { - formData.append("comment", data.comment) - } - - // ✅ B3 문서인 경우 usage 추가 - if (isB3Document && data.usage) { - formData.append("usage", data.usage) - } - - // 파일들 추가 - data.attachments.forEach((file) => { - formData.append("attachments", file) - }) - - // 진행률 업데이트 시뮬레이션 - const updateProgress = (progress: number) => { - setUploadProgress(Math.min(progress, 95)) - } - - const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) - let uploadedSize = 0 - - const progressInterval = setInterval(() => { - uploadedSize += totalSize * 0.1 - const progress = Math.min((uploadedSize / totalSize) * 100, 90) - updateProgress(progress) - }, 300) - - const response = await fetch('/api/revision-upload', { - method: 'POST', - body: formData, - }) - - clearInterval(progressInterval) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') - } - - const result = await response.json() - setUploadProgress(100) - - toast.success( - result.message || - `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` - ) - - console.log('✅ 업로드 성공:', result) - - setTimeout(async () => { - await refreshCaches() - handleDialogClose() - }, 1000) - - } catch (error) { - console.error('❌ 업로드 오류:', error) - toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") - } finally { - setIsUploading(false) - setTimeout(() => setUploadProgress(0), 2000) - } - } - - const handleDialogClose = () => { - form.reset({ - stage: presetStage || document?.currentStageName || "", - revision: presetRevision || "", - uploaderName: session?.user?.name || "", - comment: "", - attachments: [], - usage: "", // ✅ usage 리셋 추가 - }) - setSelectedFiles([]) - setIsUploading(false) - setUploadProgress(0) - onOpenChange(false) - } - - return ( - <Dialog open={open} onOpenChange={handleDialogClose}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Upload className="w-5 h-5" /> - {mode === 'new' ? '새 리비전 업로드' : '파일 추가'} - </DialogTitle> - <DialogDescription> - {document ? `${document.docNumber} - ${document.title}` : - mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."} - </DialogDescription> - - <div className="flex items-center gap-2 pt-2 flex-wrap"> - <Badge variant={projectType === "ship" ? "default" : "secondary"}> - {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} - </Badge> - <Badge variant="outline" className="text-xs"> - → {targetSystem} - </Badge> - {/* ✅ B3 문서 표시 */} - {isB3Document && ( - <Badge variant="outline" className="text-xs bg-orange-50 text-orange-700 border-orange-200"> - B3 문서 - </Badge> - )} - {session?.user?.name && ( - <Badge variant="outline" className="text-xs"> - 업로더: {session.user.name} - </Badge> - )} - {mode === 'append' && presetRevision && ( - <Badge variant="outline" className="text-xs"> - 리비전 {presetRevision}에 파일 추가 - </Badge> - )} - {mode === 'new' && presetRevision && ( - <Badge variant="outline" className="text-xs"> - 다음 리비전: {presetRevision} - </Badge> - )} - </div> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="stage" - render={({ field }) => ( - <FormItem> - <FormLabel>스테이지</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="스테이지 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {stageOptions.map((stage) => ( - <SelectItem key={stage} value={stage}> - {stage} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="revision" - render={({ field }) => ( - <FormItem> - <FormLabel>리비전</FormLabel> - <FormControl> - <Input - {...field} - placeholder="예: A, B, 1, 2..." - readOnly={mode === 'append'} - className={mode === 'append' ? 'bg-gray-50' : ''} - /> - </FormControl> - <FormMessage /> - {mode === 'new' && presetRevision && ( - <p className="text-xs text-gray-500"> - 자동으로 계산된 다음 리비전입니다. - </p> - )} - {mode === 'append' && ( - <p className="text-xs text-gray-500"> - 기존 리비전에 파일을 추가합니다. - </p> - )} - </FormItem> - )} - /> - </div> - - {/* ✅ B3 문서용 Usage 필드 - 조건부 표시 */} - {isB3Document && usageOptions.length > 0 && ( - <FormField - control={form.control} - name="usage" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center gap-2"> - 용도 - {isUsageRequired && <span className="text-red-500">*</span>} - </FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="용도를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {usageOptions.map((usage) => ( - <SelectItem key={usage} value={usage}> - {usage} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - <p className="text-xs text-gray-500"> - {currentStage} 스테이지에 필요한 용도를 선택하세요. - </p> - </FormItem> - )} - /> - )} - - <FormField - control={form.control} - name="uploaderName" - render={({ field }) => ( - <FormItem> - <FormLabel>업로더명</FormLabel> - <FormControl> - <Input - {...field} - placeholder="업로더 이름을 입력하세요" - className="bg-gray-50" - /> - </FormControl> - <FormMessage /> - <p className="text-xs text-gray-500"> - 로그인된 사용자 정보가 자동으로 입력됩니다. - </p> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="comment" - render={({ field }) => ( - <FormItem> - <FormLabel>코멘트 (선택)</FormLabel> - <FormControl> - <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 업로드 영역 */} - <FormField - control={form.control} - name="attachments" - render={() => ( - <FormItem> - <FormLabel>파일 첨부</FormLabel> - <Dropzone - maxSize={3e9} // 3GB - multiple={true} - onDropAccepted={handleDropAccepted} - disabled={isUploading} - > - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> - <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요 - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - </Dropzone> - <FormMessage /> - </FormItem> - )} - /> - - {/* 선택된 파일 목록 */} - {selectedFiles.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) - </h6> - </div> - <ScrollArea className="max-h-[200px]"> - <FileList> - {selectedFiles.map((file, index) => ( - <FileListItem key={index} className="p-3"> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListSize>{file.size}</FileListSize> - </FileListInfo> - <FileListAction - onClick={() => removeFile(index)} - disabled={isUploading} - > - <X className="h-4 w-4" /> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </FileList> - </ScrollArea> - </div> - )} - - {/* 업로드 진행 상태 */} - {isUploading && ( - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <Loader2 className="h-4 w-4 animate-spin" /> - <span className="text-sm">{uploadProgress}% 업로드 중...</span> - </div> - <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> - <div - className="h-full bg-primary rounded-full transition-all" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={handleDialogClose} - disabled={isUploading} - > - 취소 - </Button> - <Button - type="submit" - disabled={isUploading || selectedFiles.length === 0} - > - {isUploading ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 업로드 중... - </> - ) : ( - <> - <Upload className="mr-2 h-4 w-4" /> - 업로드 - </> - )} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx b/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx deleted file mode 100644 index 933df263..00000000 --- a/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Calendar as CalendarComponent } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Calendar, Edit, Loader2 } from "lucide-react" -import { format } from "date-fns" -import { ko } from "date-fns/locale" -import { cn } from "@/lib/utils" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" - -// 단순화된 문서 편집 스키마 -const documentEditSchema = z.object({ - docNumber: z.string().min(1, "문서번호는 필수입니다"), - title: z.string().min(1, "제목은 필수입니다"), - pic: z.string().optional(), - status: z.string().min(1, "상태는 필수입니다"), - issuedDate: z.date().optional(), - description: z.string().optional(), -}) - -type DocumentEditSchema = z.infer<typeof documentEditSchema> - -const statusOptions = [ - { value: "ACTIVE", label: "활성" }, - { value: "INACTIVE", label: "비활성" }, - { value: "COMPLETED", label: "완료" }, - { value: "CANCELLED", label: "취소" }, -] - -interface SimplifiedDocumentEditDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - document: EnhancedDocumentsView | null - projectType: "ship" | "plant" -} - -export function SimplifiedDocumentEditDialog({ - open, - onOpenChange, - document, - projectType, -}: SimplifiedDocumentEditDialogProps) { - const [isUpdating, setIsUpdating] = React.useState(false) - - const form = useForm<DocumentEditSchema>({ - resolver: zodResolver(documentEditSchema), - defaultValues: { - docNumber: "", - title: "", - pic: "", - status: "ACTIVE", - issuedDate: undefined, - description: "", - }, - }) - - // 폼 초기화 - React.useEffect(() => { - if (document) { - form.reset({ - docNumber: document.docNumber, - title: document.title, - pic: document.pic || "", - status: document.status, - issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, - description: "", - }) - } - }, [document, form]) - - async function onSubmit(data: DocumentEditSchema) { - if (!document) return - - setIsUpdating(true) - try { - // 실제 업데이트 API 호출 (구현 필요) - // await updateDocumentInfo({ documentId: document.documentId, ...data }) - - toast.success("문서 정보가 업데이트되었습니다") - onOpenChange(false) - } catch (error) { - toast.error("업데이트 중 오류가 발생했습니다") - console.error(error) - } finally { - setIsUpdating(false) - } - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Edit className="w-5 h-5" /> - 문서 정보 수정 - </DialogTitle> - <DialogDescription> - {document ? `${document.docNumber}의 기본 정보를 수정합니다.` : "문서 기본 정보를 수정합니다."} - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - <FormField - control={form.control} - name="docNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>문서번호</FormLabel> - <FormControl> - <Input {...field} disabled={projectType === "ship"} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>제목</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="pic" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자 (PIC)</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>상태</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - {statusOptions.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="issuedDate" - render={({ field }) => ( - <FormItem> - <FormLabel>발행일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "yyyy년 MM월 dd일", { locale: ko }) - ) : ( - <span>날짜를 선택하세요</span> - )} - <Calendar className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <CalendarComponent - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => date > new Date()} - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>설명 (선택)</FormLabel> - <FormControl> - <Textarea {...field} placeholder="문서에 대한 설명을 입력하세요" rows={3} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isUpdating} - > - 취소 - </Button> - <Button type="submit" disabled={isUpdating}> - {isUpdating ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 저장 중... - </> - ) : ( - <> - <Edit className="mr-2 h-4 w-4" /> - 저장 - </> - )} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx b/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx deleted file mode 100644 index 6b9cffb9..00000000 --- a/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx +++ /dev/null @@ -1,752 +0,0 @@ -"use client" - -import * as React from "react" -import { WebViewerInstance } from "@pdftron/webviewer" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - FileText, - User, - Calendar, - Clock, - CheckCircle, - AlertTriangle, - ChevronDown, - ChevronRight, - Upload, - Eye, - Download, - FileIcon, - MoreHorizontal, - Loader2 -} from "lucide-react" -import { cn } from "@/lib/utils" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import type { EnhancedDocument } from "@/types/enhanced-documents" - -// 유틸리티 함수들 -const getStatusColor = (status: string) => { - switch (status) { - case 'COMPLETED': case 'APPROVED': return 'bg-green-100 text-green-800' - case 'IN_PROGRESS': return 'bg-blue-100 text-blue-800' - case 'SUBMITTED': case 'UNDER_REVIEW': return 'bg-purple-100 text-purple-800' - case 'REJECTED': return 'bg-red-100 text-red-800' - default: return 'bg-gray-100 text-gray-800' - } -} - -const getPriorityColor = (priority: string) => { - switch (priority) { - case 'HIGH': return 'bg-red-100 text-red-800 border-red-200' - case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border-yellow-200' - case 'LOW': return 'bg-green-100 text-green-800 border-green-200' - default: return 'bg-gray-100 text-gray-800 border-gray-200' - } -} - -const getStatusText = (status: string) => { - switch (status) { - case 'PLANNED': return '계획됨' - case 'IN_PROGRESS': return '진행중' - case 'SUBMITTED': return '제출됨' - case 'UPLOADED': return '등록됨' - case 'UNDER_REVIEW': return '검토중' - case 'APPROVED': return '승인됨' - case 'REJECTED': return '반려됨' - case 'COMPLETED': return '완료됨' - default: return status - } -} - -const getPriorityText = (priority: string) => { - switch (priority) { - case 'HIGH': return '높음' - case 'MEDIUM': return '보통' - case 'LOW': return '낮음' - default: return priority - } -} - -const getFileIconColor = (fileName: string) => { - const ext = fileName.split('.').pop()?.toLowerCase() - switch (ext) { - case 'pdf': return 'text-red-500' - case 'doc': case 'docx': return 'text-blue-500' - case 'xls': case 'xlsx': return 'text-green-500' - case 'dwg': return 'text-amber-500' - default: return 'text-gray-500' - } -} - -interface StageRevisionExpandedContentProps { - document: EnhancedDocument - onUploadRevision: (documentData: EnhancedDocument, stageName?: string, currentRevision?: string, mode?: 'new' | 'append') => void - onStageStatusUpdate?: (stageId: number, status: string) => void - onRevisionStatusUpdate?: (revisionId: number, status: string) => void - projectType: "ship" | "plant" - expandedStages?: Record<number, boolean> - onStageToggle?: (stageId: number) => void -} - -export const StageRevisionExpandedContent = ({ - document: documentData, - onUploadRevision, - onStageStatusUpdate, - onRevisionStatusUpdate, - projectType, - expandedStages = {}, - onStageToggle, -}: StageRevisionExpandedContentProps) => { - // 로컬 상태 관리 - const [localExpandedStages, setLocalExpandedStages] = React.useState<Record<number, boolean>>({}) - const [expandedRevisions, setExpandedRevisions] = React.useState<Set<number>>(new Set()) - - // ✅ 문서 뷰어 상태 관리 - const [viewerOpen, setViewerOpen] = React.useState(false) - const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) - const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) - const [viewerLoading, setViewerLoading] = React.useState(true) - const [fileSetLoading, setFileSetLoading] = React.useState(true) - const viewer = React.useRef<HTMLDivElement>(null) - const initialized = React.useRef(false) - const isCancelled = React.useRef(false) - - // 상위에서 관리하는지 로컬에서 관리하는지 결정 - const isExternallyManaged = onStageToggle !== undefined - const currentExpandedStages = isExternallyManaged ? expandedStages : localExpandedStages - - const handleStageToggle = React.useCallback((stageId: number) => { - if (isExternallyManaged && onStageToggle) { - onStageToggle(stageId) - } else { - setLocalExpandedStages(prev => ({ - ...prev, - [stageId]: !prev[stageId] - })) - } - }, [isExternallyManaged, onStageToggle]) - - const toggleRevisionFiles = React.useCallback((revisionId: number) => { - setExpandedRevisions(prev => { - const newSet = new Set(prev) - if (newSet.has(revisionId)) { - newSet.delete(revisionId) - } else { - newSet.add(revisionId) - } - return newSet - }) - }, []) - - // ✅ PDF 뷰어 정리 함수 - const cleanupHtmlStyle = React.useCallback(() => { - const htmlElement = window.document.documentElement - const originalStyle = htmlElement.getAttribute("style") || "" - const colorSchemeStyle = originalStyle - .split(";") - .map((s) => s.trim()) - .find((s) => s.startsWith("color-scheme:")) - - if (colorSchemeStyle) { - htmlElement.setAttribute("style", colorSchemeStyle + ";") - } else { - htmlElement.removeAttribute("style") - } - }, []) - - // ✅ 문서 뷰어 열기 함수 - const handleViewRevision = React.useCallback((revisions: any[]) => { - setSelectedRevisions(revisions) - setViewerOpen(true) - setViewerLoading(true) - setFileSetLoading(true) - initialized.current = false - }, []) - - // ✅ 파일 다운로드 함수 - 새로운 document-download API 사용 - const handleDownloadFile = React.useCallback(async (attachment: any) => { - console.log(attachment) - try { - // ID를 우선으로 사용, 없으면 filePath 사용 - const queryParam = attachment.id - ? `id=${encodeURIComponent(attachment.id)}` - : `path=${encodeURIComponent(attachment.filePath)}` - - const response = await fetch(`/api/document-download?${queryParam}`) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') - } - - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const link = window.document.createElement('a') - link.href = url - link.download = attachment.fileName - window.document.body.appendChild(link) - link.click() - window.document.body.removeChild(link) - window.URL.revokeObjectURL(url) - - console.log('✅ 파일 다운로드 완료:', attachment.fileName) - } catch (error) { - console.error('❌ 파일 다운로드 오류:', error) - // 실제 앱에서는 toast나 alert로 에러 표시 - alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) - } - }, []) - - // ✅ WebViewer 초기화 - React.useEffect(() => { - if (viewerOpen && !initialized.current) { - initialized.current = true - isCancelled.current = false - - requestAnimationFrame(() => { - if (viewer.current && !isCancelled.current) { - import("@pdftron/webviewer").then(({ default: WebViewer }) => { - if (isCancelled.current) { - console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)") - return - } - - WebViewer( - { - path: "/pdftronWeb", - licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", - fullAPI: true, - css: "/globals.css", - }, - viewer.current as HTMLDivElement - ).then(async (instance: WebViewerInstance) => { - if (!isCancelled.current) { - setInstance(instance) - instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) - instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) - setViewerLoading(false) - } - }) - }) - } - }) - } - - return () => { - if (instance) { - instance.UI.dispose() - } - setTimeout(() => cleanupHtmlStyle(), 500) - } - }, [viewerOpen, cleanupHtmlStyle]) - - // ✅ 문서 로드 - React.useEffect(() => { - const loadDocument = async () => { - if (instance && selectedRevisions.length > 0) { - const { UI } = instance - const optionsArray: any[] = [] - - selectedRevisions.forEach((revision) => { - const { attachments } = revision - attachments?.forEach((attachment: any) => { - const { fileName, filePath, fileType } = attachment - const fileTypeCur = fileType ?? "" - - const options = { - filename: fileName, - ...(fileTypeCur.includes("xlsx") && { - officeOptions: { - formatOptions: { - applyPageBreaksToSheet: true, - }, - }, - }), - } - - optionsArray.push({ filePath, options }) - }) - }) - - const tabIds = [] - for (const option of optionsArray) { - const { filePath, options } = option - try { - const response = await fetch(filePath) - const blob = await response.blob() - const tab = await UI.TabManager.addTab(blob, options) - tabIds.push(tab) - } catch (error) { - console.error("파일 로드 실패:", filePath, error) - } - } - - if (tabIds.length > 0) { - await UI.TabManager.setActiveTab(tabIds[0]) - } - - setFileSetLoading(false) - } - } - loadDocument() - }, [instance, selectedRevisions]) - - // ✅ 뷰어 닫기 - const handleCloseViewer = React.useCallback(async () => { - if (!fileSetLoading) { - isCancelled.current = true - - if (instance) { - try { - await instance.UI.dispose() - setInstance(null) - } catch (e) { - console.warn("dispose error", e) - } - } - - setViewerLoading(false) - setViewerOpen(false) - setTimeout(() => cleanupHtmlStyle(), 1000) - } - }, [fileSetLoading, instance, cleanupHtmlStyle]) - - // 뷰에서 가져온 allStages 데이터를 바로 사용 - const stagesWithRevisions = documentData.allStages || [] - - console.log(stagesWithRevisions) - - if (stagesWithRevisions.length === 0) { - return ( - <div className="p-6 text-center text-gray-500"> - <FileText className="w-12 h-12 mx-auto mb-4 text-gray-300" /> - <h4 className="font-medium mb-2">스테이지 정보가 없습니다</h4> - <p className="text-sm">이 문서에 대한 스테이지를 먼저 설정해주세요.</p> - </div> - ) - } - - return ( - <> - <div className="w-full max-w-none bg-gray-50" onClick={(e) => e.stopPropagation()}> - <div className="p-4"> - <div className="flex items-center justify-between mb-4"> - <div> - <h4 className="font-semibold flex items-center gap-2"> - <FileText className="w-4 h-4" /> - 스테이지별 리비전 현황 - </h4> - <p className="text-xs text-gray-600 mt-1"> - 총 {stagesWithRevisions.length}개 스테이지, {stagesWithRevisions.reduce((acc, stage) => acc + (stage.revisions?.length || 0), 0)}개 리비전 - </p> - </div> - {/* <Button - size="sm" - onClick={() => onUploadRevision(document, undefined, undefined, 'new')} - className="flex items-center gap-2" - > - <Upload className="w-3 h-3" /> - 새 리비전 업로드 - </Button> */} - </div> - - <ScrollArea className="h-[400px] w-full"> - <div className="space-y-3 pr-4"> - {stagesWithRevisions.map((stage) => { - const isExpanded = currentExpandedStages[stage.id] || false - const revisions = stage.revisions || [] - - return ( - <div key={stage.id} className="bg-white rounded border shadow-sm overflow-hidden"> - {/* 스테이지 헤더 - 전체 영역 클릭 가능 */} - <div - className="py-2 px-3 bg-gray-50 border-b cursor-pointer hover:bg-gray-100 transition-colors" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - handleStageToggle(stage.id) - }} - > - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - {/* 버튼 영역 - 이제 시각적 표시만 담당 */} - <div className="flex items-center gap-2"> - <div className="flex items-center gap-2"> - <div className="w-6 h-6 rounded-full bg-white border-2 border-gray-300 flex items-center justify-center text-xs font-medium"> - {stage.stageOrder || 1} - </div> - <div className={cn( - "w-2 h-2 rounded-full", - stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : - stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : - stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : - 'bg-gray-300' - )} /> - {isExpanded ? - <ChevronDown className="w-3 h-3 text-gray-500" /> : - <ChevronRight className="w-3 h-3 text-gray-500" /> - } - </div> - </div> - - <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="font-medium text-sm">{stage.stageName}</div> - <Badge className={cn("text-xs", getStatusColor(stage.stageStatus))}> - {getStatusText(stage.stageStatus)} - </Badge> - <span className="text-xs text-gray-500"> - {revisions.length}개 리비전 - </span> - </div> - </div> - </div> - - <div className="flex items-center gap-4"> - <div className="grid grid-cols-2 gap-2 text-xs"> - <div> - <span className="text-gray-500">계획: </span> - <span className="font-medium">{stage.planDate ? formatDate(stage.planDate) : '-'}</span> - </div> - {stage.actualDate && ( - <div> - <span className="text-gray-500">완료: </span> - <span className="font-medium">{formatDate(stage.actualDate)}</span> - </div> - )} - {stage.assigneeName && ( - <div className="col-span-2 flex items-center gap-1 text-gray-600"> - <User className="w-3 h-3" /> - <span className="text-xs">{stage.assigneeName}</span> - </div> - )} - </div> - - {/* 스테이지 액션 메뉴 - 클릭 이벤트 전파 차단 */} - <div - onClick={(e) => { - e.stopPropagation() // 액션 메뉴 클릭 시 스테이지 토글 방지 - }} - > - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-0" - > - <MoreHorizontal className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {onStageStatusUpdate && ( - <> - <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}> - 진행 시작 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}> - 완료 처리 - </DropdownMenuItem> - </> - )} - <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}> - 리비전 업로드 - </DropdownMenuItem> - {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */} - {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && ( - <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}> - <Eye className="w-3 h-3 mr-1" /> - 스테이지 문서 보기 - </DropdownMenuItem> - )} - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </div> - </div> - - {/* 리비전 목록 - 테이블 형태 */} - {isExpanded && ( - <div className="max-h-72 overflow-y-auto"> - {revisions.length > 0 ? ( - <div className="border-t"> - <Table> - <TableHeader> - <TableRow className="bg-gray-50/50 h-8"> - <TableHead className="w-16 py-1 px-2 text-xs"></TableHead> - <TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead> - <TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead> - {documentData.drawingKind === 'B3' && ( - <TableHead className="w-24 py-1 px-2 text-xs">용도</TableHead> - )} - <TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead> - <TableHead className="w-32 py-1 px-2 text-xs">등록일</TableHead> - <TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead> - <TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead> - <TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead> - <TableHead className="w-16 py-1 px-2 text-xs">액션</TableHead> - <TableHead className="min-w-0 py-1 px-2 text-xs">코멘트</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {revisions.map((revision) => { - const hasAttachments = revision.attachments && revision.attachments.length > 0 - - return ( - <TableRow key={revision.id} className="hover:bg-gray-50 h-10"> - {/* 리비전 */} - <TableCell className="py-1 px-2"> - <span className="text-xs font-semibold"> - {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} - </span> - </TableCell> - - <TableCell className="py-1 px-2"> - <span className="font-mono text-xs font-semibold bg-gray-100 px-1.5 py-0.5 rounded"> - {revision.revision} - </span> - </TableCell> - - {/* 상태 */} - <TableCell className="py-1 px-2"> - <Badge className={cn("text-xs px-1.5 py-0.5", getStatusColor(revision.revisionStatus))}> - {getStatusText(revision.revisionStatus)} - </Badge> - </TableCell> - - {/* ✅ B3 문서일 때만 Usage 셀 표시 */} - {documentData.drawingKind === 'B3' && ( - <TableCell className="py-1 px-2"> - {revision.usage ? ( - <span className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded border border-blue-200"> - {revision.usage} - </span> - ) : ( - <span className="text-gray-400 text-xs">-</span> - )} - </TableCell> - )} - - {/* 업로더 */} - <TableCell className="py-1 px-2"> - <div className="flex items-center gap-1"> - <User className="w-3 h-3 text-gray-400" /> - <span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span> - </div> - </TableCell> - {/* 제출일 */} - <TableCell className="py-1 px-2"> - <span className="text-xs text-gray-600"> - {revision.uploadedAt ? formatDate(revision.uploadedAt) : '-'} - </span> - </TableCell> - - {/* 제출일 */} - <TableCell className="py-1 px-2"> - <span className="text-xs text-gray-600"> - {revision.externalSentDate ? formatDate(revision.externalSentDate) : '-'} - </span> - </TableCell> - - {/* 승인/반려일 */} - <TableCell className="py-1 px-2"> - <div className="text-xs text-gray-600"> - {revision.approvedDate && ( - <div className="flex items-center gap-1 text-green-600"> - <CheckCircle className="w-3 h-3" /> - <span className="text-xs">{formatDate(revision.approvedDate)}</span> - </div> - )} - {revision.rejectedDate && ( - <div className="flex items-center gap-1 text-red-600"> - <AlertTriangle className="w-3 h-3" /> - <span className="text-xs">{formatDate(revision.rejectedDate)}</span> - </div> - )} - {revision.reviewStartDate && !revision.approvedDate && !revision.rejectedDate && ( - <div className="flex items-center gap-1 text-blue-600"> - <Clock className="w-3 h-3" /> - <span className="text-xs">{formatDate(revision.reviewStartDate)}</span> - </div> - )} - {!revision.approvedDate && !revision.rejectedDate && !revision.reviewStartDate && ( - <span className="text-gray-400 text-xs">-</span> - )} - </div> - </TableCell> - - {/* ✅ 첨부파일 - 클릭 시 다운로드, 별도 뷰어 버튼 */} - <TableCell className="py-1 px-2"> - {hasAttachments ? ( - <div className="flex items-center gap-1 flex-wrap"> - {/* 파일 아이콘들 - 클릭 시 다운로드 */} - {revision.attachments.slice(0, 4).map((file: any) => ( - <Button - key={file.id} - variant="ghost" - size="sm" - onClick={() => handleDownloadFile(file)} - className="p-0.5 h-auto hover:bg-blue-50 rounded" - title={`${file.fileName} - 클릭해서 다운로드`} - > - <FileIcon className={cn("w-3 h-3", getFileIconColor(file.fileName))} /> - </Button> - ))} - {revision.attachments.length > 4 && ( - <span - className="text-xs text-gray-500 ml-0.5" - title={`총 ${revision.attachments.length}개 파일`} - > - +{revision.attachments.length - 4} - </span> - )} - {/* ✅ 모든 파일 보기 버튼 - 뷰어 열기 */} - <Button - variant="ghost" - size="sm" - onClick={() => handleViewRevision([revision])} - className="p-0.5 h-auto hover:bg-green-50 rounded ml-1" - title="모든 파일 보기" - > - <Eye className="w-3 h-3 text-green-600" /> - </Button> - </div> - ) : ( - <span className="text-gray-400 text-xs">-</span> - )} - </TableCell> - - {/* 액션 */} - <TableCell className="py-1 px-2"> - <div className="flex gap-0.5"> - {revision.revisionStatus === 'UNDER_REVIEW' && onRevisionStatusUpdate && ( - <> - <Button - size="sm" - variant="ghost" - onClick={() => onRevisionStatusUpdate(revision.id, 'APPROVED')} - className="text-green-600 hover:bg-green-50 h-6 px-1" - title="승인" - > - <CheckCircle className="w-3 h-3" /> - </Button> - <Button - size="sm" - variant="ghost" - onClick={() => onRevisionStatusUpdate(revision.id, 'REJECTED')} - className="text-red-600 hover:bg-red-50 h-6 px-1" - title="반려" - > - <AlertTriangle className="w-3 h-3" /> - </Button> - </> - )} - <Button - size="sm" - variant="ghost" - onClick={() => onUploadRevision(documentData, stage.stageName, revision.revision, 'append')} - className="text-blue-600 hover:bg-blue-50 h-6 px-1" - title="파일 추가" - > - <Upload className="w-3 h-3" /> - </Button> - </div> - </TableCell> - - {/* 코멘트 */} - <TableCell className="py-1 px-2"> - {revision.comment ? ( - <div className="max-w-24"> - <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> - {revision.comment} - </p> - </div> - ) : ( - <span className="text-gray-400 text-xs">-</span> - )} - </TableCell> - </TableRow> - ) - })} - </TableBody> - </Table> - </div> - ) : ( - <div className="p-6 text-center"> - <div className="flex flex-col items-center gap-3"> - <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> - <FileText className="w-6 h-6 text-gray-300" /> - </div> - <div> - <h5 className="font-medium text-gray-700 mb-1 text-sm">리비전이 없습니다</h5> - <p className="text-xs text-gray-500 mb-3">아직 이 스테이지에 업로드된 리비전이 없습니다</p> - <Button - size="sm" - onClick={() => onUploadRevision(documentData, stage.stageName, undefined, 'new')} - className="text-xs" - > - <Upload className="w-3 h-3 mr-1" /> - 첫 리비전 업로드 - </Button> - </div> - </div> - </div> - )} - </div> - )} - </div> - ) - })} - </div> - </ScrollArea> - </div> - </div> - - {/* ✅ 통합된 문서 뷰어 다이얼로그 */} - <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> - <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> - <DialogHeader className="h-[38px]"> - <DialogTitle>문서 미리보기</DialogTitle> - <DialogDescription> - {selectedRevisions.length === 1 - ? `리비전 ${selectedRevisions[0]?.revision} 첨부파일` - : `${selectedRevisions.length}개 리비전 첨부파일` - } - </DialogDescription> - </DialogHeader> - <div - ref={viewer} - style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} - > - {viewerLoading && ( - <div className="flex flex-col items-center justify-center py-12"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground"> - 문서 뷰어 로딩 중... - </p> - </div> - )} - </div> - </DialogContent> - </Dialog> - </> - ) -}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/stage-revision-sheet.tsx b/lib/vendor-document-list/ship/stage-revision-sheet.tsx deleted file mode 100644 index 2cc22cce..00000000 --- a/lib/vendor-document-list/ship/stage-revision-sheet.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// StageRevisionDrawer.tsx -// Slide‑up drawer (bottom) that shows StageRevisionExpandedContent. -// Requires shadcn/ui Drawer primitives already installed. - -"use client" - -import * as React from "react" -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, - DrawerDescription, -} from "@/components/ui/drawer" - -import type { EnhancedDocument } from "@/types/enhanced-documents" -import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" - -export interface StageRevisionDrawerProps { - /** whether the drawer is open */ - open: boolean - /** callback invoked when the open state should change */ - onOpenChange: (open: boolean) => void - /** the document whose stages / revisions are displayed */ - document: EnhancedDocument | null - /** project type to propagate further */ - projectType: "ship" | "plant" - /** callbacks forwarded to StageRevisionExpandedContent */ - onUploadRevision: ( - doc: EnhancedDocument, - stageName?: string, - currentRevision?: string, - mode?: "new" | "append" - ) => void - onViewRevision: (revisions: any[]) => void - onStageStatusUpdate?: (stageId: number, status: string) => void - onRevisionStatusUpdate?: (revisionId: number, status: string) => void -} - -/** - * Bottom‑anchored Drawer that presents Stage / Revision details. - * Fills up to 85 vh and slides up from the bottom edge. - */ -export const StageRevisionDrawer: React.FC<StageRevisionDrawerProps> = ({ - open, - onOpenChange, - document, - projectType, - onUploadRevision, - onViewRevision, - onStageStatusUpdate, - onRevisionStatusUpdate, -}) => { - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - {/* No trigger – controlled by parent */} - <DrawerContent className="h-[85vh] flex flex-col p-0"> - <DrawerHeader className="border-b p-4"> - <DrawerTitle>스테이지 / 리비전 상세</DrawerTitle> - {document && ( - <DrawerDescription className="text-xs text-muted-foreground truncate"> - {document.docNumber} — {document.title} - </DrawerDescription> - )} - </DrawerHeader> - - <div className="flex-1 overflow-auto"> - {document ? ( - <StageRevisionExpandedContent - document={document} - projectType={projectType} - onUploadRevision={onUploadRevision} - onViewRevision={onViewRevision} - onStageStatusUpdate={onStageStatusUpdate} - onRevisionStatusUpdate={onRevisionStatusUpdate} - /> - ) : ( - <div className="flex h-full items-center justify-center text-sm text-gray-500"> - 문서가 선택되지 않았습니다. - </div> - )} - </div> - </DrawerContent> - </Drawer> - ) -} diff --git a/lib/vendor-document-list/ship/swp-workflow-panel.tsx b/lib/vendor-document-list/ship/swp-workflow-panel.tsx deleted file mode 100644 index ded306e7..00000000 --- a/lib/vendor-document-list/ship/swp-workflow-panel.tsx +++ /dev/null @@ -1,370 +0,0 @@ -"use client" - -import * as React from "react" -import { Send, Eye, CheckCircle, Clock, RefreshCw, AlertTriangle, Loader2 } from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { Progress } from "@/components/ui/progress" -import type { EnhancedDocument } from "@/types/enhanced-documents" - -interface SWPWorkflowPanelProps { - contractId: number - documents: EnhancedDocument[] - onWorkflowUpdate?: () => void -} - -type WorkflowStatus = - | 'IDLE' // 대기 상태 - | 'SUBMITTED' // 목록 전송됨 - | 'UNDER_REVIEW' // 검토 중 - | 'CONFIRMED' // 컨펌됨 - | 'REVISION_REQUIRED' // 수정 요청됨 - | 'RESUBMITTED' // 재전송됨 - | 'APPROVED' // 최종 승인됨 - -interface WorkflowState { - status: WorkflowStatus - lastUpdatedAt?: string - pendingActions: string[] - confirmationData?: any - revisionComments?: string[] - approvalData?: any -} - -export function SWPWorkflowPanel({ - contractId, - documents, - onWorkflowUpdate -}: SWPWorkflowPanelProps) { - const [workflowState, setWorkflowState] = React.useState<WorkflowState | null>(null) - const [isLoading, setIsLoading] = React.useState(false) - const [actionProgress, setActionProgress] = React.useState(0) - - // 워크플로우 상태 조회 - const fetchWorkflowStatus = async () => { - setIsLoading(true) - try { - const response = await fetch(`/api/sync/workflow/status?contractId=${contractId}&targetSystem=SWP`) - if (!response.ok) throw new Error('Failed to fetch workflow status') - - const status = await response.json() - setWorkflowState(status) - } catch (error) { - console.error('Failed to fetch workflow status:', error) - toast.error('워크플로우 상태를 확인할 수 없습니다') - } finally { - setIsLoading(false) - } - } - - // 컴포넌트 마운트 시 상태 조회 - React.useEffect(() => { - fetchWorkflowStatus() - }, [contractId]) - - // 워크플로우 액션 실행 - const executeWorkflowAction = async (action: string) => { - setActionProgress(0) - setIsLoading(true) - - try { - // 진행률 시뮬레이션 - const progressInterval = setInterval(() => { - setActionProgress(prev => Math.min(prev + 20, 90)) - }, 200) - - const response = await fetch('/api/sync/workflow/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contractId, - targetSystem: 'SWP', - action, - documents: documents.map(doc => ({ id: doc.id, documentNo: doc.documentNo })) - }) - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || 'Workflow action failed') - } - - const result = await response.json() - - clearInterval(progressInterval) - setActionProgress(100) - - setTimeout(() => { - setActionProgress(0) - - if (result?.success) { - toast.success( - `${getActionLabel(action)} 완료`, - { description: result?.message || '워크플로우가 성공적으로 진행되었습니다.' } - ) - } else { - toast.error( - `${getActionLabel(action)} 실패`, - { description: result?.message || '워크플로우 실행에 실패했습니다.' } - ) - } - - fetchWorkflowStatus() // 상태 갱신 - onWorkflowUpdate?.() - }, 500) - - } catch (error) { - setActionProgress(0) - - toast.error(`${getActionLabel(action)} 실패`, { - description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' - }) - } finally { - setIsLoading(false) - } - } - - const getActionLabel = (action: string): string => { - switch (action) { - case 'SUBMIT_LIST': return '목록 전송' - case 'CHECK_CONFIRMATION': return '컨펌 확인' - case 'RESUBMIT_REVISED': return '수정본 재전송' - case 'CHECK_APPROVAL': return '승인 확인' - default: return action - } - } - - const getStatusBadge = () => { - if (isLoading) { - return <Badge variant="secondary">확인 중...</Badge> - } - - if (!workflowState) { - return <Badge variant="destructive">오류</Badge> - } - - switch (workflowState.status) { - case 'IDLE': - return <Badge variant="secondary">대기</Badge> - case 'SUBMITTED': - return ( - <Badge variant="default" className="gap-1 bg-blue-500"> - <Clock className="w-3 h-3" /> - 전송됨 - </Badge> - ) - case 'UNDER_REVIEW': - return ( - <Badge variant="default" className="gap-1 bg-yellow-500"> - <Eye className="w-3 h-3" /> - 검토 중 - </Badge> - ) - case 'CONFIRMED': - return ( - <Badge variant="default" className="gap-1 bg-green-500"> - <CheckCircle className="w-3 h-3" /> - 컨펌됨 - </Badge> - ) - case 'REVISION_REQUIRED': - return ( - <Badge variant="destructive" className="gap-1"> - <AlertTriangle className="w-3 h-3" /> - 수정 요청 - </Badge> - ) - case 'RESUBMITTED': - return ( - <Badge variant="default" className="gap-1 bg-orange-500"> - <RefreshCw className="w-3 h-3" /> - 재전송됨 - </Badge> - ) - case 'APPROVED': - return ( - <Badge variant="default" className="gap-1 bg-green-600"> - <CheckCircle className="w-3 h-3" /> - 승인 완료 - </Badge> - ) - default: - return <Badge variant="secondary">알 수 없음</Badge> - } - } - - const getAvailableActions = (): string[] => { - if (!workflowState) return [] - - switch (workflowState.status) { - case 'IDLE': - return ['SUBMIT_LIST'] - case 'SUBMITTED': - return ['CHECK_CONFIRMATION'] - case 'UNDER_REVIEW': - return ['CHECK_CONFIRMATION'] - case 'CONFIRMED': - return [] // 컨펌되면 자동으로 다음 단계로 - case 'REVISION_REQUIRED': - return ['RESUBMIT_REVISED'] - case 'RESUBMITTED': - return ['CHECK_APPROVAL'] - case 'APPROVED': - return [] // 완료 상태 - default: - return [] - } - } - - const availableActions = getAvailableActions() - - return ( - <Popover> - <PopoverTrigger asChild> - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - className="flex items-center border-orange-200 hover:bg-orange-50" - disabled={isLoading} - > - {isLoading ? ( - <Loader2 className="w-4 h-4 animate-spin" /> - ) : ( - <RefreshCw className="w-4 h-4" /> - )} - <span className="hidden sm:inline">SWP 워크플로우</span> - {workflowState?.pendingActions && workflowState.pendingActions.length > 0 && ( - <Badge - variant="destructive" - className="h-5 w-5 p-0 text-xs flex items-center justify-center" - > - {workflowState.pendingActions.length} - </Badge> - )} - </Button> - </div> - </PopoverTrigger> - - <PopoverContent className="w-80"> - <div className="space-y-4"> - <div className="space-y-2"> - <h4 className="font-medium">SWP 워크플로우 상태</h4> - <div className="flex items-center justify-between"> - <span className="text-sm text-muted-foreground">현재 상태</span> - {getStatusBadge()} - </div> - </div> - - {workflowState && ( - <div className="space-y-3"> - <Separator /> - - {/* 대기 중인 액션들 */} - {workflowState.pendingActions && workflowState.pendingActions.length > 0 && ( - <div className="space-y-2"> - <div className="text-sm font-medium">대기 중인 작업</div> - {workflowState.pendingActions.map((action, index) => ( - <Badge key={index} variant="outline" className="mr-1"> - {getActionLabel(action)} - </Badge> - ))} - </div> - )} - - {/* 수정 요청 사항 */} - {workflowState.revisionComments && workflowState.revisionComments.length > 0 && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-red-600">수정 요청 사항</div> - <div className="text-xs text-muted-foreground space-y-1"> - {workflowState.revisionComments.map((comment, index) => ( - <div key={index} className="p-2 bg-red-50 rounded text-red-700"> - {comment} - </div> - ))} - </div> - </div> - )} - - {/* 마지막 업데이트 시간 */} - {workflowState.lastUpdatedAt && ( - <div className="text-sm"> - <div className="text-muted-foreground">마지막 업데이트</div> - <div className="font-medium"> - {new Date(workflowState.lastUpdatedAt).toLocaleString()} - </div> - </div> - )} - - {/* 진행률 표시 */} - {isLoading && actionProgress > 0 && ( - <div className="space-y-2"> - <div className="flex items-center justify-between text-sm"> - <span>진행률</span> - <span>{actionProgress}%</span> - </div> - <Progress value={actionProgress} className="h-2" /> - </div> - )} - </div> - )} - - <Separator /> - - {/* 액션 버튼들 */} - <div className="space-y-2"> - {availableActions.length > 0 ? ( - availableActions.map((action) => ( - <Button - key={action} - onClick={() => executeWorkflowAction(action)} - disabled={isLoading} - className="w-full justify-start" - size="sm" - variant={action.includes('SUBMIT') || action.includes('RESUBMIT') ? 'default' : 'outline'} - > - {isLoading ? ( - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - ) : ( - <Send className="w-4 h-4 mr-2" /> - )} - {getActionLabel(action)} - </Button> - )) - ) : ( - <div className="text-sm text-muted-foreground text-center py-2"> - {workflowState?.status === 'APPROVED' - ? '워크플로우가 완료되었습니다.' - : '실행 가능한 작업이 없습니다.'} - </div> - )} - - {/* 상태 새로고침 버튼 */} - <Button - variant="outline" - size="sm" - onClick={fetchWorkflowStatus} - disabled={isLoading} - className="w-full" - > - {isLoading ? ( - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - ) : ( - <RefreshCw className="w-4 h-4 mr-2" /> - )} - 상태 새로고침 - </Button> - </div> - </div> - </PopoverContent> - </Popover> - ) -}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/update-doc-sheet.tsx b/lib/vendor-document-list/ship/update-doc-sheet.tsx deleted file mode 100644 index 3e0ca225..00000000 --- a/lib/vendor-document-list/ship/update-doc-sheet.tsx +++ /dev/null @@ -1,267 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Save } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" -import { useRouter } from "next/navigation" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { modifyDocument } from "../service" - -// Document 수정을 위한 Zod 스키마 정의 -const updateDocumentSchema = z.object({ - docNumber: z.string().min(1, "Document number is required"), - title: z.string().min(1, "Title is required"), - status: z.string().min(1, "Status is required"), - description: z.string().optional(), - remarks: z.string().optional() -}); - -type UpdateDocumentSchema = z.infer<typeof updateDocumentSchema>; - -// 상태 옵션 정의 -const statusOptions = [ - "pending", - "in-progress", - "completed", - "rejected" -]; - -interface UpdateDocumentSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - document: { - id: number; - contractId: number; - docNumber: string; - title: string; - status: string; - description?: string | null; - remarks?: string | null; - } | null -} - -export function UpdateDocumentSheet({ document, ...props }: UpdateDocumentSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const router = useRouter() - - const form = useForm<UpdateDocumentSchema>({ - resolver: zodResolver(updateDocumentSchema), - defaultValues: { - docNumber: "", - title: "", - status: "", - description: "", - remarks: "", - }, - }) - - // 폼 초기화 (document가 변경될 때) - React.useEffect(() => { - if (document) { - form.reset({ - docNumber: document.docNumber, - title: document.title, - status: document.status, - description: document.description ?? "", - remarks: document.remarks ?? "", - }); - } - }, [document, form]); - - function onSubmit(input: UpdateDocumentSchema) { - startUpdateTransition(async () => { - if (!document) return - - const result = await modifyDocument({ - id: document.id, - contractId: document.contractId, - ...input, - }) - - if (!result.success) { - if ('error' in result) { - toast.error(result.error) - } else { - toast.error("Failed to update document") - } - return - } - - form.reset() - props.onOpenChange?.(false) - toast.success("Document updated successfully") - router.refresh() - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update Document</SheetTitle> - <SheetDescription> - Update the document details and save the changes - </SheetDescription> - </SheetHeader> - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - > - {/* 문서 번호 필드 */} - <FormField - control={form.control} - name="docNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>Document Number</FormLabel> - <FormControl> - <Input placeholder="Enter document number" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 문서 제목 필드 */} - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>Title</FormLabel> - <FormControl> - <Input placeholder="Enter document title" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 상태 필드 */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - value={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - {statusOptions.map((status) => ( - <SelectItem key={status} value={status}> - {status.charAt(0).toUpperCase() + status.slice(1)} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 설명 필드 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>Description</FormLabel> - <FormControl> - <Textarea - placeholder="Enter document description" - className="min-h-[80px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 필드 */} - <FormField - control={form.control} - name="remarks" - render={({ field }) => ( - <FormItem> - <FormLabel>Remarks</FormLabel> - <FormControl> - <Textarea - placeholder="Enter additional remarks" - className="min-h-[80px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button - type="button" - variant="outline" - onClick={() => form.reset()} - > - Cancel - </Button> - </SheetClose> - <Button disabled={isUpdatePending}> - {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - <Save className="mr-2 size-4" /> Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file |
