From 26365ef08588d53b8c5d9c7cfaefb244536e6743 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 24 Nov 2025 10:41:46 +0900 Subject: (김준회) 돌체 재개발 - 2차: 업로드 개선, 다운로드 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 129 +++++++---- .../dialogs/upload-files-to-detail-dialog.tsx | 242 ++++++++------------- 2 files changed, 168 insertions(+), 203 deletions(-) (limited to 'lib/dolce/dialogs') diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx index 290a226b..34d06368 100644 --- a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx +++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx @@ -1,7 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; -import { useDropzone } from "react-dropzone"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -22,8 +21,11 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { Upload, X, FileIcon, Info } from "lucide-react"; import { toast } from "sonner"; -import { UnifiedDwgReceiptItem, editDetailDwgReceipt, uploadFilesToDetailDrawing } from "../actions"; +import { UnifiedDwgReceiptItem, editDetailDwgReceipt } from "../actions"; import { v4 as uuidv4 } from "uuid"; +import { useFileUploadWithProgress } from "../hooks/use-file-upload-with-progress"; +import { uploadFilesWithProgress } from "../utils/upload-with-progress"; +import { FileUploadProgressList } from "../components/file-upload-progress-list"; interface AddDetailDrawingDialogProps { open: boolean; @@ -80,30 +82,26 @@ export function AddDetailDrawingDialog({ const [drawingUsage, setDrawingUsage] = useState(""); const [registerKind, setRegisterKind] = useState(""); const [revision, setRevision] = useState(""); - const [files, setFiles] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); - // 파일 드롭 핸들러 - const onDrop = useCallback((acceptedFiles: File[]) => { - setFiles((prev) => [...prev, ...acceptedFiles]); - }, []); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - multiple: true, - }); - - // 파일 제거 - const removeFile = (index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }; + // 파일 업로드 훅 사용 (진행도 추적) + const { + fileProgresses, + files, + removeFile, + clearFiles, + updateFileProgress, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); // 폼 초기화 const resetForm = () => { setDrawingUsage(""); setRegisterKind(""); setRevision(""); - setFiles([]); + clearFiles(); }; // 제출 @@ -169,16 +167,27 @@ export function AddDetailDrawingDialog({ if (files.length > 0) { toast.info(`${files.length}개 파일 업로드를 진행합니다...`); - const formData = new FormData(); - formData.append("uploadId", uploadId); - formData.append("userId", userId); - formData.append("fileCount", String(files.length)); - - files.forEach((file, index) => { - formData.append(`file_${index}`, file); + // 모든 파일 상태를 uploading으로 변경 + files.forEach((_, index) => { + updateFileProgress(index, 0, "uploading"); }); - const uploadResult = await uploadFilesToDetailDrawing(formData); + const uploadResult = await uploadFilesWithProgress({ + uploadId, + userId, + files, + callbacks: { + onProgress: (fileIndex, progress) => { + updateFileProgress(fileIndex, progress, "uploading"); + }, + onFileComplete: (fileIndex) => { + updateFileProgress(fileIndex, 100, "completed"); + }, + onFileError: (fileIndex, error) => { + updateFileProgress(fileIndex, 0, "error", error); + }, + }, + }); if (uploadResult.success) { toast.success(`상세도면 추가 및 ${uploadResult.uploadedCount}개 파일 업로드 완료`); @@ -189,8 +198,10 @@ export function AddDetailDrawingDialog({ toast.success("상세도면이 추가되었습니다"); } + // API 호출 성공 시 무조건 다이얼로그 닫기 (파일 업로드 성공 여부와 무관) resetForm(); onComplete(); + onOpenChange(false); } else { toast.error("상세도면 추가에 실패했습니다"); } @@ -318,7 +329,7 @@ export function AddDetailDrawingDialog({ 파일을 드래그하거나 클릭하여 선택

- 여러 파일을 한 번에 업로드할 수 있습니다 + 여러 파일을 한 번에 업로드할 수 있습니다 (최대 1GB/파일)

@@ -337,25 +348,49 @@ export function AddDetailDrawingDialog({ {/* 선택된 파일 목록 */} {files.length > 0 && (
- {files.map((file, index) => ( -
- - {file.name} - - {(file.size / 1024).toFixed(2)} KB - - -
- ))} + {isSubmitting ? ( + // 업로드 중: 진행도 표시 + + ) : ( + // 대기 중: 삭제 버튼 표시 + <> +
+

+ 선택된 파일 ({files.length}개) +

+ +
+
+ {files.map((file, index) => ( +
+ +
+

{file.name}

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+ +
+ ))} +
+ + )}
)} diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx index 1d8ac582..af73aea6 100644 --- a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx +++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx @@ -14,7 +14,9 @@ import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Upload, FolderOpen, Loader2, X, FileText, AlertCircle } from "lucide-react"; import { toast } from "sonner"; -import { uploadFilesToDetailDrawing, type UploadFilesResult } from "../actions"; +import { useFileUploadWithProgress } from "../hooks/use-file-upload-with-progress"; +import { uploadFilesWithProgress, type UploadResult } from "../utils/upload-with-progress"; +import { FileUploadProgressList } from "../components/file-upload-progress-list"; interface UploadFilesToDetailDialogProps { open: boolean; @@ -35,101 +37,26 @@ export function UploadFilesToDetailDialog({ userId, onUploadComplete, }: UploadFilesToDetailDialogProps) { - const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); - const [isDragging, setIsDragging] = useState(false); + + // 파일 업로드 훅 사용 (진행도 추적) + const { + fileProgresses, + files: selectedFiles, + removeFile, + clearFiles, + updateFileProgress, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); // 다이얼로그 닫을 때 초기화 React.useEffect(() => { if (!open) { - setSelectedFiles([]); - setIsDragging(false); - } - }, [open]); - - // 파일 선택 핸들러 - const handleFilesChange = (files: File[]) => { - if (files.length === 0) return; - - // 파일 크기 및 확장자 검증 - const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB - const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']; - - const validFiles: File[] = []; - const invalidFiles: string[] = []; - - files.forEach((file) => { - // 크기 검증 - if (file.size > MAX_FILE_SIZE) { - invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`); - return; - } - - // 확장자 검증 - const extension = file.name.split('.').pop()?.toLowerCase(); - if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { - invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`); - return; - } - - validFiles.push(file); - }); - - if (invalidFiles.length > 0) { - invalidFiles.forEach((msg) => toast.error(msg)); - } - - if (validFiles.length > 0) { - // 중복 제거 - const existingNames = new Set(selectedFiles.map((f) => f.name)); - const newFiles = validFiles.filter((f) => !existingNames.has(f.name)); - - if (newFiles.length === 0) { - toast.error("이미 선택된 파일입니다"); - return; - } - - setSelectedFiles((prev) => [...prev, ...newFiles]); - toast.success(`${newFiles.length}개 파일이 선택되었습니다`); - } - }; - - // Drag & Drop 핸들러 - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.currentTarget === e.target) { - setIsDragging(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "copy"; - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - const droppedFiles = Array.from(e.dataTransfer.files); - if (droppedFiles.length > 0) { - handleFilesChange(droppedFiles); + clearFiles(); } - }; - - // 파일 제거 - const handleRemoveFile = (index: number) => { - setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); - }; + }, [open, clearFiles]); // 업로드 처리 const handleUpload = async () => { @@ -141,18 +68,28 @@ export function UploadFilesToDetailDialog({ setIsUploading(true); try { - // FormData 생성 - const formData = new FormData(); - formData.append("uploadId", uploadId); - formData.append("userId", userId); - formData.append("fileCount", String(selectedFiles.length)); - - selectedFiles.forEach((file, index) => { - formData.append(`file_${index}`, file); + // 모든 파일 상태를 uploading으로 변경 + selectedFiles.forEach((_, index) => { + updateFileProgress(index, 0, "uploading"); }); - // 서버 액션 호출 - const result: UploadFilesResult = await uploadFilesToDetailDrawing(formData); + // 진행도 추적 업로드 호출 + const result: UploadResult = await uploadFilesWithProgress({ + uploadId, + userId, + files: selectedFiles, + callbacks: { + onProgress: (fileIndex, progress) => { + updateFileProgress(fileIndex, progress, "uploading"); + }, + onFileComplete: (fileIndex) => { + updateFileProgress(fileIndex, 100, "completed"); + }, + onFileError: (fileIndex, error) => { + updateFileProgress(fileIndex, 0, "error", error); + }, + }, + }); if (result.success) { toast.success(`${result.uploadedCount}개 파일 업로드 완료`); @@ -192,92 +129,85 @@ export function UploadFilesToDetailDialog({ {/* 파일 선택 영역 */}
- handleFilesChange(Array.from(e.target.files || []))} - className="hidden" - id="detail-file-upload" - /> -
{/* 선택된 파일 목록 */} {selectedFiles.length > 0 && (
-
-

- 선택된 파일 ({selectedFiles.length}개) -

- -
-
- {selectedFiles.map((file, index) => ( -
-
- -
-

{file.name}

-

- {(file.size / 1024 / 1024).toFixed(2)} MB -

-
-
+ {isUploading ? ( + // 업로드 중: 진행도 표시 + + ) : ( + // 대기 중: 삭제 버튼 표시 + <> +
+

+ 선택된 파일 ({selectedFiles.length}개) +

- ))} -
+
+ {selectedFiles.map((file, index) => ( +
+
+ +
+

{file.name}

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ +
+ ))} +
+ + )}
)}
-- cgit v1.2.3