diff options
Diffstat (limited to 'lib/vendor-document-list/ship')
12 files changed, 4846 insertions, 0 deletions
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx new file mode 100644 index 00000000..b80c0869 --- /dev/null +++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx @@ -0,0 +1,427 @@ +// simplified-doc-table-columns.tsx +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + 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" + +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) + + 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> + ) +} + +export function getSimplifiedDocumentColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<SimplifiedDocumentsView>[] { + + // 기본 컬럼들 + const baseColumns: 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" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 문서번호 + Drawing Kind + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서번호" /> + ), + 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> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "문서번호" + }, + }, + + // 문서명 + 프로젝트/벤더 정보 + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서명" /> + ), + 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> + ) + }, + size: 200, + enableResizing: true, + meta: { + excelHeader: "문서명" + }, + }, + + // 첫 번째 스테이지 정보 + { + accessorKey: "firstStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="1차 스테이지" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <StageNameDisplay + stageName={doc.firstStageName} + drawingKind={doc.drawingKind} + isFirst={true} + /> + ) + }, + size: 130, + enableResizing: true, + meta: { + excelHeader: "1차 스테이지" + }, + }, + + // 첫 번째 스테이지 날짜 + { + accessorKey: "firstStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="1차 일정" /> + ), + cell: ({ row }) => { + const doc = row.original + 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} + /> + ) + }, + size: 130, + enableResizing: true, + meta: { + excelHeader: "2차 스테이지" + }, + }, + + // 두 번째 스테이지 날짜 + { + accessorKey: "secondStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="2차 일정" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <StageDateInfo + planDate={doc.secondStagePlanDate} + actualDate={doc.secondStageActualDate} + stageName={doc.secondStageName} + /> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "2차 일정" + }, + }, + + // 첨부파일 수 + { + accessorKey: "attachmentCount", + header: ({ column }) => ( + <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> + ) + }, + size: 80, + enableResizing: true, + meta: { + excelHeader: "첨부파일" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업데이트" /> + ), + cell: ({ cell }) => ( + <span className="text-sm text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 140, + enableResizing: true, + meta: { + excelHeader: "업데이트" + }, + }, + + // 액션 버튼 + { + 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 +}
\ 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 new file mode 100644 index 00000000..3960bbce --- /dev/null +++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx @@ -0,0 +1,147 @@ +"use client" +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Plus, Files, RefreshCw } from "lucide-react" +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 { SendToSHIButton } from "./send-to-shi-button" +import { ImportFromDOLCEButton } from "./import-from-dolce-button" +import { SWPWorkflowPanel } from "./swp-workflow-panel" + +interface EnhancedDocTableToolbarActionsProps { + table: Table<EnhancedDocument> + 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) + + // 현재 테이블의 모든 데이터 (필터링된 상태) + const allDocuments = table.getFilteredRowModel().rows.map(row => row.original) + + const handleSyncComplete = () => { + // 동기화 완료 후 테이블 새로고침 + table.resetRowSelection() + // 필요시 추가 액션 수행 + } + + const handleDocumentAdded = () => { + // 테이블 새로고침 + table.resetRowSelection() + + // 추가적인 새로고침 시도 + setTimeout(() => { + window.location.reload() // 강제 새로고침 + }, 500) + } + + const handleImportComplete = () => { + // 가져오기 완료 후 테이블 새로고침 + table.resetRowSelection() + setTimeout(() => { + window.location.reload() + }, 500) + } + + 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} + 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 + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */} + <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 new file mode 100644 index 00000000..88e342c8 --- /dev/null +++ b/lib/vendor-document-list/ship/enhanced-document-sheet.tsx @@ -0,0 +1,939 @@ +// 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 new file mode 100644 index 00000000..47bce275 --- /dev/null +++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx @@ -0,0 +1,238 @@ +// simplified-documents-table.tsx +"use client" + +import React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { getEnhancedDocumentsShip } from "../enhanced-document-service" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { toast } from "sonner" + +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" + +interface SimplifiedDocumentsTableProps { + promises: Promise<{ + data: SimplifiedDocumentsView[], + pageCount: number, + total: number + }> +} + +export function SimplifiedDocumentsTable({ + promises, +}: SimplifiedDocumentsTableProps) { + // React.use()로 Promise 결과를 받고, 그 다음에 destructuring + const result = React.use(promises) + const { data, pageCount, total } = result + + // 기존 상태들 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null) // ✅ 타입 변경 + const [expandedRows,] = React.useState<Set<string>>(new Set()) + + const columns = React.useMemo( + () => getSimplifiedDocumentColumns({ setRowAction }), + [setRowAction] + ) + + // ✅ SimplifiedDocumentsView에 맞게 필터 필드 업데이트 + const advancedFilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "vendorDocNumber", + label: "벤더 문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "drawingKind", + label: "문서종류", + type: "select", + options: [ + { label: "B3", value: "B3" }, + { label: "B4", value: "B4" }, + { label: "B5", value: "B5" }, + ], + }, + { + id: "projectCode", + label: "프로젝트 코드", + type: "text", + }, + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "pic", + label: "담당자", + type: "text", + }, + { + id: "status", + label: "문서 상태", + type: "select", + options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + { label: "보류", value: "PENDING" }, + { label: "완료", value: "COMPLETED" }, + ], + }, + { + id: "firstStageName", + label: "1차 스테이지", + type: "text", + }, + { + id: "secondStageName", + label: "2차 스테이지", + type: "text", + }, + { + id: "firstStagePlanDate", + label: "1차 계획일", + type: "date", + }, + { + id: "firstStageActualDate", + label: "1차 실제일", + type: "date", + }, + { + id: "secondStagePlanDate", + label: "2차 계획일", + type: "date", + }, + { + id: "secondStageActualDate", + label: "2차 실제일", + type: "date", + }, + { + id: "issuedDate", + label: "발행일", + type: "date", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + // ✅ B4 전용 필드들 (조건부로 추가) + const b4FilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = [ + { + id: "cGbn", + label: "C 구분", + type: "text", + }, + { + id: "dGbn", + label: "D 구분", + type: "text", + }, + { + id: "degreeGbn", + label: "Degree 구분", + type: "text", + }, + { + id: "deptGbn", + label: "Dept 구분", + type: "text", + }, + { + id: "jGbn", + label: "J 구분", + type: "text", + }, + { + id: "sGbn", + label: "S 구분", + type: "text", + }, + ] + + // B4 문서가 있는지 확인하여 B4 전용 필드 추가 + const hasB4Documents = data.some(doc => doc.drawingKind === 'B4') + const finalFilterFields = hasB4Documents + ? [...advancedFilterFields, ...b4FilterFields] + : advancedFilterFields + + const { table } = useDataTable({ + data: data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + 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]) + + 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> + </DataTableAdvancedToolbar> + </DataTable> + </div> + ) +} diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx new file mode 100644 index 00000000..519d40cb --- /dev/null +++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx @@ -0,0 +1,356 @@ +"use client" + +import * as React from "react" +import { RefreshCw, Download, Loader2, CheckCircle, AlertTriangle } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Separator } from "@/components/ui/separator" + +interface ImportFromDOLCEButtonProps { + contractId: number + onImportComplete?: () => void +} + +interface ImportStatus { + lastImportAt?: string + availableDocuments: number + newDocuments: number + updatedDocuments: number + importEnabled: boolean +} + +export function ImportFromDOLCEButton({ + contractId, + 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 [statusLoading, setStatusLoading] = React.useState(false) + + // DOLCE 상태 조회 + const fetchImportStatus = async () => { + setStatusLoading(true) + 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() + setImportStatus(status) + + // 프로젝트 코드가 없는 경우 에러 처리 + if (status.error) { + toast.error(`상태 확인 실패: ${status.error}`) + setImportStatus(null) + } + } catch (error) { + console.error('Failed to fetch import status:', error) + toast.error('DOLCE 상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.') + setImportStatus(null) + } finally { + setStatusLoading(false) + } + } + + // 컴포넌트 마운트 시 상태 조회 + React.useEffect(() => { + fetchImportStatus() + }, [contractId]) + + const handleImport = async () => { + if (!contractId) return + + setImportProgress(0) + setIsImporting(true) + + 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' + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || 'Import failed') + } + + const result = await response.json() + + clearInterval(progressInterval) + setImportProgress(100) + + setTimeout(() => { + setImportProgress(0) + setIsDialogOpen(false) + setIsImporting(false) + + if (result?.success) { + const { newCount = 0, updatedCount = 0, skippedCount = 0 } = result + toast.success( + `DOLCE 가져오기 완료`, + { + description: `신규 ${newCount}건, 업데이트 ${updatedCount}건, 건너뜀 ${skippedCount}건 (B3/B4/B5 포함)` + } + ) + } else { + toast.error( + `DOLCE 가져오기 부분 실패`, + { + description: result?.message || '일부 DrawingKind에서 가져오기에 실패했습니다.' + } + ) + } + + fetchImportStatus() // 상태 갱신 + onImportComplete?.() + }, 500) + + } catch (error) { + setImportProgress(0) + setIsImporting(false) + + toast.error('DOLCE 가져오기 실패', { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } + } + + const getStatusBadge = () => { + if (statusLoading) { + return <Badge variant="secondary">DOLCE 연결 확인 중...</Badge> + } + + if (!importStatus) { + return <Badge variant="destructive">DOLCE 연결 오류</Badge> + } + + if (!importStatus.importEnabled) { + return <Badge variant="secondary">DOLCE 가져오기 비활성화</Badge> + } + + if (importStatus.newDocuments > 0 || importStatus.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) + </Badge> + ) + } + + return ( + <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> + <CheckCircle className="w-3 h-3" /> + DOLCE와 동기화됨 + </Badge> + ) + } + + const canImport = importStatus?.importEnabled && + (importStatus?.newDocuments > 0 || importStatus?.updatedDocuments > 0) + + return ( + <> + <Popover> + <PopoverTrigger asChild> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + className="flex items-center border-blue-200 hover:bg-blue-50" + disabled={isImporting || statusLoading} + > + {isImporting ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Download className="w-4 h-4" /> + )} + <span className="hidden sm:inline">DOLCE에서 가져오기</span> + {importStatus && (importStatus.newDocuments > 0 || importStatus.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} + </Badge> + )} + </Button> + </div> + </PopoverTrigger> + + <PopoverContent className="w-80"> + <div className="space-y-4"> + <div className="space-y-2"> + <h4 className="font-medium">DOLCE 가져오기 상태</h4> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + {getStatusBadge()} + </div> + </div> + + {importStatus && ( + <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> + <div> + <div className="text-muted-foreground">업데이트</div> + <div className="font-medium">{importStatus.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> + + {importStatus.lastImportAt && ( + <div className="text-sm"> + <div className="text-muted-foreground">마지막 가져오기</div> + <div className="font-medium"> + {new Date(importStatus.lastImportAt).toLocaleString()} + </div> + </div> + )} + </div> + )} + + <Separator /> + + <div className="flex gap-2"> + <Button + onClick={() => setIsDialogOpen(true)} + disabled={!canImport || isImporting} + className="flex-1" + size="sm" + > + {isImporting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + <> + <Download className="w-4 h-4 mr-2" /> + 지금 가져오기 + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={fetchImportStatus} + disabled={statusLoading} + > + {statusLoading ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <RefreshCw className="w-4 h-4" /> + )} + </Button> + </div> + </div> + </PopoverContent> + </Popover> + + {/* 가져오기 진행 다이얼로그 */} + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>DOLCE에서 문서 목록 가져오기</DialogTitle> + <DialogDescription> + 삼성중공업 DOLCE 시스템에서 최신 문서 목록을 가져옵니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {importStatus && ( + <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)}건 + </span> + </div> + + <div className="text-xs text-muted-foreground"> + 신규 문서와 업데이트된 문서가 포함됩니다. (B3, B4, B5) + <br /> + B4 문서의 경우 GTTPreDwg, GTTWorkingDwg 이슈 스테이지가 자동 생성됩니다. + </div> + + {isImporting && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>진행률</span> + <span>{importProgress}%</span> + </div> + <Progress value={importProgress} className="h-2" /> + </div> + )} + </div> + )} + + <div className="flex justify-end gap-2"> + <Button + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={isImporting || !canImport} + > + {isImporting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + <> + <Download className="w-4 h-4 mr-2" /> + 가져오기 시작 + </> + )} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/revision-upload-dialog.tsx b/lib/vendor-document-list/ship/revision-upload-dialog.tsx new file mode 100644 index 00000000..16fc9fbb --- /dev/null +++ b/lib/vendor-document-list/ship/revision-upload-dialog.tsx @@ -0,0 +1,629 @@ +"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/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx new file mode 100644 index 00000000..1a27a794 --- /dev/null +++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx @@ -0,0 +1,348 @@ +// components/sync/send-to-shi-button.tsx (최종 버전) +"use client" + +import * as React from "react" +import { Send, Loader2, CheckCircle, AlertTriangle, Settings } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Separator } from "@/components/ui/separator" +import { useSyncStatus, useTriggerSync } from "@/hooks/use-sync-status" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +interface SendToSHIButtonProps { + contractId: number + documents?: EnhancedDocument[] + onSyncComplete?: () => void + projectType: "ship" | "plant" +} + +export function SendToSHIButton({ + contractId, + documents = [], + onSyncComplete, + projectType +}: SendToSHIButtonProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [syncProgress, setSyncProgress] = React.useState(0) + + const targetSystem = projectType === 'ship'?"DOLCE":"SWP" + + const { + syncStatus, + isLoading: statusLoading, + error: statusError, + refetch: refetchStatus + } = useSyncStatus(contractId, targetSystem) + + const { + triggerSync, + isLoading: isSyncing, + error: syncError + } = useTriggerSync() + + // 에러 상태 표시 + React.useEffect(() => { + if (statusError) { + console.warn('Failed to load sync status:', statusError) + } + }, [statusError]) + + const handleSync = async () => { + if (!contractId) return + + setSyncProgress(0) + + try { + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setSyncProgress(prev => Math.min(prev + 10, 90)) + }, 200) + + const result = await triggerSync({ + contractId, + targetSystem + }) + + clearInterval(progressInterval) + setSyncProgress(100) + + setTimeout(() => { + setSyncProgress(0) + setIsDialogOpen(false) + + if (result?.success) { + toast.success( + `동기화 완료: ${result.successCount || 0}건 성공`, + { + description: result.successCount > 0 + ? `${result.successCount}개 항목이 SHI 시스템으로 전송되었습니다.` + : '전송할 새로운 변경사항이 없습니다.' + } + ) + } else { + toast.error( + `동기화 부분 실패: ${result?.successCount || 0}건 성공, ${result?.failureCount || 0}건 실패`, + { + description: result?.errors?.[0] || '일부 항목 전송에 실패했습니다.' + } + ) + } + + refetchStatus() // SWR 캐시 갱신 + onSyncComplete?.() + }, 500) + + } catch (error) { + setSyncProgress(0) + + toast.error('동기화 실패', { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } + } + + const getSyncStatusBadge = () => { + if (statusLoading) { + return <Badge variant="secondary">확인 중...</Badge> + } + + if (statusError) { + return <Badge variant="destructive">오류</Badge> + } + + if (!syncStatus) { + return <Badge variant="secondary">데이터 없음</Badge> + } + + if (syncStatus.pendingChanges > 0) { + return ( + <Badge variant="destructive" className="gap-1"> + <AlertTriangle className="w-3 h-3" /> + {syncStatus.pendingChanges}건 대기 + </Badge> + ) + } + + if (syncStatus.syncedChanges > 0) { + return ( + <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> + <CheckCircle className="w-3 h-3" /> + 동기화됨 + </Badge> + ) + } + + return <Badge variant="secondary">변경사항 없음</Badge> + } + + const canSync = !statusError && syncStatus?.syncEnabled && syncStatus?.pendingChanges > 0 + + return ( + <> + <Popover> + <PopoverTrigger asChild> + <div className="flex items-center gap-3"> + <Button + variant="default" + size="sm" + className="flex items-center bg-blue-600 hover:bg-blue-700" + disabled={isSyncing || statusLoading} + > + {isSyncing ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Send className="w-4 h-4" /> + )} + <span className="hidden sm:inline">Send to SHI</span> + {syncStatus?.pendingChanges > 0 && ( + <Badge + variant="destructive" + className="h-5 w-5 p-0 text-xs flex items-center justify-center" + > + {syncStatus.pendingChanges} + </Badge> + )} + </Button> + </div> + </PopoverTrigger> + + <PopoverContent className="w-80"> + <div className="space-y-4"> + <div className="space-y-2"> + <h4 className="font-medium">SHI 동기화 상태</h4> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + {getSyncStatusBadge()} + </div> + </div> + + {syncStatus && !statusError && ( + <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">{syncStatus.pendingChanges || 0}건</div> + </div> + <div> + <div className="text-muted-foreground">동기화됨</div> + <div className="font-medium">{syncStatus.syncedChanges || 0}건</div> + </div> + </div> + + {syncStatus.failedChanges > 0 && ( + <div className="text-sm"> + <div className="text-muted-foreground">실패</div> + <div className="font-medium text-red-600">{syncStatus.failedChanges}건</div> + </div> + )} + + {syncStatus.lastSyncAt && ( + <div className="text-sm"> + <div className="text-muted-foreground">마지막 동기화</div> + <div className="font-medium"> + {new Date(syncStatus.lastSyncAt).toLocaleString()} + </div> + </div> + )} + </div> + )} + + {statusError && ( + <div className="space-y-2"> + <Separator /> + <div className="text-sm text-red-600"> + <div className="font-medium">연결 오류</div> + <div className="text-xs">동기화 상태를 확인할 수 없습니다.</div> + </div> + </div> + )} + + <Separator /> + + <div className="flex gap-2"> + <Button + onClick={() => setIsDialogOpen(true)} + disabled={!canSync || isSyncing} + className="flex-1" + size="sm" + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 지금 동기화 + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => refetchStatus()} + disabled={statusLoading} + > + {statusLoading ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Settings className="w-4 h-4" /> + )} + </Button> + </div> + </div> + </PopoverContent> + </Popover> + + {/* 동기화 진행 다이얼로그 */} + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>SHI 시스템으로 동기화</DialogTitle> + <DialogDescription> + 변경된 문서 데이터를 SHI 시스템으로 전송합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {syncStatus && !statusError && ( + <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">{syncStatus.pendingChanges || 0}건</span> + </div> + + <div className="text-xs text-muted-foreground"> + 문서, 리비전, 첨부파일의 변경사항이 포함됩니다. + </div> + + {isSyncing && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>진행률</span> + <span>{syncProgress}%</span> + </div> + <Progress value={syncProgress} className="h-2" /> + </div> + )} + </div> + )} + + {statusError && ( + <div className="rounded-lg border border-red-200 p-4"> + <div className="text-sm text-red-600"> + 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요. + </div> + </div> + )} + + <div className="flex justify-end gap-2"> + <Button + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isSyncing} + > + 취소 + </Button> + <Button + onClick={handleSync} + disabled={isSyncing || !canSync} + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 동기화 시작 + </> + )} + </Button> + </div> + </div> + </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 new file mode 100644 index 00000000..933df263 --- /dev/null +++ b/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx @@ -0,0 +1,287 @@ +"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 new file mode 100644 index 00000000..6b9cffb9 --- /dev/null +++ b/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx @@ -0,0 +1,752 @@ +"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 new file mode 100644 index 00000000..2cc22cce --- /dev/null +++ b/lib/vendor-document-list/ship/stage-revision-sheet.tsx @@ -0,0 +1,86 @@ +// 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 new file mode 100644 index 00000000..ded306e7 --- /dev/null +++ b/lib/vendor-document-list/ship/swp-workflow-panel.tsx @@ -0,0 +1,370 @@ +"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 new file mode 100644 index 00000000..3e0ca225 --- /dev/null +++ b/lib/vendor-document-list/ship/update-doc-sheet.tsx @@ -0,0 +1,267 @@ +"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 |
