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/swp-document-detail-dialog.tsx | |
| parent | f56eb799ecdcd2baae1db26460cd1deff0a69eee (diff) | |
(김준회) swp: VxDCS 와 동일하게 변경, Complete된 리스트 중 없는 건이 있으면 DOCUMENT LIST에서 보여줌
Diffstat (limited to 'lib/swp/table/swp-document-detail-dialog.tsx')
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 567 |
1 files changed, 310 insertions, 257 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`; -} - |
