diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 07:51:27 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 07:51:27 +0000 |
| commit | 9ecdfb23fe3df6a5df86782385002c562dfc1198 (patch) | |
| tree | 4188cb7e6bf2c862d9c86a59d79946bd41217227 /lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx | |
| parent | b67861fbb424c7ad47ad1538f75e2945bd8890c5 (diff) | |
(대표님) rfq 히스토리, swp 등
Diffstat (limited to 'lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx')
| -rw-r--r-- | lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx | 492 |
1 files changed, 492 insertions, 0 deletions
diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState<FileWithMetadata[]>([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return <FileWarning className="h-4 w-4 text-destructive" /> + } + + switch (fileData.status) { + case 'validating': + return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + case 'uploading': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'success': + return <CheckCircle2 className="h-4 w-4 text-success" /> + case 'error': + return <AlertCircle className="h-4 w-4 text-destructive" /> + default: + if (fileData.matched) { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } else { + return <AlertCircle className="h-4 w-4 text-warning" /> + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <CloudUpload className="h-4 w-4" /> + Multi-Upload + </Button> + </DialogTrigger> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Bulk Document Upload</DialogTitle> + <DialogDescription> + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + </DialogDescription> + </DialogHeader> + + {/* Custom Dropzone with input */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors cursor-pointer" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => document.getElementById('file-upload')?.click()} + > + <input + id="file-upload" + type="file" + multiple + className="hidden" + onChange={handleFilesChange} + accept="*/*" + /> + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-lg font-medium">Drop files here or click to browse</p> + <p className="text-sm text-gray-500 mt-1"> + Maximum 10GB total • Format: DocNumber_StageName_Revision.ext + </p> + </div> + + {/* Stats */} + {files.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + <Badge variant="outline">Total: {stats.total}</Badge> + <Badge variant={stats.valid === stats.total ? "success" : "secondary"}> + Valid Format: {stats.valid} + </Badge> + <Badge variant={stats.matched > 0 ? "success" : "secondary"}> + Matched: {stats.matched} + </Badge> + <Badge variant={stats.ready > 0 ? "default" : "outline"}> + Ready: {stats.ready} + </Badge> + <Badge variant="outline"> + Size: {formatFileSize(stats.totalSize)} + </Badge> + </div> + )} + + {/* File List */} + {files.length > 0 && ( + <div className="flex-1 rounded-md border overflow-y-auto" style={{ minHeight: 200, maxHeight: 400 }}> + <FileList className="p-4"> + <FileListHeader> + <div className="text-sm font-medium">Files ({files.length})</div> + </FileListHeader> + + {files.map((fileData, index) => ( + <FileListItem key={index}> + <FileListIcon> + {getStatusIcon(fileData)} + </FileListIcon> + + <FileListInfo> + <FileListName>{fileData.file.name}</FileListName> + <FileListDescription> + {getStatusDescription(fileData)} + </FileListDescription> + </FileListInfo> + + <FileListSize> + {fileData.file.size} + </FileListSize> + + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading || fileData.status === 'uploading'} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + </AlertDescription> + </Alert> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setOpen(false) + setFiles([]) + }} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={stats.ready === 0 || isUploading || isValidating} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading {stats.ready} files... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload {stats.ready} file{stats.ready !== 1 ? 's' : ''} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
