From 935fd22e17afc034a472bc2d159de7b9f5e5dcae Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 20 Nov 2025 19:36:01 +0900
Subject: (김준회) PO, POS, swp - PO: 발주서출력기능 초안 - 벤더측 POS 다운로드
 기능 추가 - Contract 생성시 Status 설정 (mapper) - swp document registration
 table 로직 리팩터링 - swp: 입력가능 문서번호 validation 추가 (리스트 메뉴에서
 Completed 된 건)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/table/swp-inbox-table.tsx | 459 +++++++++++++++++++++++---------------
 1 file changed, 282 insertions(+), 177 deletions(-)

(limited to 'lib/swp/table/swp-inbox-table.tsx')

diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx
index 430447f4..d070f2fd 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -38,29 +38,33 @@ interface SwpInboxTableProps {
 
 // 테이블 행 데이터 (플랫하게 펼침)
 interface TableRowData {
-  uploadId: string | null; // 업로드 필요 문서는 null
+  uploadId: string | null;
   docNo: string;
   revNo: string | null;
   stage: string | null;
   status: string | null;
   statusNm: string | null;
   actvNo: string | null;
-  crter: string | null; // CRTER (그대로 표시)
-  note1: string | null; // DC Note (Activity의 NOTE1 또는 buyerSystemComment)
-  pkgNo: string | null; // PKG_NO
-  file: SwpFileApiResponse | null; // 업로드 필요 문서는 null
+  actvSeq: string | null; // 정렬용
+  crter: string | null;
+  note1: string | null;
+  pkgNo: string | null;
+  file: SwpFileApiResponse | null;
   uploadDate: string | null;
-  // 각 행이 속한 그룹의 정보 (계층적 rowSpan 처리)
-  isFirstInUpload: boolean;
-  fileCountInUpload: number;
-  isFirstInDoc: boolean;
-  fileCountInDoc: number;
-  isFirstInRev: boolean;
-  fileCountInRev: number;
-  isFirstInActivity: boolean;
-  fileCountInActivity: number;
-  // 업로드 필요 문서 여부
   isRequiredDoc: boolean;
+  
+  // Row Span 정보 (0이면 렌더링 안함)
+  spans: {
+    uploadId: number;
+    docNo: number;
+    pkgNo: number;
+    revNo: number;
+    stage: number;
+    status: number;
+    actvNo: number;
+    crter: number;
+    note1: number;
+  };
 }
 
 // Status 집계 타입
@@ -136,117 +140,47 @@ export function SwpInboxTable({
     });
   }, [files, requiredDocs]);
 
