diff options
Diffstat (limited to 'lib/swp/table/swp-inbox-history-dialog.tsx')
| -rw-r--r-- | lib/swp/table/swp-inbox-history-dialog.tsx | 509 |
1 files changed, 509 insertions, 0 deletions
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> + ); +} + |
