diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-17 20:47:44 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-17 20:47:44 +0900 |
| commit | e6403e9610253b2bfc7a46e470c236e2ed10c626 (patch) | |
| tree | 05f7e458ac8b0f3bef56553e4cfbd666f5134042 /lib/swp/table | |
| parent | f56eb799ecdcd2baae1db26460cd1deff0a69eee (diff) | |
(김준회) swp: VxDCS 와 동일하게 변경, Complete된 리스트 중 없는 건이 있으면 DOCUMENT LIST에서 보여줌
Diffstat (limited to 'lib/swp/table')
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 567 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-history-dialog.tsx | 509 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-table.tsx | 703 |
3 files changed, 1333 insertions, 446 deletions
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx index 418ddea9..d69e2986 100644 --- a/lib/swp/table/swp-document-detail-dialog.tsx +++ b/lib/swp/table/swp-document-detail-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, @@ -12,20 +12,31 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Loader2, - ChevronDown, - ChevronRight, Download, FileIcon, - XCircle, AlertCircle, + ArrowDownToLine, + ArrowUpFromLine, } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { - fetchVendorDocumentDetail, - cancelVendorFile, - downloadVendorFile, -} from "@/lib/swp/vendor-actions"; -import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service"; + fetchGetRevTreeCompleteList, + parseRevisionTree, + fetchGetActivityFileList, + type ActivityFileApiResponse, +} from "@/lib/swp/api-client"; +import { downloadVendorFile } from "@/lib/swp/vendor-actions"; +import type { DocumentListItem } from "@/lib/swp/document-service"; import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { formatSwpDateShort, formatFileSize } from "@/lib/swp/utils"; interface SwpDocumentDetailDialogProps { open: boolean; @@ -36,22 +47,41 @@ interface SwpDocumentDetailDialogProps { userId: string; } +// Activity 행 데이터 +interface ActivityRow { + revNo: string; + revSeq: string; + stage: string; + actvNo: string; + inOut: "IN" | "OUT"; + statusCode: string; + statusName: string; + transmittalNo: string; + refActivityNo: string; + createDate: string; + createEmpNo: string; +} + export function SwpDocumentDetailDialog({ open, onOpenChange, document, projNo, }: SwpDocumentDetailDialogProps) { - const [detail, setDetail] = useState<DocumentDetail | null>(null); + const [activities, setActivities] = useState<ActivityRow[]>([]); const [isLoading, setIsLoading] = useState(false); - const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set()); - const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set()); - const [isAllExpanded, setIsAllExpanded] = useState(true); // 기본값 true + const [selectedActivity, setSelectedActivity] = useState<ActivityRow | null>(null); + const [activityFiles, setActivityFiles] = useState<ActivityFileApiResponse[]>([]); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); // 문서 상세 로드 useEffect(() => { if (open && document) { loadDocumentDetail(); + } else { + // 다이얼로그 닫힐 때 초기화 + setSelectedActivity(null); + setActivityFiles([]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, document?.DOC_NO]); @@ -60,100 +90,79 @@ export function SwpDocumentDetailDialog({ if (!document) return; setIsLoading(true); + setSelectedActivity(null); + setActivityFiles([]); + try { - const detailData = await fetchVendorDocumentDetail(projNo, document.DOC_NO); - setDetail(detailData); - - // 모든 리비전 자동 펼치기 - const allRevKeys = new Set<string>(); - const allActKeys = new Set<string>(); - - detailData.revisions.forEach((revision) => { - const revKey = `${revision.revNo}|${revision.revSeq}`; - allRevKeys.add(revKey); - - // 모든 액티비티도 자동 펼치기 - revision.activities.forEach((activity) => { - const actKey = `${revKey}|${activity.actvNo}`; - allActKeys.add(actKey); + // GetRevTreeCompleteList 호출 + const tree = await fetchGetRevTreeCompleteList({ + proj_no: projNo, + doc_no: document.DOC_NO, + }); + + const parsed = await parseRevisionTree(tree); + + // Activity를 flat한 배열로 변환 (테이블용) + const flatActivities: ActivityRow[] = []; + parsed.revisions.forEach((rev) => { + rev.activities.forEach((act) => { + flatActivities.push({ + revNo: rev.revNo, + revSeq: rev.revSeq, + stage: rev.stage, + actvNo: act.actvNo, + inOut: act.inOut, + statusCode: act.statusCode, + statusName: act.statusName, + transmittalNo: act.transmittalNo, + refActivityNo: act.refActivityNo, + createDate: act.createDate, + createEmpNo: act.createEmpNo, + }); }); }); - - setExpandedRevisions(allRevKeys); - setExpandedActivities(allActKeys); - setIsAllExpanded(true); + + setActivities(flatActivities); } catch (error) { console.error("문서 상세 조회 실패:", error); - toast.error("문서 상세 정보를 불러오는데 실패했습니다"); + toast.error("문서 리비전 트리를 불러오는데 실패했습니다"); } finally { setIsLoading(false); } }; - 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; - }); - }; + // Activity 선택 및 파일 로드 + const handleActivityClick = async (activity: ActivityRow) => { + if (selectedActivity?.actvNo === activity.actvNo) { + // 같은 Activity 클릭 시 토글 + setSelectedActivity(null); + setActivityFiles([]); + return; + } - // 일괄 열기/닫기 - const handleToggleAll = () => { - if (!detail) return; + setSelectedActivity(activity); + setIsLoadingFiles(true); + setActivityFiles([]); - if (isAllExpanded) { - // 모두 닫기 - setExpandedRevisions(new Set()); - setExpandedActivities(new Set()); - setIsAllExpanded(false); - } else { - // 모두 열기 - const allRevKeys = new Set<string>(); - const allActKeys = new Set<string>(); - - detail.revisions.forEach((revision) => { - const revKey = `${revision.revNo}|${revision.revSeq}`; - allRevKeys.add(revKey); - - revision.activities.forEach((activity) => { - const actKey = `${revKey}|${activity.actvNo}`; - allActKeys.add(actKey); - }); + try { + // GetActivityFileList 호출 + const files = await fetchGetActivityFileList({ + proj_no: projNo, + doc_no: document?.DOC_NO || "", + rev_seq: activity.revSeq, }); - - setExpandedRevisions(allRevKeys); - setExpandedActivities(allActKeys); - setIsAllExpanded(true); - } - }; - const handleCancelFile = async (boxSeq: string, actvSeq: string, fileName: string) => { - try { - await cancelVendorFile(boxSeq, actvSeq); - toast.success(`파일 취소 완료: ${fileName}`); - - // 문서 상세 재로드 - await loadDocumentDetail(); + // 해당 Activity의 파일만 필터링 + const activitySpecificFiles = files.filter( + (f) => f.ACTV_NO === activity.actvNo + ); + + setActivityFiles(activitySpecificFiles); } catch (error) { - console.error("파일 취소 실패:", error); - toast.error("파일 취소에 실패했습니다"); + console.error("파일 목록 조회 실패:", error); + toast.error("파일 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingFiles(false); } }; @@ -185,11 +194,24 @@ export function SwpDocumentDetailDialog({ } }; + // Revision별로 Activity 그룹핑 (rowspan용) + const groupedActivities = useMemo(() => { + const groups: Map<string, ActivityRow[]> = new Map(); + activities.forEach((activity) => { + const key = `${activity.revNo}|${activity.stage}`; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(activity); + }); + return groups; + }, [activities]); + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogContent className="max-w-[95vw] max-h-[80vh] overflow-hidden flex flex-col"> <DialogHeader> - <DialogTitle>문서 상세</DialogTitle> + <DialogTitle>문서 리비전 히스토리</DialogTitle> {document && ( <DialogDescription> {document.DOC_NO} - {document.DOC_TITLE} @@ -198,9 +220,9 @@ export function SwpDocumentDetailDialog({ </DialogHeader> {document && ( - <div className="space-y-4"> + <div className="flex-1 flex flex-col space-y-4 overflow-hidden min-h-0"> {/* 문서 정보 */} - <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg"> + <div className="grid grid-cols-1 md:grid-cols-5 gap-4 p-4 bg-muted/30 rounded-lg"> <div> <span className="text-sm font-semibold">프로젝트:</span> <div className="text-sm">{document.PROJ_NO}</div> @@ -223,174 +245,216 @@ export function SwpDocumentDetailDialog({ <span className="text-sm font-semibold">최신 리비전:</span> <div className="text-sm">{document.LTST_REV_NO || "-"}</div> </div> + <div> + <span className="text-sm font-semibold">총 Activity:</span> + <div className="text-sm">{activities.length}개</div> + </div> </div> - {/* 리비전 및 액티비티 트리 */} + {/* Activity 테이블 */} {isLoading ? ( <div className="flex items-center justify-center p-8"> <Loader2 className="h-6 w-6 animate-spin" /> - <span className="ml-2">문서 상세 로딩 중...</span> + <span className="ml-2">리비전 트리 로딩 중...</span> </div> - ) : detail && detail.revisions.length > 0 ? ( - <div className="space-y-2"> - {/* 일괄 열기/닫기 버튼 */} - <div className="flex justify-end"> - <Button - variant="outline" - size="sm" - onClick={handleToggleAll} - > - {isAllExpanded ? ( + ) : activities.length > 0 ? ( + <> + {/* Activity 테이블 (위) */} + <div className="flex-1 overflow-auto border rounded-lg min-h-0"> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[80px]">Rev</TableHead> + <TableHead className="w-[80px]">Stage</TableHead> + <TableHead className="w-[80px]">IN/OUT</TableHead> + <TableHead className="w-[100px]">Status</TableHead> + <TableHead className="min-w-[150px]">Transmittal No</TableHead> + <TableHead className="min-w-[150px]">Activity No</TableHead> + <TableHead className="min-w-[100px]">Ref Activity</TableHead> + <TableHead className="w-[120px]">Modified</TableHead> + <TableHead className="w-[80px]">By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {Array.from(groupedActivities.entries()).map(([key, groupActivities]) => { + const [revNo, stage] = key.split("|"); + return groupActivities.map((activity, idx) => ( + <TableRow + key={activity.actvNo} + className={cn( + "cursor-pointer hover:bg-muted/50", + selectedActivity?.actvNo === activity.actvNo && + "bg-blue-50 hover:bg-blue-100" + )} + onClick={() => handleActivityClick(activity)} + > + {/* Rev 컬럼 (첫 행만 표시, rowspan) */} + {idx === 0 && ( + <TableCell + className="font-mono text-sm font-semibold align-top border-r" + rowSpan={groupActivities.length} + > + {revNo} + </TableCell> + )} + {/* Stage 컬럼 (첫 행만 표시, rowspan) */} + {idx === 0 && ( + <TableCell + className="align-top border-r" + rowSpan={groupActivities.length} + > + <Badge + variant="outline" + className={ + stage === "IFC" + ? "bg-green-100 text-green-800" + : stage === "IFA" + ? "bg-blue-100 text-blue-800" + : "bg-gray-100 text-gray-800" + } + > + {stage} + </Badge> + </TableCell> + )} + <TableCell> + <Badge + variant="outline" + className={ + activity.inOut === "IN" + ? "bg-blue-100 text-blue-800" + : "bg-green-100 text-green-800" + } + > + {activity.inOut === "IN" ? ( + <> + <ArrowDownToLine className="h-3 w-3 mr-1" /> + IN + </> + ) : ( + <> + <ArrowUpFromLine className="h-3 w-3 mr-1" /> + OUT + </> + )} + </Badge> + </TableCell> + <TableCell> + <div className="text-sm"> + <div className="font-medium">{activity.statusName}</div> + <div className="text-xs text-muted-foreground"> + {activity.statusCode} + </div> + </div> + </TableCell> + <TableCell className="text-sm"> + {activity.transmittalNo || "-"} + </TableCell> + <TableCell className="font-mono text-xs"> + {activity.actvNo} + </TableCell> + <TableCell className="font-mono text-xs"> + {activity.refActivityNo || "-"} + </TableCell> + <TableCell className="text-xs"> + {formatSwpDateShort(activity.createDate)} + </TableCell> + <TableCell className="text-xs"> + {activity.createEmpNo} + </TableCell> + </TableRow> + )); + })} + </TableBody> + </Table> + </div> + + {/* 파일 목록 (아래) */} + <div className="border rounded-lg overflow-hidden" style={{ height: "250px" }}> + <div className="p-3 bg-muted/50 border-b"> + <h3 className="font-semibold text-sm">파일 목록</h3> + {selectedActivity ? ( <> - <ChevronDown className="h-4 w-4 mr-2" /> - 일괄 닫기 + <p className="text-xs text-muted-foreground mt-1"> + Activity: {selectedActivity.actvNo} + </p> + <p className="text-xs text-muted-foreground"> + Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut} + </p> </> ) : ( - <> - <ChevronRight className="h-4 w-4 mr-2" /> - 일괄 열기 - </> + <p className="text-xs text-muted-foreground mt-1"> + Activity를 선택하면 파일 목록이 표시됩니다 + </p> )} - </Button> - </div> - {detail.revisions.map((revision) => { - const revKey = `${revision.revNo}|${revision.revSeq}`; - 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 className="overflow-auto p-3" style={{ height: "calc(250px - 80px)" }}> + {selectedActivity ? ( + isLoadingFiles ? ( + <div className="flex items-center justify-center h-full"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span className="ml-2 text-sm">파일 로딩 중...</span> </div> - </div> - - {/* 액티비티 목록 */} - {isRevExpanded && ( - <div className="p-2 space-y-2"> - {revision.activities.map((activity) => { - const actKey = `${revKey}|${activity.actvNo}`; - const isActExpanded = expandedActivities.has(actKey); - - // Activity 타입에 따른 색상 - const activityColor = - activity.type === "Receive" ? "bg-blue-100 text-blue-800" : - activity.type === "Send" ? "bg-green-100 text-green-800" : - "bg-purple-100 text-purple-800"; - - 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" /> - )} - <Badge variant="outline" className={activityColor}> - {activity.type} - </Badge> - <span className="text-xs text-muted-foreground font-mono"> - {activity.actvNo} - </span> - <span className="text-sm text-muted-foreground"> - {activity.toFrom} - </span> - <span className="text-xs text-muted-foreground"> - {activity.files.length}개 파일 - </span> + ) : activityFiles.length > 0 ? ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> + {activityFiles.map((file) => ( + <div + key={file.FILE_SEQ} + className="flex flex-col gap-2 p-3 border rounded bg-background hover:bg-muted/30" + > + <div className="flex items-start gap-2"> + <FileIcon className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-sm font-medium break-all line-clamp-2"> + {file.FILE_NM} </div> + {file.FILE_SZ && ( + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.FILE_SZ)} + </div> + )} + {file.STAT && ( + <Badge variant="outline" className="text-xs mt-1"> + {file.STAT} + </Badge> + )} </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.fileNm}</span> - {file.fileSz && ( - <span className="text-xs text-muted-foreground"> - ({formatFileSize(file.fileSz)}) - </span> - )} - {file.stat && ( - <Badge variant="outline" className={ - file.stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : - file.stat === "SCW03" ? "bg-green-100 text-green-800" : - file.stat === "SCW09" ? "bg-gray-100 text-gray-800" : - "bg-gray-100 text-gray-800" - }> - {file.statNm || file.stat} - </Badge> - )} - </div> - <div className="flex items-center gap-1"> - {file.canCancel && file.boxSeq && file.actvSeq && ( - <Button - variant="outline" - size="sm" - onClick={() => handleCancelFile(file.boxSeq!, file.actvSeq!, file.fileNm)} - > - <XCircle className="h-4 w-4 mr-1" /> - 취소 - </Button> - )} - <Button - variant="outline" - size="sm" - onClick={() => handleDownloadFile(file.fileNm, document.DOC_NO)} - > - <Download className="h-4 w-4 mr-1" /> - 다운로드 - </Button> - </div> - </div> - ))} - </div> - )} </div> - ); - })} + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => handleDownloadFile(file.FILE_NM, document.OWN_DOC_NO || document.DOC_NO)} + > + <Download className="h-3 w-3 mr-1" /> + 다운로드 + </Button> + </div> + ))} </div> - )} - </div> - ); - })} - </div> + ) : ( + <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + <div className="text-center"> + <AlertCircle className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p>파일이 없습니다</p> + </div> + </div> + ) + ) : ( + <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + <div className="text-center"> + <FileIcon className="h-12 w-12 mx-auto mb-2 opacity-30" /> + <p>Activity를 선택해주세요</p> + </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> + <p>Activity 정보가 없습니다</p> </div> )} </div> @@ -399,14 +463,3 @@ export function SwpDocumentDetailDialog({ </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-history-dialog.tsx b/lib/swp/table/swp-inbox-history-dialog.tsx new file mode 100644 index 00000000..fbb75f3c --- /dev/null +++ b/lib/swp/table/swp-inbox-history-dialog.tsx @@ -0,0 +1,509 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronDown, + ChevronRight, + Download, + FileIcon, + Search, + XCircle, + Loader2, +} from "lucide-react"; +import { cancelVendorUploadedFile } from "@/lib/swp/vendor-actions"; +import type { SwpFileApiResponse } from "@/lib/swp/api-client"; +import { toast } from "sonner"; +import { formatSwpDate } from "@/lib/swp/utils"; + +interface SwpInboxHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + docNo: string | null; + files: SwpFileApiResponse[]; // 전체 파일 목록 + projNo: string; + userId: string; +} + +// Rev별 그룹 타입 +interface RevisionGroup { + revNo: string; + stage: string; + activities: ActivityGroup[]; + totalFiles: number; +} + +// Activity별 그룹 타입 (activity가 null일 수 있음) +interface ActivityGroup { + actvNo: string | null; + files: SwpFileApiResponse[]; +} + +export function SwpInboxHistoryDialog({ + open, + onOpenChange, + docNo, + files, + projNo, + userId, +}: SwpInboxHistoryDialogProps) { + const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set()); + const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set()); + const [isAllExpanded, setIsAllExpanded] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set()); + const [cancelledFiles, setCancelledFiles] = useState<Set<string>>(new Set()); // 취소된 파일 추적 + + // 해당 Document No의 모든 파일 필터링 + const documentFiles = useMemo(() => { + if (!docNo) return []; + return files.filter((file) => file.OWN_DOC_NO === docNo); + }, [docNo, files]); + + // 검색어 필터링 + const filteredFiles = useMemo(() => { + if (!searchQuery.trim()) return documentFiles; + + const query = searchQuery.toLowerCase(); + return documentFiles.filter((file) => { + return ( + file.REV_NO?.toLowerCase().includes(query) || + file.ACTV_NO?.toLowerCase().includes(query) || + file.FILE_NM?.toLowerCase().includes(query) || + file.STAGE?.toLowerCase().includes(query) + ); + }); + }, [documentFiles, searchQuery]); + + // 파일들을 Rev > Activity 구조로 그룹핑 + const revisionGroups = useMemo(() => { + const revMap = new Map<string, SwpFileApiResponse[]>(); + + filteredFiles.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) => { + // Upload Date 기준 정렬 + const sortedFiles = [...files].sort((a, b) => + (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "") + ); + activities.push({ actvNo, files: sortedFiles }); + }); + + // 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)); + }, [filteredFiles]); + + // 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 (file: SwpFileApiResponse) => { + if (!file.BOX_SEQ || !file.ACTV_SEQ) { + toast.error("취소할 수 없는 파일입니다 (BOX_SEQ 또는 ACTV_SEQ 없음)"); + return; + } + + const fileKey = `${file.BOX_SEQ}_${file.FILE_SEQ}`; + + if (cancellingFiles.has(fileKey)) { + return; // 이미 취소 중 + } + + try { + setCancellingFiles((prev) => new Set(prev).add(fileKey)); + + await cancelVendorUploadedFile({ + boxSeq: file.BOX_SEQ, + actvSeq: file.ACTV_SEQ, + userId, + }); + + toast.success(`파일 취소 완료: ${file.FILE_NM}`); + + // 취소된 파일로 마킹 (상태 변경) + setCancelledFiles((prev) => new Set(prev).add(fileKey)); + } catch (error) { + console.error("파일 취소 실패:", error); + toast.error("파일 취소에 실패했습니다"); + } finally { + setCancellingFiles((prev) => { + const newSet = new Set(prev); + newSet.delete(fileKey); + return newSet; + }); + } + }; + + const handleDownloadFile = async (file: SwpFileApiResponse) => { + try { + toast.info("파일 다운로드 준비 중..."); + + // API route를 통해 다운로드 + const downloadUrl = `/api/swp/download/${encodeURIComponent(file.OWN_DOC_NO)}?projNo=${encodeURIComponent(projNo)}&fileName=${encodeURIComponent(file.FILE_NM)}`; + + // 새 탭에서 다운로드 + window.open(downloadUrl, "_blank"); + + toast.success(`파일 다운로드 시작: ${file.FILE_NM}`); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }; + + const formatFileSize = (sizeStr: string | null): string => { + if (!sizeStr) return "-"; + 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`; + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Document 전체 이력</DialogTitle> + {docNo && ( + <DialogDescription> + {docNo} - 총 {documentFiles.length}개 파일 + </DialogDescription> + )} + </DialogHeader> + + {docNo && ( + <div className="space-y-4"> + {/* 검색 및 제어 */} + <div className="flex items-center gap-2"> + <div className="relative flex-1"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="Rev No, Activity No, File Name, Stage로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-10" + /> + </div> + <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> + + {/* 검색 결과 안내 */} + {searchQuery && ( + <div className="text-sm text-muted-foreground"> + 검색 결과: {filteredFiles.length}개 파일 (전체 {documentFiles.length}개) + </div> + )} + + {/* 리비전 및 액티비티 트리 */} + {revisionGroups.length > 0 ? ( + <div className="space-y-2"> + {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"> + {revision.stage} + </Badge> + <span className="text-sm text-muted-foreground"> + {revision.activities.length}개 그룹 / {revision.totalFiles}개 파일 + </span> + </div> + </div> + + {/* 액티비티 목록 */} + {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) => { + const fileKey = `${file.BOX_SEQ}_${file.FILE_SEQ}`; + const isCancelling = cancellingFiles.has(fileKey); + const isCancelled = cancelledFiles.has(fileKey); + const currentStatus = isCancelled ? "SCW09" : file.STAT; + const currentStatusNm = isCancelled ? "Cancelled" : file.STAT_NM; + const canCancel = currentStatus === "SCW01"; // Standby만 취소 가능 + + return ( + <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 min-w-0"> + <FileIcon className="h-4 w-4 text-blue-500 flex-shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-sm font-mono truncate" title={file.FILE_NM}> + {file.FILE_NM} + </div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>{formatFileSize(file.FILE_SZ)}</span> + <span>•</span> + <span>{formatSwpDate(file.CRTE_DTM)}</span> + {currentStatusNm && ( + <> + <span>•</span> + <Badge + variant="outline" + className={ + currentStatus === "SCW03" || currentStatus === "SCW08" ? "bg-green-100 text-green-800" : + currentStatus === "SCW02" ? "bg-blue-100 text-blue-800" : + currentStatus === "SCW01" ? "bg-yellow-100 text-yellow-800" : + currentStatus === "SCW04" || currentStatus === "SCW05" || currentStatus === "SCW06" ? "bg-red-100 text-red-800" : + currentStatus === "SCW07" ? "bg-purple-100 text-purple-800" : + currentStatus === "SCW09" ? "bg-gray-100 text-gray-800" : + currentStatus === "SCW00" ? "bg-orange-100 text-orange-800" : + "bg-gray-100 text-gray-800" + } + > + {currentStatusNm} + </Badge> + </> + )} + </div> + </div> + </div> + <div className="flex items-center gap-1 ml-2 flex-shrink-0"> + {canCancel && ( + <Button + variant="outline" + size="sm" + onClick={() => handleCancelFile(file)} + disabled={isCancelling} + > + {isCancelling ? ( + <> + <Loader2 className="h-4 w-4 mr-1 animate-spin" /> + 취소 중... + </> + ) : ( + <> + <XCircle className="h-4 w-4 mr-1" /> + 취소 + </> + )} + </Button> + )} + <Button + variant="ghost" + size="sm" + onClick={() => handleDownloadFile(file)} + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + ) : ( + <div className="p-8 text-center text-muted-foreground"> + {searchQuery ? "검색 결과가 없습니다" : "파일 정보가 없습니다"} + </div> + )} + </div> + )} + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx index c3f3a243..a2fedeed 100644 --- a/lib/swp/table/swp-inbox-table.tsx +++ b/lib/swp/table/swp-inbox-table.tsx @@ -1,11 +1,6 @@ "use client"; -import React, { useState, useMemo } from "react"; -import { - useReactTable, - getCoreRowModel, - flexRender, -} from "@tanstack/react-table"; +import React, { useMemo, useState } from "react"; import { Table, TableBody, @@ -15,230 +10,560 @@ import { 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 { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Download, XCircle } from "lucide-react"; +import { cancelVendorUploadedFile } from "@/lib/swp/vendor-actions"; import type { SwpFileApiResponse } from "@/lib/swp/api-client"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { formatSwpDate } from "@/lib/swp/utils"; +import { SwpInboxHistoryDialog } from "./swp-inbox-history-dialog"; + +// 업로드 필요 문서 타입 (DB stageDocuments에서 조회) +interface RequiredDocument { + vendorDocNumber: string; + title: string; + buyerSystemComment: string | null; +} interface SwpInboxTableProps { files: SwpFileApiResponse[]; + requiredDocs: RequiredDocument[]; 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[]; +// 테이블 행 데이터 (플랫하게 펼침) +interface TableRowData { + uploadId: string | null; // 업로드 필요 문서는 null + docNo: string; + revNo: string | null; + stage: string | null; + status: string | null; + statusNm: string | null; + actvNo: string | null; + crter: string | null; // CRTER (그대로 표시) + note: string | null; // 첫 번째 파일의 note 또는 buyerSystemComment + file: SwpFileApiResponse | null; // 업로드 필요 문서는 null + uploadDate: string | null; + // 각 행이 속한 그룹의 정보 + isFirstFileInRev: boolean; + fileCountInRev: number; + // 업로드 필요 문서 여부 + isRequiredDoc: boolean; } -// 테이블 컬럼 정의 -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, - }, -]; +// Status 집계 타입 +interface StatusCount { + status: string; + statusNm: string; + count: number; + color: string; +} export function SwpInboxTable({ files, + requiredDocs, 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[]>(); + const [selectedStatus, setSelectedStatus] = useState<string | null>(null); + const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); // 선택된 파일 (fileKey) + const [historyDialogOpen, setHistoryDialogOpen] = useState(false); + const [selectedDocNo, setSelectedDocNo] = useState<string | null>(null); + // Status 집계 (API 응답 + 업로드 필요 문서) + const statusCounts = useMemo(() => { + const statusMap = new Map<string, { statusNm: string; count: number }>(); + + // API 응답 파일 집계 files.forEach((file) => { - const docNo = file.OWN_DOC_NO; - if (!docMap.has(docNo)) { - docMap.set(docNo, []); + const status = file.STAT || "UNKNOWN"; + const statusNm = file.STAT_NM || status; + + if (statusMap.has(status)) { + statusMap.get(status)!.count++; + } else { + statusMap.set(status, { statusNm, count: 1 }); } - docMap.get(docNo)!.push(file); }); - const result: InboxDocumentItem[] = []; + // 업로드 필요 문서 집계 + if (requiredDocs.length > 0) { + const status = "UPLOAD_REQUIRED"; + const statusNm = "Upload Required"; + statusMap.set(status, { statusNm, count: requiredDocs.length }); + } - 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 || ""; + const counts: StatusCount[] = []; + statusMap.forEach((value, status) => { + const color = + status === "SCW03" || status === "SCW08" ? "bg-green-100 text-green-800 hover:bg-green-200" : + status === "SCW02" ? "bg-blue-100 text-blue-800 hover:bg-blue-200" : + status === "SCW01" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-200" : + status === "SCW04" || status === "SCW05" || status === "SCW06" ? "bg-red-100 text-red-800 hover:bg-red-200" : + status === "SCW07" ? "bg-purple-100 text-purple-800 hover:bg-purple-200" : + status === "SCW09" ? "bg-gray-100 text-gray-800 hover:bg-gray-200" : + status === "SCW00" ? "bg-orange-100 text-orange-800 hover:bg-orange-200" : + status === "UPLOAD_REQUIRED" ? "bg-amber-100 text-amber-800 hover:bg-amber-200" : + "bg-gray-100 text-gray-800 hover:bg-gray-200"; - // 최신 REV의 파일들만 필터링 - const latestRevFiles = docFiles.filter(file => file.REV_NO === latestRevNo); + counts.push({ + status, + statusNm: value.statusNm, + count: value.count, + color, + }); + }); - // 최신 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, // 전체 파일 목록 (상세보기용) + // 개수 순으로 정렬 (Upload Required를 맨 앞으로) + return counts.sort((a, b) => { + if (a.status === "UPLOAD_REQUIRED") return -1; + if (b.status === "UPLOAD_REQUIRED") return 1; + return b.count - a.count; + }); + }, [files, requiredDocs]); + + // 데이터 그룹화 및 플랫 변환 (API 응답 + 업로드 필요 문서) + const tableRows = useMemo(() => { + const rows: TableRowData[] = []; + + // 1. API 응답 파일 처리 + // Status 필터링 + let filteredFiles = files; + if (selectedStatus && selectedStatus !== "UPLOAD_REQUIRED") { + filteredFiles = files.filter((file) => file.STAT === selectedStatus); + } + + // BOX_SEQ 기준으로 그룹화 + 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); + }); + } + + uploadGroups.forEach((uploadFiles, uploadId) => { + // 2. Document No 기준으로 그룹화 + const docGroups = new Map<string, SwpFileApiResponse[]>(); + + uploadFiles.forEach((file) => { + const docNo = file.OWN_DOC_NO; + if (!docGroups.has(docNo)) { + docGroups.set(docNo, []); + } + docGroups.get(docNo)!.push(file); + }); + + docGroups.forEach((docFiles, docNo) => { + // 3. 최신 RevNo 찾기 (CRTE_DTM 기준) + const sortedByDate = [...docFiles].sort((a, b) => + (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "") + ); + const latestRevNo = sortedByDate[0]?.REV_NO || ""; + + // 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 || "") + ); + + // 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, + }); + }); }); }); - return result.sort((a, b) => a.ownDocNo.localeCompare(b.ownDocNo)); - }, [files]); + // 2. 업로드 필요 문서 추가 (Upload Required 필터일 때만 또는 필터 없을 때) + if (!selectedStatus || selectedStatus === "UPLOAD_REQUIRED") { + requiredDocs.forEach((doc) => { + rows.push({ + uploadId: null, + docNo: doc.vendorDocNumber, + revNo: null, + stage: null, + status: "UPLOAD_REQUIRED", + statusNm: "Upload Required", + actvNo: null, + crter: null, + note: doc.buyerSystemComment, + file: null, + uploadDate: null, + isFirstFileInRev: true, + fileCountInRev: 1, + isRequiredDoc: true, + }); + }); + } + + // Upload Date 기준 전체 정렬 (null은 맨 뒤로) + return rows.sort((a, b) => { + if (!a.uploadDate) return 1; + if (!b.uploadDate) return -1; + return b.uploadDate.localeCompare(a.uploadDate); + }); + }, [files, requiredDocs, selectedStatus]); + + // 선택 가능한 파일들 (Standby 상태만) + const selectableFiles = useMemo(() => { + return tableRows + .filter((row) => row.file && row.status === "SCW01") + .map((row) => `${row.file!.BOX_SEQ}_${row.file!.FILE_SEQ}`); + }, [tableRows]); + + // 전체 선택/해제 + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedFiles(new Set(selectableFiles)); + } else { + setSelectedFiles(new Set()); + } + }; + + // 개별 선택/해제 + const handleSelectFile = (fileKey: string, checked: boolean | "indeterminate") => { + const isChecked = checked === true; + setSelectedFiles((prev) => { + const newSet = new Set(prev); + if (isChecked) { + newSet.add(fileKey); + } else { + newSet.delete(fileKey); + } + return newSet; + }); + }; + + // 선택된 파일 일괄 취소 + const handleBulkCancel = async () => { + if (selectedFiles.size === 0) { + toast.error("취소할 파일을 선택해주세요"); + return; + } - const table = useReactTable({ - data: documents, - columns: inboxDocumentColumns, - getCoreRowModel: getCoreRowModel(), - }); + const filesToCancel = tableRows.filter((row) => + row.file && selectedFiles.has(`${row.file.BOX_SEQ}_${row.file.FILE_SEQ}`) + ); - // 문서 클릭 핸들러 - const handleDocumentClick = (document: InboxDocumentItem) => { - setSelectedDocument(document); - setDialogOpen(true); + if (filesToCancel.length === 0) { + toast.error("취소할 파일이 없습니다"); + return; + } + + try { + toast.info(`${filesToCancel.length}개 파일 취소 중...`); + + // 병렬 취소 + const cancelPromises = filesToCancel.map((row) => + cancelVendorUploadedFile({ + boxSeq: row.file!.BOX_SEQ!, + actvSeq: row.file!.ACTV_SEQ!, + userId, + }) + ); + + await Promise.all(cancelPromises); + + toast.success(`${filesToCancel.length}개 파일 취소 완료`); + setSelectedFiles(new Set()); // 선택 초기화 + + // 페이지 리프레시 + window.location.reload(); + } catch (error) { + console.error("일괄 취소 실패:", error); + toast.error("일부 파일 취소에 실패했습니다"); + } + }; + + + const handleDownloadFile = async (file: SwpFileApiResponse) => { + try { + toast.info("파일 다운로드 준비 중..."); + + // API route를 통해 다운로드 + const downloadUrl = `/api/swp/download/${encodeURIComponent(file.OWN_DOC_NO)}?projNo=${encodeURIComponent(projNo)}&fileName=${encodeURIComponent(file.FILE_NM)}`; + + // 새 탭에서 다운로드 + window.open(downloadUrl, "_blank"); + + toast.success(`파일 다운로드 시작: ${file.FILE_NM}`); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error("파일 다운로드에 실패했습니다"); + } }; + + // 행 클릭 핸들러 (Document No 기준 전체 이력 보기) + const handleRowClick = (docNo: string) => { + setSelectedDocNo(docNo); + setHistoryDialogOpen(true); + }; + + const getStatusBadge = (status: string | null, statusNm: string | null) => { + const displayStatus = statusNm || status || "-"; + + if (!status) return <span className="text-muted-foreground">{displayStatus}</span>; + + const color = + status === "SCW03" || status === "SCW08" ? "bg-green-100 text-green-800" : + status === "SCW02" ? "bg-blue-100 text-blue-800" : + status === "SCW01" ? "bg-yellow-100 text-yellow-800" : + status === "SCW04" || status === "SCW05" || status === "SCW06" ? "bg-red-100 text-red-800" : + status === "SCW07" ? "bg-purple-100 text-purple-800" : + status === "SCW09" ? "bg-gray-100 text-gray-800" : + status === "SCW00" ? "bg-orange-100 text-orange-800" : + "bg-gray-100 text-gray-800"; + + return ( + <Badge variant="outline" className={color}> + {displayStatus} + </Badge> + ); + }; + + if (files.length === 0 && requiredDocs.length === 0) { + return ( + <div className="border rounded-lg p-8 text-center text-muted-foreground"> + 업로드한 파일이 없습니다. + </div> + ); + } + return ( <div className="space-y-4"> + {/* Status 필터 UI */} + <div className="flex items-center justify-between gap-4"> + <div className="flex flex-wrap gap-2 p-4 bg-muted/30 rounded-lg flex-1"> + <Button + variant={selectedStatus === null ? "default" : "outline"} + size="sm" + onClick={() => setSelectedStatus(null)} + className="h-9" + > + 전체 ({files.length + requiredDocs.length}) + </Button> + {statusCounts.map((statusCount) => ( + <Button + key={statusCount.status} + variant="outline" + size="sm" + onClick={() => setSelectedStatus(statusCount.status)} + className={cn( + "h-9", + selectedStatus === statusCount.status ? statusCount.color : "", + selectedStatus !== statusCount.status && "hover:opacity-80" + )} + > + {statusCount.statusNm} ({statusCount.count}) + </Button> + ))} + </div> + + {/* 선택된 파일 정보 및 일괄 취소 버튼 */} + {selectedFiles.size > 0 && ( + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {selectedFiles.size}개 선택됨 + </span> + <Button + variant="destructive" + size="sm" + onClick={handleBulkCancel} + > + <XCircle className="h-4 w-4 mr-1" /> + Cancel + </Button> + </div> + )} + </div> + {/* 테이블 */} - <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> - )) - ) : ( + {tableRows.length === 0 ? ( + <div className="border rounded-lg p-8 text-center text-muted-foreground"> + 해당 상태의 파일이 없습니다. + </div> + ) : ( + <div className="rounded-md border overflow-x-auto"> + <Table> + <TableHeader> <TableRow> - <TableCell colSpan={inboxDocumentColumns.length} className="h-24 text-center"> - 업로드한 파일이 없습니다. - </TableCell> + <TableHead className="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-[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]">Note</TableHead> + <TableHead className="w-[400px]">Attachment File</TableHead> + <TableHead className="w-[180px]">Upload Date</TableHead> </TableRow> - )} - </TableBody> - </Table> - </div> + </TableHeader> + <TableBody> + {tableRows.map((row, idx) => { + const fileKey = row.file ? `${row.file.BOX_SEQ}_${row.file.FILE_SEQ}` : `required_${idx}`; + const canSelect = row.status === "SCW01" && row.file; // Standby이고 file이 있는 경우만 선택 가능 + const isSelected = canSelect && selectedFiles.has(fileKey); + + return ( + <TableRow + key={`${row.uploadId}_${row.docNo}_${row.revNo}_${idx}`} + className="cursor-pointer hover:bg-muted/50" + onClick={() => handleRowClick(row.docNo)} + > + {/* Select Checkbox */} + <TableCell onClick={(e) => e.stopPropagation()}> + {canSelect ? ( + <Checkbox + checked={!!isSelected} + onCheckedChange={(checked) => handleSelectFile(fileKey, checked)} + /> + ) : null} + </TableCell> + + {/* Upload ID - 같은 Rev의 첫 파일에만 표시 */} + {row.isFirstFileInRev ? ( + <TableCell rowSpan={row.fileCountInRev} className="font-mono text-sm align-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"> + {row.docNo} + </TableCell> + ) : null} + + {/* Rev No - 같은 Rev의 첫 파일에만 표시 */} + {row.isFirstFileInRev ? ( + <TableCell rowSpan={row.fileCountInRev} className="align-top"> + {row.revNo || <span className="text-muted-foreground">-</span>} + </TableCell> + ) : null} - {/* 문서 상세 Dialog */} - <SwpInboxDocumentDetailDialog - open={dialogOpen} - onOpenChange={setDialogOpen} - document={selectedDocument} + {/* Stage - 같은 Rev의 첫 파일에만 표시 (텍스트로만 표시) */} + {row.isFirstFileInRev ? ( + <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm"> + {row.stage || <span className="text-muted-foreground">-</span>} + </TableCell> + ) : null} + + {/* Status - 같은 Rev의 첫 파일에만 표시 */} + {row.isFirstFileInRev ? ( + <TableCell rowSpan={row.fileCountInRev} className="align-top"> + {getStatusBadge(row.status, row.statusNm)} + </TableCell> + ) : null} + + {/* Activity - 같은 Rev의 첫 파일에만 표시 */} + {row.isFirstFileInRev ? ( + <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-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"> + {row.crter || <span className="text-muted-foreground">-</span>} + </TableCell> + ) : null} + + {/* Note - 같은 Rev의 첫 파일에만 표시 (개행문자 처리) */} + {row.isFirstFileInRev ? ( + <TableCell + rowSpan={row.fileCountInRev} + className="text-xs max-w-[150px] align-top whitespace-pre-wrap" + > + {row.note || <span className="text-muted-foreground">-</span>} + </TableCell> + ) : null} + + {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */} + <TableCell className="max-w-[400px]"> + {row.file ? ( + <div className="flex items-center gap-2"> + <span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}> + {row.file.FILE_NM} + </span> + <Button + variant="ghost" + size="sm" + onClick={(e) => { + e.stopPropagation(); + handleDownloadFile(row.file!); + }} + className="h-7 w-7 p-0 flex-shrink-0" + > + <Download className="h-4 w-4" /> + </Button> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> + + {/* Upload Date - 각 파일마다 표시 */} + <TableCell className="text-xs"> + {row.uploadDate ? formatSwpDate(row.uploadDate) : <span className="text-muted-foreground">-</span>} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + </div> + )} + + {/* Document 전체 이력 Dialog */} + <SwpInboxHistoryDialog + open={historyDialogOpen} + onOpenChange={setHistoryDialogOpen} + docNo={selectedDocNo} + files={files} projNo={projNo} - vendorCode={vendorCode} userId={userId} /> </div> ); } - |
