From 1a8bf9c1c98454bd0e961b84d14299155ad67e7f Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Tue, 18 Nov 2025 12:15:43 +0900
Subject: (김준회) swp: 그룹핑 로직 수정요청사항 반영, 대용량 파일업로드
 formidable 사용한 스트리밍 방식으로 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/table/swp-inbox-table.tsx | 174 +++++++++++++++++++++++++-------------
 1 file changed, 115 insertions(+), 59 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 a2fedeed..a88ff656 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -45,12 +45,18 @@ interface TableRowData {
   statusNm: string | null;
   actvNo: string | null;
   crter: string | null; // CRTER (그대로 표시)
-  note: string | null; // 첫 번째 파일의 note 또는 buyerSystemComment
+  note: string | null; // Activity의 note 또는 buyerSystemComment
   file: SwpFileApiResponse | null; // 업로드 필요 문서는 null
   uploadDate: string | null;
-  // 각 행이 속한 그룹의 정보
-  isFirstFileInRev: boolean;
+  // 각 행이 속한 그룹의 정보 (계층적 rowSpan 처리)
+  isFirstInUpload: boolean;
+  fileCountInUpload: number;
+  isFirstInDoc: boolean;
+  fileCountInDoc: number;
+  isFirstInRev: boolean;
   fileCountInRev: number;
+  isFirstInActivity: boolean;
+  fileCountInActivity: number;
   // 업로드 필요 문서 여부
   isRequiredDoc: boolean;
 }
