summaryrefslogtreecommitdiff
path: root/lib/swp/table/swp-inbox-history-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/swp/table/swp-inbox-history-dialog.tsx')
-rw-r--r--lib/swp/table/swp-inbox-history-dialog.tsx509
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>
+ );
+}
+