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 +++++++++++++++++++---------- lib/swp/table/swp-table-toolbar.tsx | 41 ++++++- lib/swp/table/swp-table.tsx | 64 ++++++++++- lib/swp/table/swp-upload-result-dialog.tsx | 9 +- 4 files changed, 218 insertions(+), 70 deletions(-) (limited to 'lib/swp') 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(); 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(); 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(); + + 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 ( handleRowClick(row.docNo)} > @@ -460,60 +515,61 @@ export function SwpInboxTable({ ) : null} - {/* Upload ID - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* Upload ID - Upload의 첫 파일에만 표시 */} + {row.isFirstInUpload ? ( + {row.uploadId || -} ) : null} - {/* Document No - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* Document No - Document의 첫 파일에만 표시 */} + {row.isFirstInDoc ? ( + {row.docNo} ) : null} - {/* Rev No - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* Rev No - Rev의 첫 파일에만 표시 */} + {row.isFirstInRev ? ( + {row.revNo || -} ) : null} - {/* Stage - 같은 Rev의 첫 파일에만 표시 (텍스트로만 표시) */} - {row.isFirstFileInRev ? ( - + {/* Stage - Rev의 첫 파일에만 표시 */} + {row.isFirstInRev ? ( + {row.stage || -} ) : null} - {/* Status - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* Status - Rev의 첫 파일에만 표시 */} + {row.isFirstInRev ? ( + {getStatusBadge(row.status, row.statusNm)} ) : null} - {/* Activity - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* Activity - Activity의 첫 파일에만 표시 */} + {row.isFirstInActivity ? ( + {row.actvNo || -} ) : null} - {/* CRTER - 같은 Rev의 첫 파일에만 표시 */} - {row.isFirstFileInRev ? ( - + {/* CRTER (Upload ID User) - Activity의 첫 파일에만 표시 */} + {row.isFirstInActivity ? ( + {row.crter || -} ) : null} - {/* Note - 같은 Rev의 첫 파일에만 표시 (개행문자 처리) */} - {row.isFirstFileInRev ? ( + {/* Note - Activity의 첫 파일에만 표시 (개행문자 처리) */} + {row.isFirstInActivity ? ( {row.note || -} @@ -522,7 +578,7 @@ export function SwpInboxTable({ {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */} {row.file ? ( -
+
{row.file.FILE_NM} diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index 594bdd77..08eda3ef 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -18,7 +18,7 @@ import { SwpUploadValidationDialog, validateFileName } from "./swp-upload-validation-dialog"; -import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog"; +// import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog"; import { getDocumentClassInfoByProjectCode } from "@/lib/swp/swp-upload-server-actions"; import type { DocumentListItem } from "@/lib/swp/document-service"; @@ -36,6 +36,7 @@ interface SwpTableToolbarProps { onProjNoChange: (projNo: string) => void; onFiltersChange: (filters: SwpTableFilters) => void; onRefresh: () => void; + onUploadComplete?: () => void; // 업로드 완료 시 콜백 (탭 전환용) isRefreshing: boolean; projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>; vendorCode?: string; @@ -51,6 +52,7 @@ export function SwpTableToolbar({ onProjNoChange, onFiltersChange, onRefresh, + onUploadComplete, isRefreshing, projects = [], vendorCode, @@ -322,6 +324,26 @@ export function SwpTableToolbar({ title: result.success ? "업로드 완료" : "일부 업로드 실패", description: result.message, }); + + // 업로드 성공 시 자동 새로고침 (외부 시스템 처리 시간 고려) + if (result.success && result.successCount > 0) { + // 업로드 완료 시 Inbox 탭으로 전환 + onUploadComplete?.(); + + toast({ + title: "문서 목록 갱신 중", + description: "외부 시스템 처리를 기다리는 중입니다...", + }); + + // 2초 딜레이 후 새로고침 + setTimeout(() => { + onRefresh(); + toast({ + title: "갱신 완료", + description: "업로드된 파일이 문서 목록에 반영되었습니다.", + }); + }, 2000); + } } catch (error) { console.error("파일 업로드 실패:", error); @@ -442,6 +464,7 @@ export function SwpTableToolbar({ size="sm" onClick={handleReset} className="h-8" + disabled={isRefreshing} > 초기화 @@ -460,6 +483,7 @@ export function SwpTableToolbar({ role="combobox" aria-expanded={projectSearchOpen} className="w-full justify-between" + disabled={isRefreshing} > {projNo ? ( @@ -538,6 +562,7 @@ export function SwpTableToolbar({ onChange={(e) => setLocalFilters({ ...localFilters, docNo: e.target.value }) } + disabled={isRefreshing} />
@@ -551,6 +576,7 @@ export function SwpTableToolbar({ onChange={(e) => setLocalFilters({ ...localFilters, docTitle: e.target.value }) } + disabled={isRefreshing} />
@@ -564,6 +590,7 @@ export function SwpTableToolbar({ onChange={(e) => setLocalFilters({ ...localFilters, pkgNo: e.target.value }) } + disabled={isRefreshing} /> @@ -577,6 +604,7 @@ export function SwpTableToolbar({ onChange={(e) => setLocalFilters({ ...localFilters, stage: e.target.value }) } + disabled={isRefreshing} /> @@ -590,14 +618,19 @@ export function SwpTableToolbar({ onChange={(e) => setLocalFilters({ ...localFilters, status: e.target.value }) } + disabled={isRefreshing} />
-
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx index 21a1c775..b6c3558b 100644 --- a/lib/swp/table/swp-table.tsx +++ b/lib/swp/table/swp-table.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { useReactTable, getCoreRowModel, @@ -14,6 +14,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; import { swpDocumentColumns } from "./swp-table-columns"; import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog"; import type { DocumentListItem } from "@/lib/swp/document-service"; @@ -25,6 +26,12 @@ interface SwpTableProps { userId: string; } +// Status 집계 타입 +interface StatusCount { + status: string; + count: number; +} + export function SwpTable({ documents, projNo, @@ -33,9 +40,39 @@ export function SwpTable({ }: SwpTableProps) { const [dialogOpen, setDialogOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); + const [selectedStatus, setSelectedStatus] = useState(null); + + // Status 집계 + const statusCounts = useMemo(() => { + const statusMap = new Map(); + + documents.forEach((doc) => { + const status = doc.LTST_ACTV_STAT || "UNKNOWN"; + statusMap.set(status, (statusMap.get(status) || 0) + 1); + }); + + const counts: StatusCount[] = []; + statusMap.forEach((count, status) => { + counts.push({ + status, + count, + }); + }); + + // 개수 순으로 정렬 + return counts.sort((a, b) => b.count - a.count); + }, [documents]); + + // Status 필터링된 문서 목록 + const filteredDocuments = useMemo(() => { + if (!selectedStatus) { + return documents; + } + return documents.filter((doc) => doc.LTST_ACTV_STAT === selectedStatus); + }, [documents, selectedStatus]); const table = useReactTable({ - data: documents, + data: filteredDocuments, columns: swpDocumentColumns, getCoreRowModel: getCoreRowModel(), }); @@ -48,6 +85,29 @@ export function SwpTable({ return (
+ {/* Status 필터 UI */} +
+ + {statusCounts.map((statusCount) => ( + + ))} +
+ {/* 테이블 */}
diff --git a/lib/swp/table/swp-upload-result-dialog.tsx b/lib/swp/table/swp-upload-result-dialog.tsx index 7b79fa68..06caf66e 100644 --- a/lib/swp/table/swp-upload-result-dialog.tsx +++ b/lib/swp/table/swp-upload-result-dialog.tsx @@ -9,7 +9,6 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { CheckCircle2, XCircle, FileText } from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; interface UploadResult { fileName: string; @@ -34,7 +33,7 @@ export function SwpUploadResultDialog({ return ( - + 파일 업로드 결과 @@ -42,7 +41,7 @@ export function SwpUploadResultDialog({ - +
{results.map((result, index) => (
))}
- +
-
+
{failCount > 0 && ( -- cgit v1.2.3