@@ -137,7 +143,7 @@ export function SwpInboxTable({
       filteredFiles = files.filter((file) => file.STAT === selectedStatus);
     }
 
-    // BOX_SEQ 기준으로 그룹화
+    // 1단계: BOX_SEQ (Upload ID) 기준으로 그룹화
     const uploadGroups = new Map<string, SwpFileApiResponse[]>();
     
     if (!selectedStatus || selectedStatus !== "UPLOAD_REQUIRED") {
@@ -150,8 +156,9 @@ export function SwpInboxTable({
       });
     }
 
+    // Upload ID별로 처리
     uploadGroups.forEach((uploadFiles, uploadId) => {
-      // 2. Document No 기준으로 그룹화
+      // 2단계: Document No 기준으로 그룹화
       const docGroups = new Map<string, SwpFileApiResponse[]>();
       
       uploadFiles.forEach((file) => {
@@ -162,44 +169,86 @@ export function SwpInboxTable({
         docGroups.get(docNo)!.push(file);
       });
 
+      // 전체 Upload ID의 파일 수 계산
+      const totalUploadFileCount = uploadFiles.length;
+      let isFirstInUpload = true;
+
+      // Document No별로 처리
       docGroups.forEach((docFiles, docNo) => {
-        // 3. 최신 RevNo 찾기 (CRTE_DTM 기준)
+        // 3단계: 최신 RevNo 찾기 (CRTE_DTM 기준)
         const sortedByDate = [...docFiles].sort((a, b) =>
           (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
         );
         const latestRevNo = sortedByDate[0]?.REV_NO || "";
+        const latestStage = sortedByDate[0]?.STAGE || null;
+        const latestStatus = sortedByDate[0]?.STAT || null;
+        const latestStatusNm = sortedByDate[0]?.STAT_NM || null;
 
-        // 4. 최신 Rev의 파일들만 필터링
+        // 4단계: 최신 Rev의 파일들만 필터링
         const latestRevFiles = docFiles.filter(
           (file) => file.REV_NO === latestRevNo
         );
 
-        // 5. Upload Date 기준 DESC 정렬
-        const sortedFiles = latestRevFiles.sort((a, b) =>
-          (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
-        );
+        const totalDocFileCount = latestRevFiles.length;
+        let isFirstInDoc = true;
+
+        // 5단계: Activity 기준으로 그룹화
+        const activityGroups = new Map<string, SwpFileApiResponse[]>();
+        
+        latestRevFiles.forEach((file) => {
+          const actvNo = file.ACTV_NO || "NO_ACTIVITY";
+          if (!activityGroups.has(actvNo)) {
+            activityGroups.set(actvNo, []);
+          }
+          activityGroups.get(actvNo)!.push(file);
+        });
 
-        // 6. 최신 파일의 정보로 Rev 메타데이터 설정 (첫 번째 파일의 crter와 note 사용)
-        const latestFile = sortedFiles[0];
-        if (!latestFile) return;
-
-        // 7. 각 파일을 테이블 행으로 변환 (crter와 note는 첫 번째 파일 것으로 통일)
-        sortedFiles.forEach((file, idx) => {
-          rows.push({
-            uploadId,
-            docNo,
-            revNo: latestRevNo,
-            stage: latestFile.STAGE,
-            status: latestFile.STAT,
-            statusNm: latestFile.STAT_NM,
-            actvNo: latestFile.ACTV_NO,
-            crter: latestFile.CRTER, // CRTER 그대로
-            note: latestFile.NOTE || null, // 첫 번째 파일의 note
-            file,
-            uploadDate: file.CRTE_DTM,
-            isFirstFileInRev: idx === 0,
-            fileCountInRev: sortedFiles.length,
-            isRequiredDoc: false,
+        let isFirstInRev = true;
+
+        // Activity별로 처리
+        activityGroups.forEach((activityFiles, actvNo) => {
+          // 6단계: 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;
+
+          // 7단계: 각 파일을 테이블 행으로 변환
+          sortedFiles.forEach((file, idx) => {
+            rows.push({
+              uploadId,
+              docNo,
+              revNo: latestRevNo,
+              stage: latestStage,
+              status: latestStatus,
+              statusNm: latestStatusNm,
+              actvNo: actvNo === "NO_ACTIVITY" ? null : actvNo,
+              crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER
+              note: firstActivityFile.NOTE || null, // Activity 첫 파일의 note
+              file,
+              uploadDate: file.CRTE_DTM,
+              isFirstInUpload,
+              fileCountInUpload: totalUploadFileCount,
+              isFirstInDoc,
+              fileCountInDoc: totalDocFileCount,
+              isFirstInRev,
+              fileCountInRev: totalDocFileCount, // Rev = Doc의 최신 Rev이므로 파일 수 동일
+              isFirstInActivity: idx === 0,
+              fileCountInActivity: totalActivityFileCount,
+              isRequiredDoc: false,
+            });
+
+            // 첫 번째 플래그들 업데이트
+            if (idx === 0) {
+              isFirstInUpload = false;
+              isFirstInDoc = false;
+              isFirstInRev = false;
+            }
           });
         });
       });
@@ -220,8 +269,14 @@ export function SwpInboxTable({
           note: doc.buyerSystemComment,
           file: null,
           uploadDate: null,
-          isFirstFileInRev: true,
+          isFirstInUpload: true,
+          fileCountInUpload: 1,
+          isFirstInDoc: true,
+          fileCountInDoc: 1,
+          isFirstInRev: true,
           fileCountInRev: 1,
+          isFirstInActivity: true,
+          fileCountInActivity: 1,
           isRequiredDoc: true,
         });
       });
@@ -446,7 +501,7 @@ export function SwpInboxTable({
 
                 return (
                   <TableRow 
-                    key={`${row.uploadId}_${row.docNo}_${row.revNo}_${idx}`}
+                    key={`${row.uploadId}_${row.docNo}_${row.revNo}_${row.actvNo}_${idx}`}
                     className="cursor-pointer hover:bg-muted/50"
                     onClick={() => handleRowClick(row.docNo)}
                   >
@@ -460,60 +515,61 @@ export function SwpInboxTable({
                       ) : null}
                     </TableCell>
 
-                    {/* Upload ID - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="font-mono text-sm align-top">
+                    {/* Upload ID - Upload의 첫 파일에만 표시 */}
+                    {row.isFirstInUpload ? (
+                      <TableCell rowSpan={row.fileCountInUpload} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
                         {row.uploadId || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Document No - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-top">
+                    {/* Document No - Document의 첫 파일에만 표시 */}
+                    {row.isFirstInDoc ? (
+                      <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
                         {row.docNo}
                       </TableCell>
                     ) : null}
 
-                    {/* Rev No - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top">
+                    {/* Rev No - Rev의 첫 파일에만 표시 */}
+                    {row.isFirstInRev ? (
+                      <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
                         {row.revNo || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Stage - 같은 Rev의 첫 파일에만 표시 (텍스트로만 표시) */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm">
+                    {/* Stage - Rev의 첫 파일에만 표시 */}
+                    {row.isFirstInRev ? (
+                      <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm" style={{ verticalAlign: "top" }}>
                         {row.stage || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Status - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="align-top">
+                    {/* Status - Rev의 첫 파일에만 표시 */}
+                    {row.isFirstInRev ? (
+                      <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
                         {getStatusBadge(row.status, row.statusNm)}
                       </TableCell>
                     ) : null}
 
-                    {/* Activity - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-top">
+                    {/* Activity - Activity의 첫 파일에만 표시 */}
+                    {row.isFirstInActivity ? (
+                      <TableCell rowSpan={row.fileCountInActivity} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
                         {row.actvNo || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* CRTER - 같은 Rev의 첫 파일에만 표시 */}
-                    {row.isFirstFileInRev ? (
-                      <TableCell rowSpan={row.fileCountInRev} className="text-sm font-mono align-top">
+                    {/* CRTER (Upload ID User) - Activity의 첫 파일에만 표시 */}
+                    {row.isFirstInActivity ? (
+                      <TableCell rowSpan={row.fileCountInActivity} className="text-sm font-mono align-top" style={{ verticalAlign: "top" }}>
                         {row.crter || <span className="text-muted-foreground">-</span>}
                       </TableCell>
                     ) : null}
 
-                    {/* Note - 같은 Rev의 첫 파일에만 표시 (개행문자 처리) */}
-                    {row.isFirstFileInRev ? (
+                    {/* Note - Activity의 첫 파일에만 표시 (개행문자 처리) */}
+                    {row.isFirstInActivity ? (
                       <TableCell 
-                        rowSpan={row.fileCountInRev} 
+                        rowSpan={row.fileCountInActivity} 
                         className="text-xs max-w-[150px] align-top whitespace-pre-wrap"
+                        style={{ verticalAlign: "top" }}
                       >
                         {row.note || <span className="text-muted-foreground">-</span>}
                       </TableCell>
@@ -522,7 +578,7 @@ export function SwpInboxTable({
                     {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
                     <TableCell className="max-w-[400px]">
                       {row.file ? (
-                        <div className="flex items-center gap-2">
+                        <div className="flex items-center justify-between gap-2">
                           <span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}>
                             {row.file.FILE_NM}
                           </span>
-- 
cgit v1.2.3

