diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
| commit | 2ecdac866c19abea0b5389708fcdf5b3889c969a (patch) | |
| tree | e02a02cfa0890691fb28a7df3a96ef495b3d4b79 /lib/swp/table/swp-uploaded-files-dialog.tsx | |
| parent | 2fc9e5492e220041ba322d9a1479feb7803228cf (diff) | |
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib/swp/table/swp-uploaded-files-dialog.tsx')
| -rw-r--r-- | lib/swp/table/swp-uploaded-files-dialog.tsx | 358 |
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> + ); +} |
