From e6403e9610253b2bfc7a46e470c236e2ed10c626 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 17 Nov 2025 20:47:44 +0900
Subject: (김준회) swp: VxDCS 와 동일하게 변경, Complete된 리스트 중 없는 건이
 있으면 DOCUMENT LIST에서 보여줌
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/table/swp-inbox-history-dialog.tsx | 509 +++++++++++++++++++++++++++++
 1 file changed, 509 insertions(+)
 create mode 100644 lib/swp/table/swp-inbox-history-dialog.tsx

(limited to 'lib/swp/table/swp-inbox-history-dialog.tsx')

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>
+  );
+}
+
-- 
cgit v1.2.3

