diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-17 15:42:29 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-17 15:42:29 +0900 |
| commit | b9f575f6110faabc7b062f84bf491d69d88a10a5 (patch) | |
| tree | 23babc36a1131a861bcb9fbae7fed7314e6ce7e7 /lib/swp | |
| parent | 85d32a79dcf0f7047406039363baa2e06b859ddd (diff) | |
(김준회) swp: GetExternalInboxList를 메인 테이블로 변경
Diffstat (limited to 'lib/swp')
| -rw-r--r-- | lib/swp/table/swp-help-dialog.tsx | 53 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-document-detail-dialog.tsx | 450 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-table-columns.tsx | 129 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-table.tsx | 244 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 19 |
5 files changed, 880 insertions, 15 deletions
diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx index c6c5296b..3aa7d6dc 100644 --- a/lib/swp/table/swp-help-dialog.tsx +++ b/lib/swp/table/swp-help-dialog.tsx @@ -18,7 +18,7 @@ export function SwpUploadHelpDialog() { <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <HelpCircle className="h-4 w-4" /> - 업로드 가이드 + SWP 제출 메뉴 가이드 </Button> </DialogTrigger> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> @@ -30,6 +30,33 @@ export function SwpUploadHelpDialog() { </DialogHeader> <div className="space-y-6"> + {/* 탭 설명 */} + <div className="space-y-3"> + <h3 className="text-sm font-semibold">탭 설명</h3> + + <div className="rounded-lg border p-4 space-y-3"> + <div> + <Badge variant="default" className="mb-2"> + SBOX (ALL) 탭 + </Badge> + <p className="text-sm text-muted-foreground"> + 파일을 업로드한 현황입니다. SHI가 파일 접수 여부를 응답할 예정입니다. + 업로드한 파일의 승낙 여부 등을 확인할 수 있으며, 접수 전의 Standby 상태의 경우 업로드를 취소할 수 있습니다. + </p> + </div> + + <div className="border-t pt-3"> + <Badge variant="default" className="mb-2"> + VDR Documents (Received) 탭 + </Badge> + <p className="text-sm text-muted-foreground"> + 파일을 업로드한 뒤, SHI가 업로드한 파일을 수락하면 Rev, Activity No가 만들어지며, 해당 테이블에 추가됩니다. + 수락시 Activity No 가 부여됩니다. + </p> + </div> + </div> + </div> + {/* 파일명 형식 */} <div className="space-y-2"> <h3 className="text-sm font-semibold">파일명 형식</h3> @@ -37,10 +64,10 @@ export function SwpUploadHelpDialog() { [DOC_NO]_[REV_NO]_[STAGE].[확장자] </div> <p className="text-xs text-muted-foreground"> - ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다 + [주의] 언더스코어(_)가 최소 2개 이상 있어야 합니다 </p> <p className="text-xs text-muted-foreground"> - ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자]) + [선택사항] 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자]) </p> </div> @@ -98,7 +125,7 @@ export function SwpUploadHelpDialog() { VD-DOC-001_01_IFA.pdf </code> <p className="text-xs text-green-600 dark:text-green-400 mt-1"> - ✓ 기본 형식 (파일명 생략) + [O] 기본 형식 (파일명 생략) </p> </div> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> @@ -106,7 +133,7 @@ export function SwpUploadHelpDialog() { VD-DOC-001_01_IFA_drawing_final.pdf </code> <p className="text-xs text-green-600 dark:text-green-400 mt-1"> - ✓ 파일명 추가 (파일명에 언더스코어 포함 가능) + [O] 파일명 추가 (파일명에 언더스코어 포함 가능) </p> </div> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> @@ -114,7 +141,7 @@ export function SwpUploadHelpDialog() { TECH-SPEC-002_02_IFC.dwg </code> <p className="text-xs text-green-600 dark:text-green-400 mt-1"> - ✓ 기본 형식 사용 + [O] 기본 형식 사용 </p> </div> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> @@ -122,7 +149,7 @@ export function SwpUploadHelpDialog() { DOC-003_03_IFA_test_result_data.xlsx </code> <p className="text-xs text-green-600 dark:text-green-400 mt-1"> - ✓ 파일명 추가 (여러 단어 조합 가능) + [O] 파일명 추가 (여러 단어 조합 가능) </p> </div> </div> @@ -137,7 +164,7 @@ export function SwpUploadHelpDialog() { VD-DOC-001-01-IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 언더스코어(_) 대신 하이픈(-) 사용 + [X] 언더스코어(_) 대신 하이픈(-) 사용 </p> </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> @@ -145,7 +172,7 @@ export function SwpUploadHelpDialog() { VD-DOC-001_01.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ STAGE 정보 누락 (최소 3개 항목 필요) + [X] 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"> @@ -153,7 +180,7 @@ export function SwpUploadHelpDialog() { VD DOC 001_01_IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 공백 포함 (언더스코어 사용 필요) + [X] 공백 포함 (언더스코어 사용 필요) </p> </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> @@ -161,7 +188,7 @@ export function SwpUploadHelpDialog() { VD-DOC-001__IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ REV_NO 비어있음 (빈 항목 불가) + [X] REV_NO 비어있음 (빈 항목 불가) </p> </div> </div> @@ -170,7 +197,7 @@ export function SwpUploadHelpDialog() { {/* 주의사항 */} <div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-4"> <h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2"> - ⚠️ 주의사항 + [주의사항] </h3> <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside"> <li>파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다</li> @@ -178,7 +205,7 @@ export function SwpUploadHelpDialog() { <li>4번째 항목(파일명)은 선택사항으로 생략 가능합니다</li> <li>업로드 날짜/시간은 시스템에서 자동으로 생성됩니다</li> <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li> - <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li> + <li>프로젝트를 먼저 선택해야 업로드 버튼이 활성화됩니다</li> </ul> </div> </div> diff --git a/lib/swp/table/swp-inbox-document-detail-dialog.tsx b/lib/swp/table/swp-inbox-document-detail-dialog.tsx new file mode 100644 index 00000000..ca7fcf1b --- /dev/null +++ b/lib/swp/table/swp-inbox-document-detail-dialog.tsx @@ -0,0 +1,450 @@ +"use client"; + +import React, { useState, useMemo } 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 { + ChevronDown, + ChevronRight, + Download, + FileIcon, + XCircle, + AlertCircle, +} from "lucide-react"; +import { + cancelVendorFile, + downloadVendorFile, +} from "@/lib/swp/vendor-actions"; +import type { SwpFileApiResponse } from "@/lib/swp/api-client"; +import type { InboxDocumentItem } from "./swp-inbox-table"; +import { toast } from "sonner"; + +interface SwpInboxDocumentDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + document: InboxDocumentItem | null; + projNo: string; + vendorCode: string; + userId: string; +} + +// 리비전별 그룹 타입 +interface RevisionGroup { + revNo: string; + stage: string; + activities: ActivityGroup[]; + totalFiles: number; +} + +// Activity별 그룹 타입 (activity가 null일 수 있음) +interface ActivityGroup { + actvNo: string | null; + files: SwpFileApiResponse[]; +} + +export function SwpInboxDocumentDetailDialog({ + open, + onOpenChange, + document, + projNo, +}: SwpInboxDocumentDetailDialogProps) { + const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set()); + const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set()); + const [isAllExpanded, setIsAllExpanded] = useState(true); + + // 파일들을 리비전 > Activity 구조로 그룹핑 + const revisionGroups = useMemo(() => { + if (!document) return []; + + const revMap = new Map<string, SwpFileApiResponse[]>(); + + document.files.forEach((file) => { + const revKey = `${file.REV_NO}|${file.STAGE}`; + if (!revMap.has(revKey)) { + revMap.set(revKey, []); + } + revMap.get(revKey)!.push(file); + }); + + const result: RevisionGroup[] = []; + + revMap.forEach((revFiles, revKey) => { + const [revNo, stage] = revKey.split("|"); + + // Activity별로 그룹핑 (null 가능) + const actMap = new Map<string | null, SwpFileApiResponse[]>(); + + revFiles.forEach((file) => { + const actvNo = file.ACTV_NO || null; + if (!actMap.has(actvNo)) { + actMap.set(actvNo, []); + } + actMap.get(actvNo)!.push(file); + }); + + const activities: ActivityGroup[] = []; + actMap.forEach((files, actvNo) => { + activities.push({ actvNo, files }); + }); + + // Activity가 없는 것을 먼저, 있는 것을 나중에 정렬 + activities.sort((a, b) => { + if (a.actvNo === null && b.actvNo !== null) return -1; + if (a.actvNo !== null && b.actvNo === null) return 1; + if (a.actvNo === null && b.actvNo === null) return 0; + return (a.actvNo || "").localeCompare(b.actvNo || ""); + }); + + result.push({ + revNo, + stage, + activities, + totalFiles: revFiles.length, + }); + }); + + // 리비전 번호로 정렬 (최신이 위로) + return result.sort((a, b) => b.revNo.localeCompare(a.revNo)); + }, [document]); + + // Dialog가 열릴 때 모두 펼치기 + React.useEffect(() => { + if (open && revisionGroups.length > 0) { + const allRevKeys = new Set<string>(); + const allActKeys = new Set<string>(); + + revisionGroups.forEach((revision) => { + const revKey = revision.revNo; + allRevKeys.add(revKey); + + revision.activities.forEach((activity) => { + const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`; + allActKeys.add(actKey); + }); + }); + + setExpandedRevisions(allRevKeys); + setExpandedActivities(allActKeys); + setIsAllExpanded(true); + } + }, [open, revisionGroups]); + + 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 (isAllExpanded) { + // 모두 닫기 + setExpandedRevisions(new Set()); + setExpandedActivities(new Set()); + setIsAllExpanded(false); + } else { + // 모두 열기 + const allRevKeys = new Set<string>(); + const allActKeys = new Set<string>(); + + revisionGroups.forEach((revision) => { + const revKey = revision.revNo; + allRevKeys.add(revKey); + + revision.activities.forEach((activity) => { + const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`; + 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}`); + + // Dialog를 닫고 부모 컴포넌트가 새로고침하도록 함 + onOpenChange(false); + } 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.ownDocNo} + </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">OWN_DOC_NO:</span> + <div className="text-sm font-mono">{document.ownDocNo}</div> + </div> + <div> + <span className="text-sm font-semibold">최신 스테이지:</span> + <div className="text-sm">{document.latestStage || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">최신 리비전:</span> + <div className="text-sm">{document.latestRevNo || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">최신 REV 파일:</span> + <div className="text-sm">{document.latestRevFileCount}개</div> + </div> + </div> + + {/* 리비전 및 액티비티 트리 */} + {revisionGroups.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> + {revisionGroups.map((revision) => { + const revKey = revision.revNo; + 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> + + {/* 액티비티 목록 (또는 Activity 없는 파일들) */} + {isRevExpanded && ( + <div className="p-2 space-y-2"> + {revision.activities.map((activity) => { + const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`; + const isActExpanded = expandedActivities.has(actKey); + + 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" /> + )} + {activity.actvNo ? ( + <> + <Badge variant="outline" className="bg-blue-100 text-blue-800"> + Activity + </Badge> + <span className="text-xs text-muted-foreground font-mono"> + {activity.actvNo} + </span> + </> + ) : ( + <Badge variant="outline" className="bg-gray-100 text-gray-800"> + Activity 없음 + </Badge> + )} + <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.FILE_NM}</span> + {file.FILE_SZ && ( + <span className="text-xs text-muted-foreground"> + ({formatFileSize(file.FILE_SZ)}) + </span> + )} + {file.STAT && ( + <Badge variant="outline" className={ + file.STAT === "SCW03" || file.STAT === "SCW08" ? "bg-green-100 text-green-800" : // Complete, Checked + file.STAT === "SCW02" ? "bg-blue-100 text-blue-800" : // Processing + file.STAT === "SCW01" ? "bg-yellow-100 text-yellow-800" : // Standby + file.STAT === "SCW04" || file.STAT === "SCW05" || file.STAT === "SCW06" ? "bg-red-100 text-red-800" : // Reject, Error Zip, Error Meta + file.STAT === "SCW07" ? "bg-purple-100 text-purple-800" : // Send for Eng Verification + file.STAT === "SCW09" ? "bg-gray-100 text-gray-800" : // Cancelled + file.STAT === "SCW00" ? "bg-orange-100 text-orange-800" : // Upload + "bg-gray-100 text-gray-800" + }> + {file.STAT_NM || file.STAT} + </Badge> + )} + </div> + <div className="flex items-center gap-1"> + {file.STAT === "SCW01" && file.BOX_SEQ && file.ACTV_SEQ && ( + <Button + variant="outline" + size="sm" + onClick={() => handleCancelFile(file.BOX_SEQ!, file.ACTV_SEQ!, file.FILE_NM)} + > + <XCircle className="h-4 w-4 mr-1" /> + 취소 + </Button> + )} + {file.FLD_PATH && ( + <Button + variant="outline" + size="sm" + onClick={() => handleDownloadFile(file.FILE_NM, document.ownDocNo)} + > + <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-inbox-table-columns.tsx b/lib/swp/table/swp-inbox-table-columns.tsx new file mode 100644 index 00000000..bd740ca4 --- /dev/null +++ b/lib/swp/table/swp-inbox-table-columns.tsx @@ -0,0 +1,129 @@ +"use client"; + +import React from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import type { SwpFileApiResponse } from "@/lib/swp/api-client"; + +export const swpInboxDocumentColumns: ColumnDef<SwpFileApiResponse>[] = [ + { + accessorKey: "STAT_NM", + header: "최신 리비전의 최신 파일 상태", + cell: ({ row }) => { + const statNm = row.original.STAT_NM; + const stat = row.original.STAT; + const displayStatus = statNm || stat || "-"; + + if (!stat) return displayStatus; + + // STAT 코드 기반 색상 결정 + const color = + stat === "SCW03" || stat === "SCW08" ? "bg-green-100 text-green-800" : // Complete, Checked + stat === "SCW02" ? "bg-blue-100 text-blue-800" : // Processing + stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : // Standby + stat === "SCW04" || stat === "SCW05" || stat === "SCW06" ? "bg-red-100 text-red-800" : // Reject, Error Zip, Error Meta + stat === "SCW07" ? "bg-purple-100 text-purple-800" : // Send for Eng Verification + stat === "SCW09" ? "bg-gray-100 text-gray-800" : // Cancelled + stat === "SCW00" ? "bg-orange-100 text-orange-800" : // Upload + "bg-gray-100 text-gray-800"; // 기타 + + return ( + <Badge variant="outline" className={color}> + {displayStatus} + </Badge> + ); + }, + size: 120, + }, + { + accessorKey: "OWN_DOC_NO", + header: "OWN_DOC_NO", + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.OWN_DOC_NO || "-"}</div> + ), + size: 250, + }, + { + accessorKey: "FILE_NM", + header: "파일명", + cell: ({ row }) => ( + <div className="max-w-md truncate" title={row.original.FILE_NM}> + {row.original.FILE_NM} + </div> + ), + size: 300, + }, + { + accessorKey: "STAGE", + header: "스테이지", + cell: ({ row }) => { + const stage = row.original.STAGE; + if (!stage) return "-"; + + 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: 80, + }, + { + accessorKey: "REV_NO", + header: "REV", + cell: ({ row }) => row.original.REV_NO || "-", + size: 80, + }, + { + accessorKey: "ACTV_NO", + header: "Activity", + cell: ({ row }) => { + const actvNo = row.original.ACTV_NO; + if (!actvNo) return <span className="text-muted-foreground">-</span>; + return <div className="font-mono text-xs">{actvNo}</div>; + }, + size: 120, + }, + { + accessorKey: "FILE_SZ", + header: "파일 크기", + cell: ({ row }) => { + const size = row.original.FILE_SZ; + if (!size) return "-"; + const bytes = parseInt(size); + if (isNaN(bytes)) return size; + + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + }, + size: 100, + }, + { + accessorKey: "CRTE_DTM", + header: "생성일시", + cell: ({ row }) => { + const date = row.original.CRTE_DTM; + if (!date) return "-"; + try { + return new Date(date).toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return date; + } + }, + size: 150, + }, +]; + diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx new file mode 100644 index 00000000..c3f3a243 --- /dev/null +++ b/lib/swp/table/swp-inbox-table.tsx @@ -0,0 +1,244 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { + useReactTable, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { ColumnDef } from "@tanstack/react-table"; +import { SwpInboxDocumentDetailDialog } from "./swp-inbox-document-detail-dialog"; +import type { SwpFileApiResponse } from "@/lib/swp/api-client"; + +interface SwpInboxTableProps { + files: SwpFileApiResponse[]; + projNo: string; + vendorCode: string; + userId: string; +} + +// 문서별로 그룹핑된 데이터 타입 +export interface InboxDocumentItem { + ownDocNo: string; + latestRevFileCount: number; // 최신 REV의 파일 개수 + latestStage: string; + latestRevNo: string; + latestStatus: string | null; + latestStatusNm: string | null; + files: SwpFileApiResponse[]; +} + +// 테이블 컬럼 정의 +const inboxDocumentColumns: ColumnDef<InboxDocumentItem>[] = [ + { + accessorKey: "latestStatusNm", + header: "상태", + cell: ({ row }) => { + const statNm = row.original.latestStatusNm; + const stat = row.original.latestStatus; + const displayStatus = statNm || stat || "-"; + + if (!stat) return displayStatus; + + // STAT 코드 기반 색상 결정 + const color = + stat === "SCW03" || stat === "SCW08" ? "bg-green-100 text-green-800" : // Complete, Checked + stat === "SCW02" ? "bg-blue-100 text-blue-800" : // Processing + stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : // Standby + stat === "SCW04" || stat === "SCW05" || stat === "SCW06" ? "bg-red-100 text-red-800" : // Reject, Error Zip, Error Meta + stat === "SCW07" ? "bg-purple-100 text-purple-800" : // Send for Eng Verification + stat === "SCW09" ? "bg-gray-100 text-gray-800" : // Cancelled + stat === "SCW00" ? "bg-orange-100 text-orange-800" : // Upload + "bg-gray-100 text-gray-800"; // 기타 + + return ( + <Badge variant="outline" className={color}> + {displayStatus} + </Badge> + ); + }, + size: 120, + }, + { + accessorKey: "ownDocNo", + header: "OWN_DOC_NO", + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.ownDocNo}</div> + ), + size: 300, + }, + { + accessorKey: "latestStage", + header: "최신 스테이지", + cell: ({ row }) => { + const stage = row.original.latestStage; + if (!stage) return "-"; + + 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: 120, + }, + { + accessorKey: "latestRevNo", + header: "최신 REV", + cell: ({ row }) => row.original.latestRevNo || "-", + size: 100, + }, + { + accessorKey: "latestRevFileCount", + header: "최신 REV 파일 수", + cell: ({ row }) => ( + <div className="text-center"> + <div className="text-sm font-medium"> + {row.original.latestRevFileCount}개 + </div> + </div> + ), + size: 100, + }, +]; + +export function SwpInboxTable({ + files, + projNo, + vendorCode, + userId, +}: SwpInboxTableProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedDocument, setSelectedDocument] = useState<InboxDocumentItem | null>(null); + + // 파일들을 문서별로 그룹핑 + const documents = useMemo(() => { + const docMap = new Map<string, SwpFileApiResponse[]>(); + + files.forEach((file) => { + const docNo = file.OWN_DOC_NO; + if (!docMap.has(docNo)) { + docMap.set(docNo, []); + } + docMap.get(docNo)!.push(file); + }); + + const result: InboxDocumentItem[] = []; + + docMap.forEach((docFiles, ownDocNo) => { + // 최신 REV 찾기 (REV_NO 기준으로 정렬) + const sortedByRev = [...docFiles].sort((a, b) => + (b.REV_NO || "").localeCompare(a.REV_NO || "") + ); + const latestRevNo = sortedByRev[0].REV_NO || ""; + + // 최신 REV의 파일들만 필터링 + const latestRevFiles = docFiles.filter(file => file.REV_NO === latestRevNo); + + // 최신 REV 내에서 가장 최근 생성된 파일 찾기 (상태 표시용) + const sortedLatestRevFiles = [...latestRevFiles].sort((a, b) => + (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "") + ); + const latestFile = sortedLatestRevFiles[0]; + + result.push({ + ownDocNo, + latestRevFileCount: latestRevFiles.length, // 최신 REV의 파일 개수 + latestStage: latestFile.STAGE || "", + latestRevNo: latestRevNo, + latestStatus: latestFile.STAT, + latestStatusNm: latestFile.STAT_NM, + files: docFiles, // 전체 파일 목록 (상세보기용) + }); + }); + + return result.sort((a, b) => a.ownDocNo.localeCompare(b.ownDocNo)); + }, [files]); + + const table = useReactTable({ + data: documents, + columns: inboxDocumentColumns, + getCoreRowModel: getCoreRowModel(), + }); + + // 문서 클릭 핸들러 + const handleDocumentClick = (document: InboxDocumentItem) => { + setSelectedDocument(document); + setDialogOpen(true); + }; + + return ( + <div className="space-y-4"> + {/* 테이블 */} + <div className="rounded-md border"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <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}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={inboxDocumentColumns.length} className="h-24 text-center"> + 업로드한 파일이 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* 문서 상세 Dialog */} + <SwpInboxDocumentDetailDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + document={selectedDocument} + projNo={projNo} + vendorCode={vendorCode} + userId={userId} + /> + </div> + ); +} + diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index ea5ee729..594bdd77 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -27,6 +27,7 @@ interface SwpTableFilters { docTitle?: string; pkgNo?: string; stage?: string; + status?: string; } interface SwpTableToolbarProps { @@ -419,13 +420,14 @@ export function SwpTableToolbar({ {isUploading ? "업로드 중..." : "파일 업로드"} </Button> - {userId && ( + {/* 별도 탭으로 분리하고 메인 테이블로 변경하였음. */} + {/* {userId && ( <SwpUploadedFilesDialog projNo={projNo} vndrCd={vendorCode} userId={userId} /> - )} + )} */} <SwpUploadHelpDialog /> </div> @@ -577,6 +579,19 @@ export function SwpTableToolbar({ } /> </div> + + {/* 상태 */} + <div className="space-y-2"> + <Label htmlFor="status">상태</Label> + <Input + id="status" + placeholder="ex. standby.." + value={localFilters.status || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, status: e.target.value }) + } + /> + </div> </div> <div className="flex justify-end"> |
