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