@@ -30,6 +30,33 @@ export function SwpUploadHelpDialog() {
+ {/* 탭 설명 */}
+
+
탭 설명
+
+
+
+
+ SBOX (ALL) 탭
+
+
+ 파일을 업로드한 현황입니다. SHI가 파일 접수 여부를 응답할 예정입니다.
+ 업로드한 파일의 승낙 여부 등을 확인할 수 있으며, 접수 전의 Standby 상태의 경우 업로드를 취소할 수 있습니다.
+
+
+
+
+
+ VDR Documents (Received) 탭
+
+
+ 파일을 업로드한 뒤, SHI가 업로드한 파일을 수락하면 Rev, Activity No가 만들어지며, 해당 테이블에 추가됩니다.
+ 수락시 Activity No 가 부여됩니다.
+
+
+
+
+
{/* 파일명 형식 */}
파일명 형식
@@ -37,10 +64,10 @@ export function SwpUploadHelpDialog() {
[DOC_NO]_[REV_NO]_[STAGE].[확장자]
- ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다
+ [주의] 언더스코어(_)가 최소 2개 이상 있어야 합니다
- ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자])
+ [선택사항] 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자])
@@ -98,7 +125,7 @@ export function SwpUploadHelpDialog() {
VD-DOC-001_01_IFA.pdf
- ✓ 기본 형식 (파일명 생략)
+ [O] 기본 형식 (파일명 생략)
@@ -106,7 +133,7 @@ export function SwpUploadHelpDialog() {
VD-DOC-001_01_IFA_drawing_final.pdf
- ✓ 파일명 추가 (파일명에 언더스코어 포함 가능)
+ [O] 파일명 추가 (파일명에 언더스코어 포함 가능)
@@ -114,7 +141,7 @@ export function SwpUploadHelpDialog() {
TECH-SPEC-002_02_IFC.dwg
- ✓ 기본 형식 사용
+ [O] 기본 형식 사용
@@ -122,7 +149,7 @@ export function SwpUploadHelpDialog() {
DOC-003_03_IFA_test_result_data.xlsx
- ✓ 파일명 추가 (여러 단어 조합 가능)
+ [O] 파일명 추가 (여러 단어 조합 가능)
@@ -137,7 +164,7 @@ export function SwpUploadHelpDialog() {
VD-DOC-001-01-IFA.pdf
- ✗ 언더스코어(_) 대신 하이픈(-) 사용
+ [X] 언더스코어(_) 대신 하이픈(-) 사용
@@ -145,7 +172,7 @@ export function SwpUploadHelpDialog() {
VD-DOC-001_01.pdf
- ✗ STAGE 정보 누락 (최소 3개 항목 필요)
+ [X] STAGE 정보 누락 (최소 3개 항목 필요)
@@ -153,7 +180,7 @@ export function SwpUploadHelpDialog() {
VD DOC 001_01_IFA.pdf
- ✗ 공백 포함 (언더스코어 사용 필요)
+ [X] 공백 포함 (언더스코어 사용 필요)
@@ -161,7 +188,7 @@ export function SwpUploadHelpDialog() {
VD-DOC-001__IFA.pdf
- ✗ REV_NO 비어있음 (빈 항목 불가)
+ [X] REV_NO 비어있음 (빈 항목 불가)
@@ -170,7 +197,7 @@ export function SwpUploadHelpDialog() {
{/* 주의사항 */}
- ⚠️ 주의사항
+ [주의사항]
- 파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다
@@ -178,7 +205,7 @@ export function SwpUploadHelpDialog() {
- 4번째 항목(파일명)은 선택사항으로 생략 가능합니다
- 업로드 날짜/시간은 시스템에서 자동으로 생성됩니다
- 같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다
- - 프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다
+ - 프로젝트를 먼저 선택해야 업로드 버튼이 활성화됩니다
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>(new Set());
+ const [expandedActivities, setExpandedActivities] = useState>(new Set());
+ const [isAllExpanded, setIsAllExpanded] = useState(true);
+
+ // 파일들을 리비전 > Activity 구조로 그룹핑
+ const revisionGroups = useMemo(() => {
+ if (!document) return [];
+
+ const revMap = new Map();
+
+ 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();
+
+ 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();
+ const allActKeys = new Set();
+
+ 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();
+ const allActKeys = new Set();
+
+ 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 (
+
+ );
+}
+
+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[] = [
+ {
+ 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 (
+
+ {displayStatus}
+
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "OWN_DOC_NO",
+ header: "OWN_DOC_NO",
+ cell: ({ row }) => (
+ {row.original.OWN_DOC_NO || "-"}
+ ),
+ size: 250,
+ },
+ {
+ accessorKey: "FILE_NM",
+ header: "파일명",
+ cell: ({ row }) => (
+
+ {row.original.FILE_NM}
+
+ ),
+ 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 (
+
+ {stage}
+
+ );
+ },
+ 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 -;
+ return {actvNo}
;
+ },
+ 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[] = [
+ {
+ 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 (
+
+ {displayStatus}
+
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "ownDocNo",
+ header: "OWN_DOC_NO",
+ cell: ({ row }) => (
+ {row.original.ownDocNo}
+ ),
+ 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 (
+
+ {stage}
+
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "latestRevNo",
+ header: "최신 REV",
+ cell: ({ row }) => row.original.latestRevNo || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "latestRevFileCount",
+ header: "최신 REV 파일 수",
+ cell: ({ row }) => (
+
+
+ {row.original.latestRevFileCount}개
+
+
+ ),
+ size: 100,
+ },
+];
+
+export function SwpInboxTable({
+ files,
+ projNo,
+ vendorCode,
+ userId,
+}: SwpInboxTableProps) {
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [selectedDocument, setSelectedDocument] = useState(null);
+
+ // 파일들을 문서별로 그룹핑
+ const documents = useMemo(() => {
+ const docMap = new Map();
+
+ 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 (
+
+ {/* 테이블 */}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ handleDocumentClick(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ 업로드한 파일이 없습니다.
+
+
+ )}
+
+
+
+
+ {/* 문서 상세 Dialog */}
+
+
+ );
+}
+
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 ? "업로드 중..." : "파일 업로드"}
- {userId && (
+ {/* 별도 탭으로 분리하고 메인 테이블로 변경하였음. */}
+ {/* {userId && (
- )}
+ )} */}
@@ -577,6 +579,19 @@ export function SwpTableToolbar({
}
/>
+
+ {/* 상태 */}
+
+
+
+ setLocalFilters({ ...localFilters, status: e.target.value })
+ }
+ />
+
--
cgit v1.2.3