diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
| commit | 2ecdac866c19abea0b5389708fcdf5b3889c969a (patch) | |
| tree | e02a02cfa0890691fb28a7df3a96ef495b3d4b79 /lib/swp/table | |
| parent | 2fc9e5492e220041ba322d9a1479feb7803228cf (diff) | |
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib/swp/table')
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 412 | ||||
| -rw-r--r-- | lib/swp/table/swp-help-dialog.tsx | 66 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 353 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 634 | ||||
| -rw-r--r-- | lib/swp/table/swp-table.tsx | 198 | ||||
| -rw-r--r-- | lib/swp/table/swp-upload-validation-dialog.tsx | 373 | ||||
| -rw-r--r-- | lib/swp/table/swp-uploaded-files-dialog.tsx | 358 |
7 files changed, 1591 insertions, 803 deletions
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx new file mode 100644 index 00000000..418ddea9 --- /dev/null +++ b/lib/swp/table/swp-document-detail-dialog.tsx @@ -0,0 +1,412 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + ChevronDown, + ChevronRight, + Download, + FileIcon, + XCircle, + AlertCircle, +} from "lucide-react"; +import { + fetchVendorDocumentDetail, + cancelVendorFile, + downloadVendorFile, +} from "@/lib/swp/vendor-actions"; +import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service"; +import { toast } from "sonner"; + +interface SwpDocumentDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + document: DocumentListItem | null; + projNo: string; + vendorCode: string; + userId: string; +} + +export function SwpDocumentDetailDialog({ + open, + onOpenChange, + document, + projNo, +}: SwpDocumentDetailDialogProps) { + const [detail, setDetail] = useState<DocumentDetail | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set()); + const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set()); + const [isAllExpanded, setIsAllExpanded] = useState(true); // 기본값 true + + // 문서 상세 로드 + useEffect(() => { + if (open && document) { + loadDocumentDetail(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, document?.DOC_NO]); + + const loadDocumentDetail = async () => { + if (!document) return; + + setIsLoading(true); + try { + const detailData = await fetchVendorDocumentDetail(projNo, document.DOC_NO); + setDetail(detailData); + + // 모든 리비전 자동 펼치기 + const allRevKeys = new Set<string>(); + const allActKeys = new Set<string>(); + + detailData.revisions.forEach((revision) => { + const revKey = `${revision.revNo}|${revision.revSeq}`; + allRevKeys.add(revKey); + + // 모든 액티비티도 자동 펼치기 + revision.activities.forEach((activity) => { + const actKey = `${revKey}|${activity.actvNo}`; + allActKeys.add(actKey); + }); + }); + + setExpandedRevisions(allRevKeys); + setExpandedActivities(allActKeys); + setIsAllExpanded(true); + } catch (error) { + console.error("문서 상세 조회 실패:", error); + toast.error("문서 상세 정보를 불러오는데 실패했습니다"); + } finally { + setIsLoading(false); + } + }; + + const toggleRevision = (revKey: string) => { + setExpandedRevisions((prev) => { + const newSet = new Set(prev); + if (newSet.has(revKey)) { + newSet.delete(revKey); + } else { + newSet.add(revKey); + } + return newSet; + }); + }; + + const toggleActivity = (actKey: string) => { + setExpandedActivities((prev) => { + const newSet = new Set(prev); + if (newSet.has(actKey)) { + newSet.delete(actKey); + } else { + newSet.add(actKey); + } + return newSet; + }); + }; + + // 일괄 열기/닫기 + const handleToggleAll = () => { + if (!detail) return; + + if (isAllExpanded) { + // 모두 닫기 + setExpandedRevisions(new Set()); + setExpandedActivities(new Set()); + setIsAllExpanded(false); + } else { + // 모두 열기 + const allRevKeys = new Set<string>(); + const allActKeys = new Set<string>(); + + detail.revisions.forEach((revision) => { + const revKey = `${revision.revNo}|${revision.revSeq}`; + allRevKeys.add(revKey); + + revision.activities.forEach((activity) => { + const actKey = `${revKey}|${activity.actvNo}`; + allActKeys.add(actKey); + }); + }); + + setExpandedRevisions(allRevKeys); + setExpandedActivities(allActKeys); + setIsAllExpanded(true); + } + }; + + const handleCancelFile = async (boxSeq: string, actvSeq: string, fileName: string) => { + try { + await cancelVendorFile(boxSeq, actvSeq); + toast.success(`파일 취소 완료: ${fileName}`); + + // 문서 상세 재로드 + await loadDocumentDetail(); + } catch (error) { + console.error("파일 취소 실패:", error); + toast.error("파일 취소에 실패했습니다"); + } + }; + + const handleDownloadFile = async (fileName: string, ownDocNo: string) => { + try { + toast.info("파일 다운로드 중..."); + const result = await downloadVendorFile(projNo, ownDocNo, fileName); + + if (!result.success || !result.data) { + toast.error(result.error || "파일 다운로드 실패"); + return; + } + + // Blob 생성 및 다운로드 + const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType }); + const url = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + link.href = url; + link.download = result.fileName || fileName; + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`파일 다운로드 완료: ${fileName}`); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>문서 상세</DialogTitle> + {document && ( + <DialogDescription> + {document.DOC_NO} - {document.DOC_TITLE} + </DialogDescription> + )} + </DialogHeader> + + {document && ( + <div className="space-y-4"> + {/* 문서 정보 */} + <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg"> + <div> + <span className="text-sm font-semibold">프로젝트:</span> + <div className="text-sm">{document.PROJ_NO}</div> + {document.PROJ_NM && ( + <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">패키지:</span> + <div className="text-sm">{document.PKG_NO || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">업체:</span> + <div className="text-sm">{document.CPY_NM || "-"}</div> + {document.VNDR_CD && ( + <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">최신 리비전:</span> + <div className="text-sm">{document.LTST_REV_NO || "-"}</div> + </div> + </div> + + {/* 리비전 및 액티비티 트리 */} + {isLoading ? ( + <div className="flex items-center justify-center p-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + <span className="ml-2">문서 상세 로딩 중...</span> + </div> + ) : detail && detail.revisions.length > 0 ? ( + <div className="space-y-2"> + {/* 일괄 열기/닫기 버튼 */} + <div className="flex justify-end"> + <Button + variant="outline" + size="sm" + onClick={handleToggleAll} + > + {isAllExpanded ? ( + <> + <ChevronDown className="h-4 w-4 mr-2" /> + 일괄 닫기 + </> + ) : ( + <> + <ChevronRight className="h-4 w-4 mr-2" /> + 일괄 열기 + </> + )} + </Button> + </div> + {detail.revisions.map((revision) => { + const revKey = `${revision.revNo}|${revision.revSeq}`; + const isRevExpanded = expandedRevisions.has(revKey); + + return ( + <div key={revKey} className="border rounded-lg"> + {/* 리비전 헤더 */} + <div + className="flex items-center justify-between p-3 bg-muted/50 cursor-pointer hover:bg-muted" + onClick={() => toggleRevision(revKey)} + > + <div className="flex items-center gap-3"> + {isRevExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <Badge variant="secondary" className="font-mono"> + REV {revision.revNo} + </Badge> + <Badge variant="outline" className={ + revision.stage === "IFC" ? "bg-green-100 text-green-800" : + revision.stage === "IFA" ? "bg-blue-100 text-blue-800" : + "bg-gray-100 text-gray-800" + }> + {revision.stage} + </Badge> + <span className="text-sm text-muted-foreground"> + {revision.activities.length}개 액티비티 / {revision.totalFiles}개 파일 + </span> + </div> + </div> + + {/* 액티비티 목록 */} + {isRevExpanded && ( + <div className="p-2 space-y-2"> + {revision.activities.map((activity) => { + const actKey = `${revKey}|${activity.actvNo}`; + const isActExpanded = expandedActivities.has(actKey); + + // Activity 타입에 따른 색상 + const activityColor = + activity.type === "Receive" ? "bg-blue-100 text-blue-800" : + activity.type === "Send" ? "bg-green-100 text-green-800" : + "bg-purple-100 text-purple-800"; + + return ( + <div key={actKey} className="border rounded-md"> + {/* 액티비티 헤더 */} + <div + className="flex items-center justify-between p-2 bg-muted/30 cursor-pointer hover:bg-muted/50" + onClick={() => toggleActivity(actKey)} + > + <div className="flex items-center gap-2"> + {isActExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <Badge variant="outline" className={activityColor}> + {activity.type} + </Badge> + <span className="text-xs text-muted-foreground font-mono"> + {activity.actvNo} + </span> + <span className="text-sm text-muted-foreground"> + {activity.toFrom} + </span> + <span className="text-xs text-muted-foreground"> + {activity.files.length}개 파일 + </span> + </div> + </div> + + {/* 파일 목록 */} + {isActExpanded && ( + <div className="p-2 space-y-1"> + {activity.files.map((file, idx) => ( + <div + key={idx} + className="flex items-center justify-between p-2 border rounded bg-background hover:bg-muted/30" + > + <div className="flex items-center gap-2 flex-1"> + <FileIcon className="h-4 w-4 text-blue-500" /> + <span className="text-sm font-mono">{file.fileNm}</span> + {file.fileSz && ( + <span className="text-xs text-muted-foreground"> + ({formatFileSize(file.fileSz)}) + </span> + )} + {file.stat && ( + <Badge variant="outline" className={ + file.stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : + file.stat === "SCW03" ? "bg-green-100 text-green-800" : + file.stat === "SCW09" ? "bg-gray-100 text-gray-800" : + "bg-gray-100 text-gray-800" + }> + {file.statNm || file.stat} + </Badge> + )} + </div> + <div className="flex items-center gap-1"> + {file.canCancel && file.boxSeq && file.actvSeq && ( + <Button + variant="outline" + size="sm" + onClick={() => handleCancelFile(file.boxSeq!, file.actvSeq!, file.fileNm)} + > + <XCircle className="h-4 w-4 mr-1" /> + 취소 + </Button> + )} + <Button + variant="outline" + size="sm" + onClick={() => handleDownloadFile(file.fileNm, document.DOC_NO)} + > + <Download className="h-4 w-4 mr-1" /> + 다운로드 + </Button> + </div> + </div> + ))} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + ) : ( + <div className="p-8 text-center text-muted-foreground"> + <AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" /> + <p>리비전 정보가 없습니다</p> + </div> + )} + </div> + )} + </DialogContent> + </Dialog> + ); +} + +function formatFileSize(sizeStr: string): string { + const bytes = parseInt(sizeStr, 10); + if (isNaN(bytes)) return sizeStr; + + const kb = bytes / 1024; + const mb = kb / 1024; + + return mb >= 1 ? `${mb.toFixed(2)} MB` : `${kb.toFixed(2)} KB`; +} + diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx index 6880a8c7..c6c5296b 100644 --- a/lib/swp/table/swp-help-dialog.tsx +++ b/lib/swp/table/swp-help-dialog.tsx @@ -21,7 +21,7 @@ export function SwpUploadHelpDialog() { 업로드 가이드 </Button> </DialogTrigger> - <DialogContent className="max-w-2xl" opacityControl={false}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle>파일 업로드 가이드</DialogTitle> <DialogDescription> @@ -34,10 +34,13 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <h3 className="text-sm font-semibold">파일명 형식</h3> <div className="rounded-lg bg-muted p-4 font-mono text-sm"> - [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] + [DOC_NO]_[REV_NO]_[STAGE].[확장자] </div> <p className="text-xs text-muted-foreground"> - ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다 + ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다 + </p> + <p className="text-xs text-muted-foreground"> + ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자]) </p> </div> @@ -47,7 +50,7 @@ export function SwpUploadHelpDialog() { <div className="flex items-center gap-3 rounded-lg border p-3"> <Badge variant="secondary" className="font-mono shrink-0"> - OWN_DOC_NO + DOC_NO </Badge> <div className="text-sm"> <span className="font-medium">벤더의 문서번호</span> @@ -77,11 +80,11 @@ export function SwpUploadHelpDialog() { <div className="flex items-center gap-3 rounded-lg border p-3"> <Badge variant="secondary" className="font-mono shrink-0"> - YYYYMMDDhhmmss + 파일명 </Badge> <div className="text-sm"> - <span className="font-medium">날짜 및 시간</span> - <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span> + <span className="font-medium">자유 파일명 (선택사항)</span> + <span className="text-muted-foreground"> - 문서를 식별할 수 있는 이름 (언더스코어 포함 가능, 생략 가능)</span> </div> </div> </div> @@ -92,13 +95,35 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> <code className="text-xs font-mono text-green-700 dark:text-green-300"> - VD-DOC-001_01_IFA_20250124143000.pdf + VD-DOC-001_01_IFA.pdf + </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 기본 형식 (파일명 생략) + </p> + </div> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + VD-DOC-001_01_IFA_drawing_final.pdf + </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 파일명 추가 (파일명에 언더스코어 포함 가능) + </p> + </div> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + TECH-SPEC-002_02_IFC.dwg </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 기본 형식 사용 + </p> </div> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> <code className="text-xs font-mono text-green-700 dark:text-green-300"> - TECH-SPEC-002_02_IFC_20250124150000.dwg + DOC-003_03_IFA_test_result_data.xlsx </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 파일명 추가 (여러 단어 조합 가능) + </p> </div> </div> </div> @@ -109,7 +134,7 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001-01-IFA-20250124.pdf + VD-DOC-001-01-IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> ✗ 언더스코어(_) 대신 하이픈(-) 사용 @@ -117,18 +142,26 @@ export function SwpUploadHelpDialog() { </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001_01_IFA.pdf + VD-DOC-001_01.pdf + </code> + <p className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ STAGE 정보 누락 (최소 3개 항목 필요) + </p> + </div> + <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> + <code className="text-xs font-mono text-red-700 dark:text-red-300"> + VD DOC 001_01_IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 날짜/시간 정보 누락 + ✗ 공백 포함 (언더스코어 사용 필요) </p> </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001_01_IFA_20250124.pdf + VD-DOC-001__IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 시간 정보 누락 (14자리가 아님) + ✗ REV_NO 비어있음 (빈 항목 불가) </p> </div> </div> @@ -140,7 +173,10 @@ export function SwpUploadHelpDialog() { ⚠️ 주의사항 </h3> <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside"> - <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li> + <li>파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다</li> + <li>DOC_NO는 현재 프로젝트에 할당된 문서번호여야 합니다</li> + <li>4번째 항목(파일명)은 선택사항으로 생략 가능합니다</li> + <li>업로드 날짜/시간은 시스템에서 자동으로 생성됩니다</li> <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li> <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li> </ul> diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx index 9aecea96..e6abd2a0 100644 --- a/lib/swp/table/swp-table-columns.tsx +++ b/lib/swp/table/swp-table-columns.tsx @@ -2,43 +2,26 @@ import { ColumnDef } from "@tanstack/react-table"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react"; -import { formatDistanceToNow } from "date-fns"; -import { ko } from "date-fns/locale"; -import type { SwpDocumentWithStats } from "../actions"; -import { useState } from "react"; -import { toast } from "sonner"; +import type { DocumentListItem } from "@/lib/swp/document-service"; -export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ +export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [ { id: "expander", header: () => null, - cell: () => { - return ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - > - <ChevronRight className="h-4 w-4" /> - </Button> - ); - }, + cell: () => null, size: 50, }, { accessorKey: "LTST_ACTV_STAT", - header: "상태 (최신 액티비티)", + header: "상태", cell: ({ row }) => { const status = row.original.LTST_ACTV_STAT; if (!status) return "-"; - // 상태에 따른 색상 설정 (필요에 따라 조정 가능) const color = - status === "Complete" ? "bg-green-100 text-green-800" : - status === "In Progress" ? "bg-blue-100 text-blue-800" : - status === "Pending" ? "bg-yellow-100 text-yellow-800" : + status.includes("Complete") ? "bg-green-100 text-green-800" : + status.includes("Progress") ? "bg-blue-100 text-blue-800" : + status.includes("Pending") || status.includes("Ready") ? "bg-yellow-100 text-yellow-800" : "bg-gray-100 text-gray-800"; return ( @@ -47,7 +30,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ </Badge> ); }, - size: 100, + size: 120, }, { accessorKey: "DOC_NO", @@ -61,7 +44,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ accessorKey: "DOC_TITLE", header: "문서제목", cell: ({ row }) => ( - <div className="max-w-md" title={row.original.DOC_TITLE}> + <div className="max-w-md truncate" title={row.original.DOC_TITLE}> {row.original.DOC_TITLE} </div> ), @@ -74,7 +57,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ <div> <div className="font-medium">{row.original.PROJ_NO}</div> {row.original.PROJ_NM && ( - <div className="text-xs text-muted-foreground max-w-[150px]"> + <div className="text-xs text-muted-foreground max-w-[150px] truncate"> {row.original.PROJ_NM} </div> )} @@ -127,325 +110,25 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ }, { accessorKey: "LTST_REV_NO", - header: "마지막 REV NO", + header: "최신 REV", cell: ({ row }) => row.original.LTST_REV_NO || "-", size: 80, }, { id: "stats", - header: "REV/파일", + header: "파일", cell: ({ row }) => ( <div className="text-center"> <div className="text-sm font-medium"> - {row.original.revision_count} / {row.original.file_count} + {row.original.fileCount}개 </div> + {row.original.standbyFileCount > 0 && ( + <div className="text-xs text-yellow-600"> + 대기중 {row.original.standbyFileCount} + </div> + )} </div> ), size: 100, }, - { - accessorKey: "last_synced_at", - header: "동기화", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.last_synced_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - size: 100, - }, -]; - -// ============================================================================ -// 리비전 컬럼 (서브 테이블용) -// ============================================================================ - -export interface RevisionRow { - id: number; - DOC_NO: string; - REV_NO: string; - STAGE: string; - ACTV_NO: string | null; - OFDC_NO: string | null; - sync_status: "synced" | "pending" | "error"; - last_synced_at: Date; - file_count: number; -} - -export const swpRevisionColumns: ColumnDef<RevisionRow>[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - return row.getCanExpand() ? ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 ml-8" - > - {row.getIsExpanded() ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> - )} - </Button> - ) : null; - }, - size: 100, - }, - { - accessorKey: "REV_NO", - header: "리비전", - cell: ({ row }) => ( - <Badge variant="secondary" className="font-mono"> - REV {row.original.REV_NO} - </Badge> - ), - size: 100, - }, - { - accessorKey: "STAGE", - header: "스테이지", - cell: ({ row }) => { - const stage = row.original.STAGE; - const color = - stage === "IFC" ? "bg-green-100 text-green-800" : - stage === "IFA" ? "bg-blue-100 text-blue-800" : - "bg-gray-100 text-gray-800"; - - return ( - <Badge variant="outline" className={color}> - {stage} - </Badge> - ); - }, - size: 100, - }, - { - accessorKey: "OFDC_NO", - header: "OFDC 번호", - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.original.OFDC_NO || "-"}</div> - ), - size: 200, - }, - { - accessorKey: "ACTV_NO", - header: "Activity", - cell: ({ row }) => ( - <div className="font-mono text-xs text-muted-foreground"> - {row.original.ACTV_NO || "-"} - </div> - ), - size: 250, - }, - { - id: "file_count", - header: "파일 수", - cell: ({ row }) => ( - <div className="flex items-center gap-2"> - <FileIcon className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{row.original.file_count}</span> - </div> - ), - size: 100, - }, - { - accessorKey: "last_synced_at", - header: "동기화", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.last_synced_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - size: 100, - }, -]; - -// ============================================================================ -// 파일 컬럼 (서브 서브 테이블용) -// ============================================================================ - -export interface FileRow { - id: number; - FILE_NM: string; - FILE_SEQ: string; - FILE_SZ: string | null; - FLD_PATH: string | null; - STAT: string | null; - STAT_NM: string | null; - sync_status: "synced" | "pending" | "error"; - created_at: Date; -} - -export const swpFileColumns: ColumnDef<FileRow>[] = [ - { - id: "spacer", - header: () => null, - cell: () => <div className="w-16" />, - size: 150, - }, - { - accessorKey: "FILE_SEQ", - header: "순서", - cell: ({ row }) => ( - <Badge variant="outline" className="font-mono"> - #{row.original.FILE_SEQ} - </Badge> - ), - size: 80, - }, - { - accessorKey: "FILE_NM", - header: "파일명", - cell: ({ row }) => ( - <div className="flex items-center gap-2"> - <FileIcon className="h-4 w-4 text-blue-500" /> - <span className="font-mono text-sm">{row.original.FILE_NM}</span> - </div> - ), - size: 400, - }, - { - accessorKey: "FILE_SZ", - header: "크기", - cell: ({ row }) => { - const size = row.original.FILE_SZ; - if (!size) return "-"; - - const bytes = parseInt(size, 10); - if (isNaN(bytes)) return size; - - const kb = bytes / 1024; - const mb = kb / 1024; - - return mb >= 1 - ? `${mb.toFixed(2)} MB` - : `${kb.toFixed(2)} KB`; - }, - size: 100, - }, - { - accessorKey: "STAT_NM", - header: "상태", - cell: ({ row }) => { - const status = row.original.STAT_NM; - if (!status) return "-"; - - const color = status === "Complete" - ? "bg-green-100 text-green-800" - : "bg-gray-100 text-gray-800"; - - return ( - <Badge variant="outline" className={color}> - {status} - </Badge> - ); - }, - size: 100, - }, - { - accessorKey: "FLD_PATH", - header: "경로", - cell: ({ row }) => ( - <div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]" title={row.original.FLD_PATH || ""}> - {row.original.FLD_PATH || "-"} - </div> - ), - size: 200, - }, - { - accessorKey: "created_at", - header: "생성일", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.created_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - size: 100, - }, - { - id: "actions", - header: "작업", - cell: ({ row }) => ( - <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} /> - ), - size: 120, - }, ]; - -// ============================================================================ -// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리 -// ============================================================================ - -interface DownloadButtonProps { - fileId: number; - fileName: string; -} - -function DownloadButton({ fileId, fileName }: DownloadButtonProps) { - const [isDownloading, setIsDownloading] = useState(false); - - const handleDownload = async () => { - try { - setIsDownloading(true); - - // API Route 호출 (바이너리 직접 전송) - const response = await fetch(`/api/swp/download/${fileId}`); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "다운로드 실패" })); - toast.error(errorData.error || "파일 다운로드 실패"); - return; - } - - // Blob 생성 및 다운로드 - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - - toast.success(`파일 다운로드 완료: ${fileName}`); - } catch (error) { - console.error("다운로드 오류:", error); - toast.error("파일 다운로드 중 오류가 발생했습니다."); - } finally { - setIsDownloading(false); - } - }; - - return ( - <Button - variant="outline" - size="sm" - onClick={handleDownload} - disabled={isDownloading} - > - {isDownloading ? ( - <> - <Loader2 className="h-4 w-4 mr-1 animate-spin" /> - 다운로드 중... - </> - ) : ( - <> - <Download className="h-4 w-4 mr-1" /> - 다운로드 - </> - )} - </Button> - ); -} - diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index fefff091..0fd29fd3 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition, useMemo } from "react"; +import { useState, useTransition, useMemo, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -9,94 +9,137 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; -import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react"; -import { syncSwpProjectAction, type SwpTableFilters } from "../actions"; +import { Search, X, Check, ChevronsUpDown, Upload, RefreshCw } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; -import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; -import { useRef } from "react"; import { SwpUploadHelpDialog } from "./swp-help-dialog"; import { SwpUploadResultDialog } from "./swp-upload-result-dialog"; +import { + SwpUploadValidationDialog, + validateFileName +} from "./swp-upload-validation-dialog"; +import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog"; + +interface SwpTableFilters { + docNo?: string; + docTitle?: string; + pkgNo?: string; + stage?: string; +} interface SwpTableToolbarProps { + projNo: string; filters: SwpTableFilters; + onProjNoChange: (projNo: string) => void; onFiltersChange: (filters: SwpTableFilters) => void; - projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>; - vendorCode?: string; // 벤더가 접속했을 때 고정할 벤더 코드 + onRefresh: () => void; + isRefreshing: boolean; + projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>; + vendorCode?: string; + droppedFiles?: File[]; + onFilesProcessed?: () => void; + documents?: Array<{ DOC_NO: string }>; // 업로드 권한 검증용 문서 목록 + userId?: string; // 파일 취소 시 필요 } export function SwpTableToolbar({ + projNo, filters, + onProjNoChange, onFiltersChange, + onRefresh, + isRefreshing, projects = [], vendorCode, + droppedFiles = [], + onFilesProcessed, + documents = [], + userId, }: SwpTableToolbarProps) { - const [isSyncing, startSync] = useTransition(); const [isUploading, startUpload] = useTransition(); - const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters); + const [localFilters, setLocalFilters] = useState(filters); const { toast } = useToast(); - const router = useRouter(); const [projectSearchOpen, setProjectSearchOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const fileInputRef = useRef<HTMLInputElement>(null); const [uploadResults, setUploadResults] = useState<Array<{ fileName: string; success: boolean; error?: string }>>([]); const [showResultDialog, setShowResultDialog] = useState(false); + + // 검증 다이얼로그 상태 + const [validationResults, setValidationResults] = useState<Array<{ + file: File; + valid: boolean; + parsed?: { + ownDocNo: string; + revNo: string; + stage: string; + fileName: string; + extension: string; + }; + error?: string; + }>>([]); + const [showValidationDialog, setShowValidationDialog] = useState(false); - // 동기화 핸들러 - const handleSync = () => { - const projectNo = localFilters.projNo; - - if (!projectNo) { - toast({ - variant: "destructive", - title: "프로젝트 선택 필요", - description: "동기화할 프로젝트를 먼저 선택해주세요.", - }); - return; - } + /** + * 업로드 가능한 문서번호 목록 추출 + */ + const availableDocNos = useMemo(() => { + return documents.map(doc => doc.DOC_NO); + }, [documents]); - startSync(async () => { - try { + /** + * 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드) + */ + const isVendorMode = !!vendorCode; + + /** + * 드롭된 파일 처리 - useEffect로 감지하여 자동 검증 + */ + useEffect(() => { + if (droppedFiles.length > 0) { + // 프로젝트와 벤더 코드 검증 + if (!projNo) { toast({ - title: "동기화 시작", - description: `프로젝트 ${projectNo} 동기화를 시작합니다...`, + variant: "destructive", + title: "프로젝트 선택 필요", + description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.", }); + onFilesProcessed?.(); + return; + } - const result = await syncSwpProjectAction(projectNo, "V"); - - if (result.success) { - toast({ - title: "동기화 완료", - description: `문서 ${result.stats.documents.total}개, 파일 ${result.stats.files.total}개 동기화 완료`, - }); - - // 페이지 새로고침 - router.refresh(); - } else { - throw new Error(result.errors.join(", ")); - } - } catch (error) { - console.error("동기화 실패:", error); + if (!vendorCode) { toast({ variant: "destructive", - title: "동기화 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류", + title: "업체 코드 오류", + description: "벤더 정보를 가져올 수 없습니다.", }); + onFilesProcessed?.(); + return; } - }); - }; + + // 파일명 검증 (문서번호 권한 포함) + const results = droppedFiles.map((file) => { + const validation = validateFileName(file.name, availableDocNos, isVendorMode); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + setValidationResults(results); + setShowValidationDialog(true); + onFilesProcessed?.(); + } + }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode]); /** * 파일 업로드 핸들러 - * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기 - * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리 - */ + */ const handleUploadFiles = () => { - // 프로젝트와 벤더 코드 체크 - const projectNo = localFilters.projNo; - const vndrCd = vendorCode || localFilters.vndrCd; - - if (!projectNo) { + if (!projNo) { toast({ variant: "destructive", title: "프로젝트 선택 필요", @@ -105,48 +148,66 @@ export function SwpTableToolbar({ return; } - if (!vndrCd) { + if (!vendorCode) { toast({ variant: "destructive", - title: "업체 코드 입력 필요", - description: "파일을 업로드할 업체 코드를 입력해주세요.", + title: "업체 코드 오류", + description: "벤더 정보를 가져올 수 없습니다.", }); return; } - // 파일 선택 다이얼로그 열기 fileInputRef.current?.click(); }; /** - * 파일 선택 핸들러 + * 파일 선택 핸들러 - 검증만 수행 */ - const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const selectedFiles = event.target.files; if (!selectedFiles || selectedFiles.length === 0) { return; } - const projectNo = localFilters.projNo!; - const vndrCd = vendorCode || localFilters.vndrCd!; + // 각 파일의 파일명 검증 (문서번호 권한 포함) + const results = Array.from(selectedFiles).map((file) => { + const validation = validateFileName(file.name, availableDocNos, isVendorMode); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + setValidationResults(results); + setShowValidationDialog(true); + + // input 초기화 (같은 파일 재선택 가능하도록) + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + /** + * 검증 완료 후 실제 업로드 실행 + */ + const handleConfirmUpload = async (validFiles: File[]) => { startUpload(async () => { try { toast({ title: "파일 업로드 시작", - description: `${selectedFiles.length}개 파일을 업로드합니다...`, + description: `${validFiles.length}개 파일을 업로드합니다...`, }); - // FormData 생성 (바이너리 직접 전송) const formData = new FormData(); - formData.append("projNo", projectNo); - formData.append("vndrCd", vndrCd); - - Array.from(selectedFiles).forEach((file) => { + formData.append("projNo", projNo); + formData.append("vndrCd", vendorCode!); + + validFiles.forEach((file) => { formData.append("files", file); }); - // API Route 호출 const response = await fetch("/api/swp/upload", { method: "POST", body: formData, @@ -158,31 +219,31 @@ export function SwpTableToolbar({ const result = await response.json(); - // 결과 저장 및 다이얼로그 표시 + // 검증 다이얼로그 닫기 + setShowValidationDialog(false); + + // 결과 다이얼로그 표시 setUploadResults(result.details || []); setShowResultDialog(true); - // 성공한 파일이 있으면 페이지 새로고침 - if (result.successCount > 0) { - router.refresh(); - } + toast({ + title: result.success ? "업로드 완료" : "일부 업로드 실패", + description: result.message, + }); } catch (error) { console.error("파일 업로드 실패:", error); - - // 예외 발생 시에도 결과 다이얼로그 표시 - const errorResults = Array.from(selectedFiles).map((file) => ({ + + // 검증 다이얼로그 닫기 + setShowValidationDialog(false); + + const errorResults = validFiles.map((file) => ({ fileName: file.name, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", })); - + setUploadResults(errorResults); setShowResultDialog(true); - } finally { - // 파일 입력 초기화 - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } } }); }; @@ -194,7 +255,12 @@ export function SwpTableToolbar({ // 검색 초기화 const handleReset = () => { - const resetFilters: SwpTableFilters = {}; + const resetFilters: SwpTableFilters = { + docNo: "", + docTitle: "", + pkgNo: "", + stage: "", + }; setLocalFilters(resetFilters); onFiltersChange(resetFilters); }; @@ -202,17 +268,28 @@ export function SwpTableToolbar({ // 프로젝트 필터링 const filteredProjects = useMemo(() => { if (!projectSearch) return projects; - + const search = projectSearch.toLowerCase(); return projects.filter( (proj) => proj.PROJ_NO.toLowerCase().includes(search) || - proj.PROJ_NM.toLowerCase().includes(search) + (proj.PROJ_NM?.toLowerCase().includes(search) ?? false) ); }, [projects, projectSearch]); return ( <> + {/* 업로드 검증 다이얼로그 */} + <SwpUploadValidationDialog + open={showValidationDialog} + onOpenChange={setShowValidationDialog} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + availableDocNos={availableDocNos} + isVendorMode={isVendorMode} + /> + {/* 업로드 결과 다이얼로그 */} <SwpUploadResultDialog open={showResultDialog} @@ -220,240 +297,205 @@ export function SwpTableToolbar({ results={uploadResults} /> - <div className="space-y-4"> + <div className="space-y-4 w-full"> {/* 상단 액션 바 */} - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> + {vendorCode && ( + <div className="flex items-center justify-end gap-2"> + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> <Button - onClick={handleSync} - disabled={isSyncing || !localFilters.projNo} + variant="outline" size="sm" + onClick={onRefresh} + disabled={isRefreshing || !projNo} > - <RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? "animate-spin" : ""}`} /> - {isSyncing ? "동기화 중..." : "SWP 동기화"} + <RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} /> + 새로고침 </Button> - </div> - - <div className="flex items-center gap-2"> - {/* 벤더만 파일 업로드 기능 사용 가능 */} - {vendorCode && ( - <> - <input - ref={fileInputRef} - type="file" - multiple - className="hidden" - onChange={handleFileChange} - accept="*/*" - /> - <Button - variant="outline" - size="sm" - onClick={handleUploadFiles} - disabled={isUploading || !localFilters.projNo || (!vendorCode && !localFilters.vndrCd)} - > - <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} /> - {isUploading ? "업로드 중..." : "파일 업로드"} - </Button> - - <SwpUploadHelpDialog /> - </> + <Button + variant="outline" + size="sm" + onClick={handleUploadFiles} + disabled={isUploading || !projNo} + > + <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} /> + {isUploading ? "업로드 중..." : "파일 업로드"} + </Button> + + {userId && ( + <SwpUploadedFilesDialog + projNo={projNo} + vndrCd={vendorCode} + userId={userId} + /> )} + + <SwpUploadHelpDialog /> </div> - </div> + )} - {/* 검색 필터 */} - <div className="rounded-lg border p-4 space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="text-sm font-semibold">검색 필터</h3> - <Button - variant="ghost" - size="sm" - onClick={handleReset} - className="h-8" - > - <X className="h-4 w-4 mr-1" /> - 초기화 - </Button> - </div> + {/* 검색 필터 */} + <div className="rounded-lg border p-4 space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-semibold">검색 필터</h3> + <Button + variant="ghost" + size="sm" + onClick={handleReset} + className="h-8" + > + <X className="h-4 w-4 mr-1" /> + 초기화 + </Button> + </div> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - {/* 프로젝트 번호 */} - <div className="space-y-2"> - <Label htmlFor="projNo">프로젝트 번호</Label> - {projects.length > 0 ? ( - <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={projectSearchOpen} - className="w-full justify-between" - > - {localFilters.projNo ? ( - <span> - {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NO || localFilters.projNo} - {" ["} - {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NM} - {"]"} - </span> - ) : ( - <span className="text-muted-foreground">프로젝트 선택</span> - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0" align="start"> - <div className="p-2"> - <div className="flex items-center border rounded-md px-3"> - <Search className="h-4 w-4 mr-2 opacity-50" /> - <Input - placeholder="프로젝트 번호 또는 이름으로 검색..." - value={projectSearch} - onChange={(e) => setProjectSearch(e.target.value)} - className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" - /> - </div> - </div> - <div className="max-h-[300px] overflow-y-auto"> - <div className="p-1"> - <Button - variant="ghost" - className="w-full justify-start font-normal" - onClick={() => { - setLocalFilters({ ...localFilters, projNo: undefined }); - setProjectSearchOpen(false); - setProjectSearch(""); - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - !localFilters.projNo ? "opacity-100" : "opacity-0" - )} - /> - 전체 - </Button> - {filteredProjects.map((proj) => ( - <Button - key={proj.PROJ_NO} - variant="ghost" - className="w-full justify-start font-normal" - onClick={() => { - setLocalFilters({ ...localFilters, projNo: proj.PROJ_NO }); - setProjectSearchOpen(false); - setProjectSearch(""); - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - localFilters.projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0" - )} - /> - <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM}]</span> - </Button> - ))} - {filteredProjects.length === 0 && ( - <div className="py-6 text-center text-sm text-muted-foreground"> - 검색 결과가 없습니다. - </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {/* 프로젝트 번호 */} + <div className="space-y-2"> + <Label htmlFor="projNo">프로젝트 번호</Label> + {projects.length > 0 ? ( + <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={projectSearchOpen} + className="w-full justify-between" + > + {projNo ? ( + <span> + {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NO || projNo} + {" ["} + {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NM} + {"]"} + </span> + ) : ( + <span className="text-muted-foreground">프로젝트 선택</span> )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0" align="start"> + <div className="p-2"> + <div className="flex items-center border rounded-md px-3"> + <Search className="h-4 w-4 mr-2 opacity-50" /> + <Input + placeholder="프로젝트 번호 또는 이름으로 검색..." + value={projectSearch} + onChange={(e) => setProjectSearch(e.target.value)} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> + </div> + </div> + <div className="max-h-[300px] overflow-y-auto"> + <div className="p-1"> + {filteredProjects.map((proj) => ( + <Button + key={proj.PROJ_NO} + variant="ghost" + className="w-full justify-start font-normal" + onClick={() => { + onProjNoChange(proj.PROJ_NO); + setProjectSearchOpen(false); + setProjectSearch(""); + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0" + )} + /> + <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM || ""}]</span> + </Button> + ))} + {filteredProjects.length === 0 && ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 검색 결과가 없습니다. + </div> + )} + </div> </div> - </div> - </PopoverContent> - </Popover> - ) : ( + </PopoverContent> + </Popover> + ) : ( + <Input + id="projNo" + placeholder="계약된 프로젝트가 없습니다" + value={projNo} + disabled + className="bg-muted" + /> + )} + </div> + + {/* 문서 번호 */} + <div className="space-y-2"> + <Label htmlFor="docNo">문서 번호</Label> <Input - id="projNo" - placeholder="계약된 프로젝트가 없습니다" - value={localFilters.projNo || ""} + id="docNo" + placeholder="문서 번호 검색" + value={localFilters.docNo || ""} onChange={(e) => - setLocalFilters({ ...localFilters, projNo: e.target.value }) + setLocalFilters({ ...localFilters, docNo: e.target.value }) } - disabled - className="bg-muted" /> - )} - </div> - - {/* 문서 번호 */} - <div className="space-y-2"> - <Label htmlFor="docNo">문서 번호</Label> - <Input - id="docNo" - placeholder="문서 번호 검색" - value={localFilters.docNo || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, docNo: e.target.value }) - } - /> - </div> + </div> - {/* 문서 제목 */} - <div className="space-y-2"> - <Label htmlFor="docTitle">문서 제목</Label> - <Input - id="docTitle" - placeholder="제목 검색" - value={localFilters.docTitle || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, docTitle: e.target.value }) - } - /> - </div> + {/* 문서 제목 */} + <div className="space-y-2"> + <Label htmlFor="docTitle">문서 제목</Label> + <Input + id="docTitle" + placeholder="제목 검색" + value={localFilters.docTitle || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, docTitle: e.target.value }) + } + /> + </div> - {/* 패키지 번호 */} - <div className="space-y-2"> - <Label htmlFor="pkgNo">패키지</Label> - <Input - id="pkgNo" - placeholder="패키지 번호" - value={localFilters.pkgNo || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, pkgNo: e.target.value }) - } - /> - </div> + {/* 패키지 번호 */} + <div className="space-y-2"> + <Label htmlFor="pkgNo">패키지</Label> + <Input + id="pkgNo" + placeholder="패키지 번호" + value={localFilters.pkgNo || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, pkgNo: e.target.value }) + } + /> + </div> - {/* 업체 코드 */} - <div className="space-y-2"> - <Label htmlFor="vndrCd">업체 코드</Label> - <Input - id="vndrCd" - placeholder="업체 코드" - value={vendorCode || localFilters.vndrCd || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, vndrCd: e.target.value }) - } - disabled={!!vendorCode} // 벤더 코드가 제공되면 입력 비활성화 - className={vendorCode ? "bg-muted" : ""} - /> + {/* 스테이지 */} + <div className="space-y-2"> + <Label htmlFor="stage">스테이지</Label> + <Input + id="stage" + placeholder="스테이지 입력 (예: IFC, IFA)" + value={localFilters.stage || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, stage: e.target.value }) + } + /> + </div> </div> - {/* 스테이지 */} - <div className="space-y-2"> - <Label htmlFor="stage">스테이지</Label> - <Input - id="stage" - placeholder="스테이지 입력" - value={localFilters.stage || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, stage: e.target.value }) - } - /> + <div className="flex justify-end"> + <Button onClick={handleSearch} size="sm"> + <Search className="h-4 w-4 mr-2" /> + 검색 + </Button> </div> </div> - - <div className="flex justify-end"> - <Button onClick={handleSearch} size="sm"> - <Search className="h-4 w-4 mr-2" /> - 검색 - </Button> - </div> - </div> </div> </> ); } - diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx index 47c9905a..7918c07e 100644 --- a/lib/swp/table/swp-table.tsx +++ b/lib/swp/table/swp-table.tsx @@ -4,9 +4,7 @@ import React, { useState } from "react"; import { useReactTable, getCoreRowModel, - getExpandedRowModel, flexRender, - ExpandedState, } from "@tanstack/react-table"; import { Table, @@ -17,116 +15,37 @@ import { TableRow, } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; -import { swpDocumentColumns, type RevisionRow, type FileRow } from "./swp-table-columns"; -import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions"; -import { SwpRevisionListDialog } from "./swp-revision-list-dialog"; +import { ChevronRight } from "lucide-react"; +import { swpDocumentColumns } from "./swp-table-columns"; +import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog"; +import type { DocumentListItem } from "@/lib/swp/document-service"; interface SwpTableProps { - initialData: SwpDocumentWithStats[]; - total: number; - page: number; - pageSize: number; - totalPages: number; - onPageChange: (page: number) => void; + documents: DocumentListItem[]; + projNo: string; + vendorCode: string; + userId: string; } export function SwpTable({ - initialData, - total, - page, - pageSize, - totalPages, - onPageChange, + documents, + projNo, + vendorCode, + userId, }: SwpTableProps) { - const [expanded, setExpanded] = useState<ExpandedState>({}); - const [revisionData, setRevisionData] = useState<Record<string, RevisionRow[]>>({}); - const [fileData, setFileData] = useState<Record<number, FileRow[]>>({}); - const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set()); - const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set()); const [dialogOpen, setDialogOpen] = useState(false); - const [selectedDocument, setSelectedDocument] = useState<SwpDocumentWithStats | null>(null); + const [selectedDocument, setSelectedDocument] = useState<DocumentListItem | null>(null); const table = useReactTable({ - data: initialData, + data: documents, columns: swpDocumentColumns, - state: { - expanded, - }, - onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), - getRowCanExpand: () => true, // 모든 문서는 확장 가능 }); - // 리비전 로드 - const loadRevisions = async (docNo: string) => { - if (revisionData[docNo]) return; // 이미 로드됨 - - setLoadingRevisions((prev) => { - const newSet = new Set(prev); - newSet.add(docNo); - return newSet; - }); - - try { - const revisions = await fetchDocumentRevisions(docNo); - setRevisionData((prev) => ({ ...prev, [docNo]: revisions })); - } catch (error) { - console.error("리비전 로드 실패:", error); - } finally { - setLoadingRevisions((prev) => { - const next = new Set(prev); - next.delete(docNo); - return next; - }); - } - }; - - // 파일 로드 - const loadFiles = async (revisionId: number) => { - if (fileData[revisionId]) return; // 이미 로드됨 - - setLoadingFiles((prev) => { - const newSet = new Set(prev); - newSet.add(revisionId); - return newSet; - }); - - try { - const files = await fetchRevisionFiles(revisionId); - setFileData((prev) => ({ ...prev, [revisionId]: files })); - } catch (error) { - console.error("파일 로드 실패:", error); - } finally { - setLoadingFiles((prev) => { - const next = new Set(prev); - next.delete(revisionId); - return next; - }); - } - }; - // 문서 클릭 핸들러 - Dialog 열기 - const handleDocumentClick = async (document: SwpDocumentWithStats) => { + const handleDocumentClick = (document: DocumentListItem) => { setSelectedDocument(document); setDialogOpen(true); - - // 리비전 데이터 로드 - if (!revisionData[document.DOC_NO]) { - await loadRevisions(document.DOC_NO); - } - }; - - // 모든 리비전의 파일을 로드 - const loadAllFiles = async (docNo: string) => { - const revisions = revisionData[docNo]; - if (!revisions) return; - - for (const revision of revisions) { - if (!fileData[revision.id]) { - await loadFiles(revision.id); - } - } }; return ( @@ -153,31 +72,28 @@ export function SwpTable({ <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - <React.Fragment key={row.id}> - {/* 문서 행 */} - <TableRow - data-state={row.getIsSelected() && "selected"} - className="hover:bg-muted/50" - > - {row.getVisibleCells().map((cell) => ( - <TableCell key={cell.id}> - {cell.column.id === "expander" ? ( - <div - onClick={() => handleDocumentClick(row.original)} - className="cursor-pointer" - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - </div> - ) : ( - flexRender(cell.column.columnDef.cell, cell.getContext()) - )} - </TableCell> - ))} - </TableRow> - </React.Fragment> + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + className="hover:bg-muted/50 cursor-pointer" + onClick={() => handleDocumentClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {cell.column.id === "expander" ? ( + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + > + <ChevronRight className="h-4 w-4" /> + </Button> + ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} + </TableCell> + ))} + </TableRow> )) ) : ( <TableRow> @@ -190,46 +106,14 @@ export function SwpTable({ </Table> </div> - {/* 페이지네이션 */} - <div className="flex items-center justify-between"> - <div className="text-sm text-muted-foreground"> - 총 {total}개 중 {(page - 1) * pageSize + 1}- - {Math.min(page * pageSize, total)}개 표시 - </div> - <div className="flex items-center space-x-2"> - <Button - variant="outline" - size="sm" - onClick={() => onPageChange(page - 1)} - disabled={page === 1} - > - 이전 - </Button> - <div className="text-sm"> - {page} / {totalPages} - </div> - <Button - variant="outline" - size="sm" - onClick={() => onPageChange(page + 1)} - disabled={page === totalPages} - > - 다음 - </Button> - </div> - </div> - {/* 문서 상세 Dialog */} - <SwpRevisionListDialog + <SwpDocumentDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} document={selectedDocument} - revisions={selectedDocument ? revisionData[selectedDocument.DOC_NO] || [] : []} - fileData={fileData} - loadingRevisions={selectedDocument ? loadingRevisions.has(selectedDocument.DOC_NO) : false} - loadingFiles={loadingFiles} - onLoadFiles={loadFiles} - onLoadAllFiles={() => selectedDocument && loadAllFiles(selectedDocument.DOC_NO)} + projNo={projNo} + vendorCode={vendorCode} + userId={userId} /> </div> ); diff --git a/lib/swp/table/swp-upload-validation-dialog.tsx b/lib/swp/table/swp-upload-validation-dialog.tsx new file mode 100644 index 00000000..2d17e041 --- /dev/null +++ b/lib/swp/table/swp-upload-validation-dialog.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, XCircle, AlertCircle, Upload } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface FileValidationResult { + file: File; + valid: boolean; + parsed?: { + ownDocNo: string; + revNo: string; + stage: string; + fileName: string; + extension: string; + }; + error?: string; +} + +interface SwpUploadValidationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + validationResults: FileValidationResult[]; + onConfirmUpload: (validFiles: File[]) => void; + isUploading: boolean; + availableDocNos?: string[]; // 업로드 가능한 문서번호 목록 + isVendorMode?: boolean; // 벤더 모드인지 여부 (문서번호 검증 필수) +} + +/** + * 파일명 검증 함수 (클라이언트 사이드) + * 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자] + * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음 + * @param fileName 검증할 파일명 + * @param availableDocNos 업로드 가능한 문서번호 목록 (선택) + * @param isVendorMode 벤더 모드인지 여부 (true인 경우 문서번호 검증 필수) + */ +export function validateFileName( + fileName: string, + availableDocNos?: string[], + isVendorMode?: boolean +): { + valid: boolean; + parsed?: { + ownDocNo: string; + revNo: string; + stage: string; + fileName: string; + extension: string; + }; + error?: string; +} { + try { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex === -1) { + return { + valid: false, + error: "파일 확장자가 없습니다", + }; + } + + const extension = fileName.substring(lastDotIndex + 1); + const nameWithoutExt = fileName.substring(0, lastDotIndex); + + // 언더스코어로 분리 + const parts = nameWithoutExt.split("_"); + + // 최소 3개 파트 필요: docNo, revNo, stage (fileName은 선택사항) + if (parts.length < 3) { + return { + valid: false, + error: `언더스코어(_)가 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자]`, + }; + } + + // 앞에서부터 3개는 고정: docNo, revNo, stage + const ownDocNo = parts[0]; + const revNo = parts[1]; + const stage = parts[2]; + + // 나머지는 자유 파일명 (선택사항, 언더스코어 포함 가능) + const customFileName = parts.length > 3 ? parts.slice(3).join("_") : ""; + + // 필수 항목이 비어있지 않은지 확인 + if (!ownDocNo || ownDocNo.trim() === "") { + return { + valid: false, + error: "문서번호(DOC_NO)가 비어있습니다", + }; + } + + if (!revNo || revNo.trim() === "") { + return { + valid: false, + error: "리비전 번호(REV_NO)가 비어있습니다", + }; + } + + if (!stage || stage.trim() === "") { + return { + valid: false, + error: "스테이지(STAGE)가 비어있습니다", + }; + } + + // 문서번호 검증 (벤더 모드에서는 필수) + if (isVendorMode) { + const trimmedDocNo = ownDocNo.trim(); + + // 벤더 모드에서 문서 목록이 비어있으면 에러 + if (!availableDocNos || availableDocNos.length === 0) { + return { + valid: false, + error: "할당된 문서가 없거나 문서 목록 로드에 실패했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.", + }; + } + + // 문서번호가 목록에 없으면 에러 + if (!availableDocNos.includes(trimmedDocNo)) { + return { + valid: false, + error: `문서번호 '${trimmedDocNo}'는 업로드 권한이 없습니다. 할당된 문서번호를 확인해주세요.`, + }; + } + } + + return { + valid: true, + parsed: { + ownDocNo: ownDocNo.trim(), + revNo: revNo.trim(), + stage: stage.trim(), + fileName: customFileName.trim(), + extension, + }, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + +/** + * 업로드 전 파일 검증 다이얼로그 + */ +export function SwpUploadValidationDialog({ + open, + onOpenChange, + validationResults, + onConfirmUpload, + isUploading, + availableDocNos = [], + isVendorMode = false, +}: SwpUploadValidationDialogProps) { + const validFiles = validationResults.filter((r) => r.valid); + const invalidFiles = validationResults.filter((r) => !r.valid); + + const handleUpload = () => { + if (validFiles.length > 0) { + onConfirmUpload(validFiles.map((r) => r.file)); + } + }; + + const handleCancel = () => { + if (!isUploading) { + onOpenChange(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>파일 업로드 검증</DialogTitle> + <DialogDescription> + 선택한 파일의 파일명 형식을 검증합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 요약 통계 */} + <div className="grid grid-cols-3 gap-4"> + <div className="rounded-lg border p-3"> + <div className="text-sm text-muted-foreground">전체 파일</div> + <div className="text-2xl font-bold">{validationResults.length}</div> + </div> + <div className="rounded-lg border p-3 bg-green-50 dark:bg-green-950/30"> + <div className="text-sm text-green-600 dark:text-green-400">검증 성공</div> + <div className="text-2xl font-bold text-green-600 dark:text-green-400"> + {validFiles.length} + </div> + </div> + <div className="rounded-lg border p-3 bg-red-50 dark:bg-red-950/30"> + <div className="text-sm text-red-600 dark:text-red-400">검증 실패</div> + <div className="text-2xl font-bold text-red-600 dark:text-red-400"> + {invalidFiles.length} + </div> + </div> + </div> + + {/* 경고 메시지 */} + {invalidFiles.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {invalidFiles.length}개 파일의 파일명 형식이 올바르지 않습니다. + 검증에 성공한 {validFiles.length}개 파일만 업로드됩니다. + </AlertDescription> + </Alert> + )} + + {validFiles.length === 0 && ( + <Alert variant="destructive"> + <XCircle className="h-4 w-4" /> + <AlertDescription> + 업로드 가능한 파일이 없습니다. 파일명 형식을 확인해주세요. + </AlertDescription> + </Alert> + )} + + {/* 파일 목록 */} + <ScrollArea className="h-[300px] rounded-md border p-4"> + <div className="space-y-3"> + {/* 검증 성공 파일 */} + {validFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-semibold text-green-600 dark:text-green-400 flex items-center gap-2"> + <CheckCircle2 className="h-4 w-4" /> + 검증 성공 ({validFiles.length}개) + </h4> + {validFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.parsed && ( + <div className="flex flex-wrap gap-1 mt-2"> + <Badge variant="outline" className="text-xs"> + 문서: {result.parsed.ownDocNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Rev: {result.parsed.revNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Stage: {result.parsed.stage} + </Badge> + {result.parsed.fileName && ( + <Badge variant="outline" className="text-xs"> + 파일명: {result.parsed.fileName} + </Badge> + )} + <Badge variant="outline" className="text-xs"> + 확장자: .{result.parsed.extension} + </Badge> + </div> + )} + </div> + <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + + {/* 검증 실패 파일 */} + {invalidFiles.length > 0 && ( + <div className="space-y-2 mt-4"> + <h4 className="text-sm font-semibold text-red-600 dark:text-red-400 flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 검증 실패 ({invalidFiles.length}개) + </h4> + {invalidFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.error && ( + <div className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ {result.error} + </div> + )} + </div> + <XCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + </div> + </ScrollArea> + + {/* 형식 안내 */} + <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3"> + <div className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1"> + 올바른 파일명 형식 + </div> + <code className="text-xs text-blue-700 dark:text-blue-300"> + [DOC_NO]_[REV_NO]_[STAGE].[확장자] + </code> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + 예: VD-DOC-001_01_IFA.pdf + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 선택사항: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자] (파일명 추가 가능) + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 파일명에는 언더스코어(_)가 포함될 수 있습니다. + </div> + {isVendorMode && ( + <div className="text-xs text-blue-600 dark:text-blue-400 mt-2 pt-2 border-t border-blue-200 dark:border-blue-800"> + {availableDocNos.length > 0 ? ( + <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개</> + ) : ( + <>⚠️ 할당된 문서가 없습니다</> + )} + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={handleCancel} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUpload} + disabled={validFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="h-4 w-4 mr-2" /> + 업로드 ({validFiles.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx new file mode 100644 index 00000000..25a798b6 --- /dev/null +++ b/lib/swp/table/swp-uploaded-files-dialog.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useState, useTransition, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { FileText, ChevronRight, ChevronDown, X, Loader2, RefreshCw } from "lucide-react"; +import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../actions"; +import type { SwpFileApiResponse } from "../api-client"; + +interface SwpUploadedFilesDialogProps { + projNo: string; + vndrCd: string; + userId: string; +} + +interface FileTreeNode { + files: SwpFileApiResponse[]; +} + +interface RevisionTreeNode { + revNo: string; + files: FileTreeNode; +} + +interface DocumentTreeNode { + docNo: string; + revisions: Map<string, RevisionTreeNode>; +} + +export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFilesDialogProps) { + const [open, setOpen] = useState(false); + const [files, setFiles] = useState<SwpFileApiResponse[]>([]); + const [isLoading, startLoading] = useTransition(); + const [expandedDocs, setExpandedDocs] = useState<Set<string>>(new Set()); + const [expandedRevs, setExpandedRevs] = useState<Set<string>>(new Set()); + const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set()); + const { toast } = useToast(); + + // 파일 목록을 트리 구조로 변환 + const fileTree = useMemo(() => { + const tree = new Map<string, DocumentTreeNode>(); + + files.forEach((file) => { + const docNo = file.OWN_DOC_NO; + const revNo = file.REV_NO; + + if (!tree.has(docNo)) { + tree.set(docNo, { + docNo, + revisions: new Map(), + }); + } + + const docNode = tree.get(docNo)!; + + if (!docNode.revisions.has(revNo)) { + docNode.revisions.set(revNo, { + revNo, + files: { files: [] }, + }); + } + + const revNode = docNode.revisions.get(revNo)!; + revNode.files.files.push(file); + }); + + return tree; + }, [files]); + + // 다이얼로그 열릴 때 파일 목록 조회 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + loadFiles(); + } + }; + + // 파일 목록 조회 + const loadFiles = () => { + if (!projNo || !vndrCd) { + toast({ + variant: "destructive", + title: "조회 불가", + description: "프로젝트와 업체 정보가 필요합니다.", + }); + return; + } + + startLoading(async () => { + try { + const result = await fetchVendorUploadedFiles(projNo, vndrCd); + setFiles(result); + toast({ + title: "조회 완료", + description: `${result.length}개의 파일을 조회했습니다.`, + }); + } catch (error) { + console.error("파일 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "조회 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + }); + }; + + // 파일 취소 + const handleCancelFile = async (file: SwpFileApiResponse) => { + if (!file.BOX_SEQ || !file.ACTV_SEQ) { + toast({ + variant: "destructive", + title: "취소 불가", + description: "파일 정보가 올바르지 않습니다.", + }); + return; + } + + const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`; + setCancellingFiles((prev) => new Set(prev).add(fileKey)); + + try { + await cancelVendorUploadedFile({ + boxSeq: file.BOX_SEQ, + actvSeq: file.ACTV_SEQ, + userId, + }); + + toast({ + title: "취소 완료", + description: `${file.FILE_NM} 파일이 취소되었습니다.`, + }); + + // 목록 새로고침 + loadFiles(); + } catch (error) { + console.error("파일 취소 실패:", error); + toast({ + variant: "destructive", + title: "취소 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } finally { + setCancellingFiles((prev) => { + const newSet = new Set(prev); + newSet.delete(fileKey); + return newSet; + }); + } + }; + + // 문서 토글 + const toggleDoc = (docNo: string) => { + setExpandedDocs((prev) => { + const newSet = new Set(prev); + if (newSet.has(docNo)) { + newSet.delete(docNo); + } else { + newSet.add(docNo); + } + return newSet; + }); + }; + + // 리비전 토글 + const toggleRev = (docNo: string, revNo: string) => { + const key = `${docNo}_${revNo}`; + setExpandedRevs((prev) => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}> + <FileText className="h-4 w-4 mr-2" /> + 업로드 파일 관리 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>업로드한 파일 목록</DialogTitle> + <DialogDescription> + 프로젝트: {projNo} | 업체: {vndrCd} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 액션 바 */} + <div className="flex items-center justify-between"> + <div className="text-sm text-muted-foreground"> + 총 {files.length}개 파일 + </div> + <Button + variant="outline" + size="sm" + onClick={loadFiles} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + <> + <RefreshCw className="h-4 w-4 mr-2" /> + 새로고침 + </> + )} + </Button> + </div> + + {/* 파일 트리 */} + <ScrollArea className="h-[500px] rounded-md border p-4"> + {isLoading && files.length === 0 ? ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + <Loader2 className="h-6 w-6 animate-spin mr-2" /> + 파일 목록을 조회하는 중... + </div> + ) : files.length === 0 ? ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + 업로드한 파일이 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {Array.from(fileTree.entries()).map(([docNo, docNode]) => ( + <div key={docNo} className="space-y-1"> + {/* 문서번호 */} + <div + className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer" + onClick={() => toggleDoc(docNo)} + > + {expandedDocs.has(docNo) ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" /> + )} + <FileText className="h-4 w-4 text-blue-600 shrink-0" /> + <span className="font-semibold">{docNo}</span> + <Badge variant="outline" className="text-xs"> + {docNode.revisions.size}개 리비전 + </Badge> + </div> + + {/* 리비전 목록 */} + {expandedDocs.has(docNo) && ( + <div className="ml-6 space-y-1"> + {Array.from(docNode.revisions.entries()).map(([revNo, revNode]) => { + const revKey = `${docNo}_${revNo}`; + return ( + <div key={revKey} className="space-y-1"> + {/* 리비전 번호 */} + <div + className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer" + onClick={() => toggleRev(docNo, revNo)} + > + {expandedRevs.has(revKey) ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" /> + )} + <span className="font-medium text-sm">Rev: {revNo}</span> + <Badge variant="secondary" className="text-xs"> + {revNode.files.files.length}개 파일 + </Badge> + </div> + + {/* 파일 목록 */} + {expandedRevs.has(revKey) && ( + <div className="ml-6 space-y-1"> + {revNode.files.files.map((file, idx) => { + const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`; + const isCancellable = file.STAT === "SCW01"; + const isCancelling = cancellingFiles.has(fileKey); + + return ( + <div + key={`${fileKey}_${idx}`} + className="flex items-center gap-2 p-2 rounded-md border bg-card" + > + <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-sm truncate">{file.FILE_NM}</div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>Stage: {file.STAGE}</span> + <span>•</span> + <span>상태: {file.STAT_NM || file.STAT || "알 수 없음"}</span> + </div> + </div> + <Button + variant="destructive" + size="sm" + onClick={() => handleCancelFile(file)} + disabled={!isCancellable || isCancelling} + title={ + isCancellable + ? "파일 업로드 취소" + : `취소 불가 (상태: ${file.STAT_NM || file.STAT})` + } + > + {isCancelling ? ( + <> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 취소 중... + </> + ) : ( + <> + <X className="h-3 w-3 mr-1" /> + 취소 + </> + )} + </Button> + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ))} + </div> + )} + </ScrollArea> + + {/* 안내 메시지 */} + <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3"> + <div className="text-xs text-blue-600 dark:text-blue-400 space-y-1"> + <p>ℹ️ 접수 전(SCW01) 상태의 파일만 취소할 수 있습니다.</p> + <p>ℹ️ 취소된 파일은 목록에서 제거됩니다.</p> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ); +} |
