summaryrefslogtreecommitdiff
path: root/lib/swp/table/swp-uploaded-files-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/swp/table/swp-uploaded-files-dialog.tsx')
-rw-r--r--lib/swp/table/swp-uploaded-files-dialog.tsx358
1 files changed, 358 insertions, 0 deletions
diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx
new file mode 100644
index 00000000..25a798b6
--- /dev/null
+++ b/lib/swp/table/swp-uploaded-files-dialog.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useState, useTransition, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import { FileText, ChevronRight, ChevronDown, X, Loader2, RefreshCw } from "lucide-react";
+import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../actions";
+import type { SwpFileApiResponse } from "../api-client";
+
+interface SwpUploadedFilesDialogProps {
+ projNo: string;
+ vndrCd: string;
+ userId: string;
+}
+
+interface FileTreeNode {
+ files: SwpFileApiResponse[];
+}
+
+interface RevisionTreeNode {
+ revNo: string;
+ files: FileTreeNode;
+}
+
+interface DocumentTreeNode {
+ docNo: string;
+ revisions: Map<string, RevisionTreeNode>;
+}
+
+export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFilesDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [files, setFiles] = useState<SwpFileApiResponse[]>([]);
+ const [isLoading, startLoading] = useTransition();
+ const [expandedDocs, setExpandedDocs] = useState<Set<string>>(new Set());
+ const [expandedRevs, setExpandedRevs] = useState<Set<string>>(new Set());
+ const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set());
+ const { toast } = useToast();
+
+ // 파일 목록을 트리 구조로 변환
+ const fileTree = useMemo(() => {
+ const tree = new Map<string, DocumentTreeNode>();
+
+ files.forEach((file) => {
+ const docNo = file.OWN_DOC_NO;
+ const revNo = file.REV_NO;
+
+ if (!tree.has(docNo)) {
+ tree.set(docNo, {
+ docNo,
+ revisions: new Map(),
+ });
+ }
+
+ const docNode = tree.get(docNo)!;
+
+ if (!docNode.revisions.has(revNo)) {
+ docNode.revisions.set(revNo, {
+ revNo,
+ files: { files: [] },
+ });
+ }
+
+ const revNode = docNode.revisions.get(revNo)!;
+ revNode.files.files.push(file);
+ });
+
+ return tree;
+ }, [files]);
+
+ // 다이얼로그 열릴 때 파일 목록 조회
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen);
+ if (newOpen) {
+ loadFiles();
+ }
+ };
+
+ // 파일 목록 조회
+ const loadFiles = () => {
+ if (!projNo || !vndrCd) {
+ toast({
+ variant: "destructive",
+ title: "조회 불가",
+ description: "프로젝트와 업체 정보가 필요합니다.",
+ });
+ return;
+ }
+
+ startLoading(async () => {
+ try {
+ const result = await fetchVendorUploadedFiles(projNo, vndrCd);
+ setFiles(result);
+ toast({
+ title: "조회 완료",
+ description: `${result.length}개의 파일을 조회했습니다.`,
+ });
+ } catch (error) {
+ console.error("파일 목록 조회 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "조회 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ });
+ };
+
+ // 파일 취소
+ const handleCancelFile = async (file: SwpFileApiResponse) => {
+ if (!file.BOX_SEQ || !file.ACTV_SEQ) {
+ toast({
+ variant: "destructive",
+ title: "취소 불가",
+ description: "파일 정보가 올바르지 않습니다.",
+ });
+ return;
+ }
+
+ const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`;
+ setCancellingFiles((prev) => new Set(prev).add(fileKey));
+
+ try {
+ await cancelVendorUploadedFile({
+ boxSeq: file.BOX_SEQ,
+ actvSeq: file.ACTV_SEQ,
+ userId,
+ });
+
+ toast({
+ title: "취소 완료",
+ description: `${file.FILE_NM} 파일이 취소되었습니다.`,
+ });
+
+ // 목록 새로고침
+ loadFiles();
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "취소 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ } finally {
+ setCancellingFiles((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(fileKey);
+ return newSet;
+ });
+ }
+ };
+
+ // 문서 토글
+ const toggleDoc = (docNo: string) => {
+ setExpandedDocs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(docNo)) {
+ newSet.delete(docNo);
+ } else {
+ newSet.add(docNo);
+ }
+ return newSet;
+ });
+ };
+
+ // 리비전 토글
+ const toggleRev = (docNo: string, revNo: string) => {
+ const key = `${docNo}_${revNo}`;
+ setExpandedRevs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(key)) {
+ newSet.delete(key);
+ } else {
+ newSet.add(key);
+ }
+ return newSet;
+ });
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}>
+ <FileText className="h-4 w-4 mr-2" />
+ 업로드 파일 관리
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>업로드한 파일 목록</DialogTitle>
+ <DialogDescription>
+ 프로젝트: {projNo} | 업체: {vndrCd}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 액션 바 */}
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {files.length}개 파일
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={loadFiles}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 조회 중...
+ </>
+ ) : (
+ <>
+ <RefreshCw className="h-4 w-4 mr-2" />
+ 새로고침
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 파일 트리 */}
+ <ScrollArea className="h-[500px] rounded-md border p-4">
+ {isLoading && files.length === 0 ? (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mr-2" />
+ 파일 목록을 조회하는 중...
+ </div>
+ ) : files.length === 0 ? (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ 업로드한 파일이 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {Array.from(fileTree.entries()).map(([docNo, docNode]) => (
+ <div key={docNo} className="space-y-1">
+ {/* 문서번호 */}
+ <div
+ className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer"
+ onClick={() => toggleDoc(docNo)}
+ >
+ {expandedDocs.has(docNo) ? (
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+ ) : (
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+ )}
+ <FileText className="h-4 w-4 text-blue-600 shrink-0" />
+ <span className="font-semibold">{docNo}</span>
+ <Badge variant="outline" className="text-xs">
+ {docNode.revisions.size}개 리비전
+ </Badge>
+ </div>
+
+ {/* 리비전 목록 */}
+ {expandedDocs.has(docNo) && (
+ <div className="ml-6 space-y-1">
+ {Array.from(docNode.revisions.entries()).map(([revNo, revNode]) => {
+ const revKey = `${docNo}_${revNo}`;
+ return (
+ <div key={revKey} className="space-y-1">
+ {/* 리비전 번호 */}
+ <div
+ className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer"
+ onClick={() => toggleRev(docNo, revNo)}
+ >
+ {expandedRevs.has(revKey) ? (
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+ ) : (
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+ )}
+ <span className="font-medium text-sm">Rev: {revNo}</span>
+ <Badge variant="secondary" className="text-xs">
+ {revNode.files.files.length}개 파일
+ </Badge>
+ </div>
+
+ {/* 파일 목록 */}
+ {expandedRevs.has(revKey) && (
+ <div className="ml-6 space-y-1">
+ {revNode.files.files.map((file, idx) => {
+ const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`;
+ const isCancellable = file.STAT === "SCW01";
+ const isCancelling = cancellingFiles.has(fileKey);
+
+ return (
+ <div
+ key={`${fileKey}_${idx}`}
+ className="flex items-center gap-2 p-2 rounded-md border bg-card"
+ >
+ <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm truncate">{file.FILE_NM}</div>
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>Stage: {file.STAGE}</span>
+ <span>•</span>
+ <span>상태: {file.STAT_NM || file.STAT || "알 수 없음"}</span>
+ </div>
+ </div>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => handleCancelFile(file)}
+ disabled={!isCancellable || isCancelling}
+ title={
+ isCancellable
+ ? "파일 업로드 취소"
+ : `취소 불가 (상태: ${file.STAT_NM || file.STAT})`
+ }
+ >
+ {isCancelling ? (
+ <>
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ 취소 중...
+ </>
+ ) : (
+ <>
+ <X className="h-3 w-3 mr-1" />
+ 취소
+ </>
+ )}
+ </Button>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 안내 메시지 */}
+ <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3">
+ <div className="text-xs text-blue-600 dark:text-blue-400 space-y-1">
+ <p>ℹ️ 접수 전(SCW01) 상태의 파일만 취소할 수 있습니다.</p>
+ <p>ℹ️ 취소된 파일은 목록에서 제거됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}