summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/ship
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/ship')
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx427
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx147
-rw-r--r--lib/vendor-document-list/ship/enhanced-document-sheet.tsx939
-rw-r--r--lib/vendor-document-list/ship/enhanced-documents-table.tsx238
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx356
-rw-r--r--lib/vendor-document-list/ship/revision-upload-dialog.tsx629
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx348
-rw-r--r--lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx287
-rw-r--r--lib/vendor-document-list/ship/stage-revision-expanded-content.tsx752
-rw-r--r--lib/vendor-document-list/ship/stage-revision-sheet.tsx86
-rw-r--r--lib/vendor-document-list/ship/swp-workflow-panel.tsx370
-rw-r--r--lib/vendor-document-list/ship/update-doc-sheet.tsx267
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