-  // 데이터 그룹화 및 플랫 변환 (API 응답 + 업로드 필요 문서)
+  // 데이터 그룹화 및 플랫 변환 (Sorting logic applied)
   const tableRows = useMemo(() => {
     const rows: TableRowData[] = [];
 
     // 1. API 응답 파일 처리
-    // Status 필터링
-    let filteredFiles = files;
-    if (selectedStatus && selectedStatus !== "UPLOAD_REQUIRED") {
-      filteredFiles = files.filter((file) => file.STAT === selectedStatus);
-    }
-
-    // 1단계: BOX_SEQ (Upload ID) 기준으로 그룹화
-    const uploadGroups = new Map<string, SwpFileApiResponse[]>();
-    
-    if (!selectedStatus || selectedStatus !== "UPLOAD_REQUIRED") {
-      filteredFiles.forEach((file) => {
-        const uploadId = file.BOX_SEQ || "NO_UPLOAD_ID";
-        if (!uploadGroups.has(uploadId)) {
-          uploadGroups.set(uploadId, []);
-        }
-        uploadGroups.get(uploadId)!.push(file);
-      });
-    }
-
-    // Upload ID별로 처리
-    uploadGroups.forEach((uploadFiles, uploadId) => {
-      // 2단계: Document No 기준으로 그룹화
-      const docGroups = new Map<string, SwpFileApiResponse[]>();
+    files.forEach((file) => {
+      // Status 필터링
+      if (selectedStatus && file.STAT !== selectedStatus) {
+        return;
+      }
       
-      uploadFiles.forEach((file) => {
-        const docNo = file.OWN_DOC_NO;
-        if (!docGroups.has(docNo)) {
-          docGroups.set(docNo, []);
+      rows.push({
+        uploadId: file.BOX_SEQ || null,
+        docNo: file.OWN_DOC_NO,
+        revNo: file.REV_NO || null,
+        stage: file.STAGE || null,
+        status: file.STAT || null,
+        statusNm: file.STAT_NM || null,
+        actvNo: file.ACTV_NO || null,
+        actvSeq: file.ACTV_SEQ || null,
+        crter: file.CRTER || null,
+        note1: file.NOTE1 || null,
+        pkgNo: file.PKG_NO || null,
+        file,
+        uploadDate: file.CRTE_DTM || null,
+        isRequiredDoc: false,
+        spans: {
+          uploadId: 1,
+          docNo: 1,
+          pkgNo: 1,
+          revNo: 1,
+          stage: 1,
+          status: 1,
+          actvNo: 1,
+          crter: 1,
+          note1: 1,
         }
-        docGroups.get(docNo)!.push(file);
-      });
-
-      // 전체 Upload ID의 파일 수 계산
-      const totalUploadFileCount = uploadFiles.length;
-      let isFirstInUpload = true;
-
-      // Document No별로 처리
-      docGroups.forEach((docFiles, docNo) => {
-        const totalDocFileCount = docFiles.length;
-        let isFirstInDoc = true;
-        
-        // Document의 첫 번째 파일에서 PKG_NO 가져오기
-        const firstDocFile = docFiles[0];
-        const docPkgNo = firstDocFile?.PKG_NO || null;
-
-        // 3단계: ACTV_SEQ 기준으로 그룹화 (최신 Rev 필터링 제거)
-        const activityGroups = new Map<string, SwpFileApiResponse[]>();
-        
-        docFiles.forEach((file) => {
-          const actvSeq = file.ACTV_SEQ || "NO_ACTIVITY";
-          if (!activityGroups.has(actvSeq)) {
-            activityGroups.set(actvSeq, []);
-          }
-          activityGroups.get(actvSeq)!.push(file);
-        });
-
-        // Activity별로 처리
-        activityGroups.forEach((activityFiles) => {
-          // 4단계: Upload Date 기준 DESC 정렬
-          const sortedFiles = activityFiles.sort((a, b) =>
-            (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
-          );
-
-          const totalActivityFileCount = sortedFiles.length;
-          
-          // Activity의 첫 번째 파일에서 메타데이터 가져오기
-          const firstActivityFile = sortedFiles[0];
-          if (!firstActivityFile) return;
-
-          // 5단계: 각 파일을 테이블 행으로 변환
-          sortedFiles.forEach((file, idx) => {
-            rows.push({
-              uploadId,
-              docNo,
-              revNo: file.REV_NO || null,
-              stage: file.STAGE || null,
-              status: file.STAT || null,
-              statusNm: file.STAT_NM || null,
-              actvNo: file.ACTV_NO || null,
-              crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER
-              note1: firstActivityFile.NOTE1 || null, // Activity 첫 파일의 DC Note
-              pkgNo: docPkgNo, // Document 레벨의 PKG_NO
-              file,
-              uploadDate: file.CRTE_DTM,
-              isFirstInUpload,
-              fileCountInUpload: totalUploadFileCount,
-              isFirstInDoc,
-              fileCountInDoc: totalDocFileCount,
-              isFirstInRev: idx === 0,
-              fileCountInRev: totalActivityFileCount, // Activity 내 파일 수
-              isFirstInActivity: idx === 0,
-              fileCountInActivity: totalActivityFileCount,
-              isRequiredDoc: false,
-            });
-
-            // 첫 번째 플래그들 업데이트
-            if (idx === 0) {
-              isFirstInUpload = false;
-              isFirstInDoc = false;
-            }
-          });
-        });
       });
     });
 
-    // 2. 업로드 필요 문서 추가 (Upload Required 필터일 때만 또는 필터 없을 때)
+    // 2. 업로드 필요 문서 처리
     if (!selectedStatus || selectedStatus === "UPLOAD_REQUIRED") {
       requiredDocs.forEach((doc) => {
         rows.push({
@@ -257,30 +191,169 @@ export function SwpInboxTable({
           status: "UPLOAD_REQUIRED",
           statusNm: "Upload Required",
           actvNo: null,
+          actvSeq: null,
           crter: null,
-          note1: doc.buyerSystemComment, // DB의 comment를 DC Note에 매핑
+          note1: doc.buyerSystemComment,
           pkgNo: null,
           file: null,
           uploadDate: null,
-          isFirstInUpload: true,
-          fileCountInUpload: 1,
-          isFirstInDoc: true,
-          fileCountInDoc: 1,
-          isFirstInRev: true,
-          fileCountInRev: 1,
-          isFirstInActivity: true,
-          fileCountInActivity: 1,
           isRequiredDoc: true,
+          spans: {
+            uploadId: 1,
+            docNo: 1,
+            pkgNo: 1,
+            revNo: 1,
+            stage: 1,
+            status: 1,
+            actvNo: 1,
+            crter: 1,
+            note1: 1,
+          }
         });
       });
     }
 
-    // Upload Date 기준 전체 정렬 (null은 맨 뒤로)
-    return rows.sort((a, b) => {
-      if (!a.uploadDate) return 1;
-      if (!b.uploadDate) return -1;
-      return b.uploadDate.localeCompare(a.uploadDate);
+    // 3. 정렬 적용
+    // 1) BOX_SEQ (DESC) -> 2) OWN_DOC_NO (DESC) -> 3) REV_NO (DESC) -> 4) ACTV_SEQ (DESC) -> 5) CRTE_DTM (DESC)
+    rows.sort((a, b) => {
+      // 1) BOX_SEQ
+      const uploadIdA = a.uploadId || "";
+      const uploadIdB = b.uploadId || "";
+      if (uploadIdA !== uploadIdB) {
+        return uploadIdB.localeCompare(uploadIdA);
+      }
+
+      // 2) OWN_DOC_NO
+      const docNoA = a.docNo || "";
+      const docNoB = b.docNo || "";
+      if (docNoA !== docNoB) {
+        return docNoB.localeCompare(docNoA);
+      }
+
+      // 3) REV_NO
+      const revNoA = a.revNo || "";
+      const revNoB = b.revNo || "";
+      if (revNoA !== revNoB) {
+        return revNoB.localeCompare(revNoA);
+      }
+
+      // 4) ACTV_SEQ
+      const actvSeqA = a.actvSeq || "";
+      const actvSeqB = b.actvSeq || "";
+      if (actvSeqA !== actvSeqB) {
+        return actvSeqB.localeCompare(actvSeqA);
+      }
+
+      // 5) CRTE_DTM
+      const dateA = a.uploadDate || "";
+      const dateB = b.uploadDate || "";
+      return dateB.localeCompare(dateA);
     });
+
+    // 4. Row Span 계산 (간단 로직: 위와 같으면 합침)
+    // 정렬된 상태에서 위에서 아래로 내려가며, 이전 행과 값이 같으면 현재 행의 span을 0으로 만들고 leader 행의 span을 증가시킴
+    
+    // 각 컬럼별 리더 행의 인덱스를 추적
+    const leaders = {
+      uploadId: 0,
+      docNo: 0,
+      pkgNo: 0,
+      revNo: 0,
+      stage: 0,
+      status: 0,
+      actvNo: 0,
+      crter: 0,
+      note1: 0,
+    };
+
+    for (let i = 1; i < rows.length; i++) {
+      const prev = rows[i - 1];
+      const curr = rows[i];
+
+      // 1) Upload ID (최상위)
+      const mergeUploadId = curr.uploadId === prev.uploadId;
+      if (mergeUploadId) {
+        rows[leaders.uploadId].spans.uploadId++;
+        curr.spans.uploadId = 0;
+      } else {
+        leaders.uploadId = i;
+      }
+
+      // 2) Document No (Upload ID에 종속)
+      const mergeDocNo = mergeUploadId && (curr.docNo === prev.docNo);
+      if (mergeDocNo) {
+        rows[leaders.docNo].spans.docNo++;
+        curr.spans.docNo = 0;
+      } else {
+        leaders.docNo = i;
+      }
+
+      // 3) PKG NO (Document에 종속)
+      const mergePkgNo = mergeDocNo && (curr.pkgNo === prev.pkgNo);
+      if (mergePkgNo) {
+        rows[leaders.pkgNo].spans.pkgNo++;
+        curr.spans.pkgNo = 0;
+      } else {
+        leaders.pkgNo = i;
+      }
+
+      // 4) Rev No (Document에 종속)
+      const mergeRevNo = mergeDocNo && (curr.revNo === prev.revNo);
+      if (mergeRevNo) {
+        rows[leaders.revNo].spans.revNo++;
+        curr.spans.revNo = 0;
+      } else {
+        leaders.revNo = i;
+      }
+
+      // 5) Stage (Rev No에 종속)
+      const mergeStage = mergeRevNo && (curr.stage === prev.stage);
+      if (mergeStage) {
+        rows[leaders.stage].spans.stage++;
+        curr.spans.stage = 0;
+      } else {
+        leaders.stage = i;
+      }
+
+      // 6) Activity (Rev No에 종속 - 보통 Activity는 Rev 안에 있음)
+      const mergeActvNo = mergeRevNo && (curr.actvSeq === prev.actvSeq);
+      if (mergeActvNo) {
+        rows[leaders.actvNo].spans.actvNo++;
+        curr.spans.actvNo = 0;
+      } else {
+        leaders.actvNo = i;
+      }
+
+      // 7) Status (Activity 내 파일들 - Activity에 종속)
+      // Activity가 같고 Status 값도 같으면 합침
+      const mergeStatus = mergeActvNo && (curr.status === prev.status);
+      if (mergeStatus) {
+        rows[leaders.status].spans.status++;
+        curr.spans.status = 0;
+      } else {
+        leaders.status = i;
+      }
+
+      // 8) CRTER (Activity에 종속)
+      const mergeCrter = mergeActvNo && (curr.crter === prev.crter);
+      if (mergeCrter) {
+        rows[leaders.crter].spans.crter++;
+        curr.spans.crter = 0;
+      } else {
+        leaders.crter = i;
+      }
+
+      // 9) DC Note (Activity에 종속)
+      const mergeNote1 = mergeActvNo && (curr.note1 === prev.note1);
+      if (mergeNote1) {
+        rows[leaders.note1].spans.note1++;
+        curr.spans.note1 = 0;
+      } else {
+        leaders.note1 = i;
+      }
+    }
+
+    return rows;
   }, [files, requiredDocs, selectedStatus]);
 
   // 선택 가능한 파일들 (Standby 상태만)
@@ -464,27 +537,27 @@ export function SwpInboxTable({
         </div>
       ) : (
         <div className="rounded-md border overflow-x-auto">
-          <Table>
+          <Table className="min-w-[1700px]">
             <TableHeader>
               <TableRow>
-                <TableHead className="w-[50px]">
+                <TableHead className="w-[50px] min-w-[50px]">
                   <Checkbox
                     checked={selectableFiles.length > 0 && selectedFiles.size === selectableFiles.length}
                     onCheckedChange={handleSelectAll}
                     disabled={selectableFiles.length === 0}
                   />
                 </TableHead>
-                <TableHead className="w-[100px]">Upload ID</TableHead>
-                <TableHead className="w-[200px]">Document No</TableHead>
-                <TableHead className="w-[120px]">PKG NO</TableHead>
-                <TableHead className="w-[80px]">Rev No</TableHead>
-                <TableHead className="w-[80px]">Stage</TableHead>
-                <TableHead className="w-[120px]">Status</TableHead>
-                <TableHead className="w-[100px]">Activity</TableHead>
-                <TableHead className="w-[120px]">Upload ID (User)</TableHead>
-                <TableHead className="w-[150px]">DC Note</TableHead>
-                <TableHead className="w-[400px]">Attachment File</TableHead>
-                <TableHead className="w-[180px]">Upload Date</TableHead>
+                <TableHead className="w-[100px] min-w-[100px]">Upload ID</TableHead>
+                <TableHead className="w-[200px] min-w-[200px]">Document No</TableHead>
+                <TableHead className="w-[120px] min-w-[120px]">PKG NO</TableHead>
+                <TableHead className="w-[80px] min-w-[80px]">Rev No</TableHead>
+                <TableHead className="w-[80px] min-w-[80px]">Stage</TableHead>
+                <TableHead className="w-[120px] min-w-[120px]">Status</TableHead>
+                <TableHead className="w-[100px] min-w-[100px]">Activity</TableHead>
+                <TableHead className="w-[120px] min-w-[120px]">Upload ID (User)</TableHead>
+                <TableHead className="w-[150px] min-w-[150px]">DC Note</TableHead>
+                <TableHead className="w-[800px] min-w-[400px]">Attachment File</TableHead>
+                <TableHead className="w-[180px] min-w-[180px]">Upload Date</TableHead>
               </TableRow>
             </TableHeader>
             <TableBody>
@@ -500,7 +573,7 @@ export function SwpInboxTable({
                     onClick={() => handleRowClick(row.docNo)}
                   >
                     {/* Select Checkbox */}
-                    <TableCell onClick={(e) => e.stopPropagation()}>
+                    <TableCell className="w-[50px] min-w-[50px]" onClick={(e) => e.stopPropagation()}>
                       {canSelect ? (
                         <Checkbox
                           checked={!!isSelected}
@@ -509,67 +582,99 @@ export function SwpInboxTable({
                       ) : null}
                     </TableCell>
 
-                    {/* Upload ID - Upload의 첫 파일에만 표시 */}
-                    {row.isFirstInUpload ? (
-                      <TableCell rowSpan={row.fileCountInUpload} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
+                    {/* Upload ID */}
+                    {row.spans.uploadId > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.uploadId} 
+                        className="w-[100px] min-w-[100px] font-mono text-sm align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.uploadId || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Document No - Document의 첫 파일에만 표시 */}
-                    {row.isFirstInDoc ? (
-                      <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
+                    {/* Document No */}
+                    {row.spans.docNo > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.docNo} 
+                        className="w-[200px] min-w-[200px] font-mono text-xs align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.docNo}
                       </TableCell>
                     ) : null}
 
-                    {/* PKG NO - Document의 첫 파일에만 표시 */}
-                    {row.isFirstInDoc ? (
-                      <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
+                    {/* PKG NO */}
+                    {row.spans.pkgNo > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.pkgNo} 
+                        className="w-[120px] min-w-[120px] font-mono text-sm align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.pkgNo || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Rev No - Rev의 첫 파일에만 표시 */}
-                    {row.isFirstInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
+                    {/* Rev No */}
+                    {row.spans.revNo > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.revNo} 
+                        className="w-[80px] min-w-[80px] align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.revNo || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Stage - Rev의 첫 파일에만 표시 */}
-                    {row.isFirstInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm" style={{ verticalAlign: "top" }}>
+                    {/* Stage */}
+                    {row.spans.stage > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.stage} 
+                        className="w-[80px] min-w-[80px] align-top text-sm"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.stage || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Status - Rev의 첫 파일에만 표시 */}
-                    {row.isFirstInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
+                    {/* Status */}
+                    {row.spans.status > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.status} 
+                        className="w-[120px] min-w-[120px] align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {getStatusBadge(row.status, row.statusNm)}
                       </TableCell>
                     ) : null}
 
-                    {/* Activity - Activity의 첫 파일에만 표시 */}
-                    {row.isFirstInActivity ? (
-                      <TableCell rowSpan={row.fileCountInActivity} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
+                    {/* Activity */}
+                    {row.spans.actvNo > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.actvNo} 
+                        className="w-[100px] min-w-[100px] font-mono text-xs align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.actvNo || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* CRTER (Upload ID User) - Activity의 첫 파일에만 표시 */}
-                    {row.isFirstInActivity ? (
-                      <TableCell rowSpan={row.fileCountInActivity} className="text-sm font-mono align-top" style={{ verticalAlign: "top" }}>
+                    {/* CRTER (Upload ID User) */}
+                    {row.spans.crter > 0 ? (
+                      <TableCell 
+                        rowSpan={row.spans.crter} 
+                        className="w-[120px] min-w-[120px] text-sm font-mono align-top"
+                        style={{ verticalAlign: "top" }}
+                      >
                         {row.crter || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* DC Note - Activity의 첫 파일에만 표시 */}
-                    {row.isFirstInActivity ? (
+                    {/* DC Note */}
+                    {row.spans.note1 > 0 ? (
                       <TableCell 
-                        rowSpan={row.fileCountInActivity} 
-                        className="text-xs max-w-[150px] align-top"
+                        rowSpan={row.spans.note1}
+                        className="w-[150px] min-w-[150px] text-xs align-top"
                         style={{ verticalAlign: "top" }}
                         onClick={(e) => {
                           if (row.note1) {
@@ -589,8 +694,8 @@ export function SwpInboxTable({
                       </TableCell>
                     ) : null}
 
-                    {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
-                    <TableCell className="max-w-[400px]">
+                    {/* Attachment File - 병합하지 않음 */}
+                    <TableCell className="w-[400px] min-w-[400px]">
                       {row.file ? (
                         <div className="flex items-center justify-between gap-2">
                           <span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}>
@@ -613,8 +718,8 @@ export function SwpInboxTable({
                       )}
                     </TableCell>
 
-                    {/* Upload Date - 각 파일마다 표시 */}
-                    <TableCell className="text-xs">
+                    {/* Upload Date - 병합하지 않음 */}
+                    <TableCell className="w-[180px] min-w-[180px] text-xs">
                       {row.uploadDate ? formatSwpDate(row.uploadDate) : <span className="text-muted-foreground">-</span>}
                     </TableCell>
                   </TableRow>
-- 
cgit v1.2.3

