diff options
Diffstat (limited to 'lib/swp/table/swp-document-detail-dialog.tsx')
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 412 |
1 files changed, 412 insertions, 0 deletions
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx new file mode 100644 index 00000000..418ddea9 --- /dev/null +++ b/lib/swp/table/swp-document-detail-dialog.tsx @@ -0,0 +1,412 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + ChevronDown, + ChevronRight, + Download, + FileIcon, + XCircle, + AlertCircle, +} from "lucide-react"; +import { + fetchVendorDocumentDetail, + cancelVendorFile, + downloadVendorFile, +} from "@/lib/swp/vendor-actions"; +import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service"; +import { toast } from "sonner"; + +interface SwpDocumentDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + document: DocumentListItem | null; + projNo: string; + vendorCode: string; + userId: string; +} + +export function SwpDocumentDetailDialog({ + open, + onOpenChange, + document, + projNo, +}: SwpDocumentDetailDialogProps) { + const [detail, setDetail] = useState<DocumentDetail | null>(null); + 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 + + // 문서 상세 로드 + useEffect(() => { + if (open && document) { + loadDocumentDetail(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, document?.DOC_NO]); + + const loadDocumentDetail = async () => { + if (!document) return; + + setIsLoading(true); + 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); + }); + }); + + setExpandedRevisions(allRevKeys); + setExpandedActivities(allActKeys); + setIsAllExpanded(true); + } catch (error) { + console.error("문서 상세 조회 실패:", 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; + }); + }; + + // 일괄 열기/닫기 + const handleToggleAll = () => { + if (!detail) return; + + 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); + }); + }); + + 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(); + } catch (error) { + console.error("파일 취소 실패:", error); + toast.error("파일 취소에 실패했습니다"); + } + }; + + const handleDownloadFile = async (fileName: string, ownDocNo: string) => { + try { + toast.info("파일 다운로드 중..."); + const result = await downloadVendorFile(projNo, ownDocNo, fileName); + + if (!result.success || !result.data) { + toast.error(result.error || "파일 다운로드 실패"); + return; + } + + // Blob 생성 및 다운로드 + const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType }); + const url = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + link.href = url; + link.download = result.fileName || fileName; + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`파일 다운로드 완료: ${fileName}`); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>문서 상세</DialogTitle> + {document && ( + <DialogDescription> + {document.DOC_NO} - {document.DOC_TITLE} + </DialogDescription> + )} + </DialogHeader> + + {document && ( + <div className="space-y-4"> + {/* 문서 정보 */} + <div className="grid grid-cols-1 md:grid-cols-4 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> + {document.PROJ_NM && ( + <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">패키지:</span> + <div className="text-sm">{document.PKG_NO || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">업체:</span> + <div className="text-sm">{document.CPY_NM || "-"}</div> + {document.VNDR_CD && ( + <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">최신 리비전:</span> + <div className="text-sm">{document.LTST_REV_NO || "-"}</div> + </div> + </div> + + {/* 리비전 및 액티비티 트리 */} + {isLoading ? ( + <div className="flex items-center justify-center p-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + <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 ? ( + <> + <ChevronDown className="h-4 w-4 mr-2" /> + 일괄 닫기 + </> + ) : ( + <> + <ChevronRight className="h-4 w-4 mr-2" /> + 일괄 열기 + </> + )} + </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> + + {/* 액티비티 목록 */} + {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> + </div> + </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> + ); + })} + </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> + </div> + )} + </div> + )} + </DialogContent> + </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`; +} + |
