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

---
 .../dialogs/upload-files-to-detail-dialog.tsx      | 242 ++++++++-------------
 1 file changed, 86 insertions(+), 156 deletions(-)

(limited to 'lib/dolce/dialogs/upload-files-to-detail-dialog.tsx')

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<File[]>([]);
   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({
 
           {/* 파일 선택 영역 */}
           <div
-            className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${
-              isDragging
+            {...getRootProps()}
+            className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 cursor-pointer ${
+              isDragActive
                 ? "border-primary bg-primary/5 scale-[1.02]"
                 : "border-muted-foreground/30 hover:border-muted-foreground/50"
             }`}
-            onDragEnter={handleDragEnter}
-            onDragLeave={handleDragLeave}
-            onDragOver={handleDragOver}
-            onDrop={handleDrop}
           >
-            <input
-              type="file"
-              multiple
-              accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip"
-              onChange={(e) => handleFilesChange(Array.from(e.target.files || []))}
-              className="hidden"
-              id="detail-file-upload"
-            />
-            <label
-              htmlFor="detail-file-upload"
-              className="flex flex-col items-center justify-center cursor-pointer"
-            >
+            <input {...getInputProps()} />
+            <div className="flex flex-col items-center justify-center">
               <FolderOpen
                 className={`h-12 w-12 mb-3 transition-colors ${
-                  isDragging ? "text-primary" : "text-muted-foreground"
+                  isDragActive ? "text-primary" : "text-muted-foreground"
                 }`}
               />
               <p
                 className={`text-sm transition-colors ${
-                  isDragging
+                  isDragActive
                     ? "text-primary font-medium"
                     : "text-muted-foreground"
                 }`}
               >
-                {isDragging
+                {isDragActive
                   ? "파일을 여기에 놓으세요"
                   : "클릭하거나 파일을 드래그하여 선택"}
               </p>
               <p className="text-xs text-muted-foreground mt-1">
-                PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP (max 1GB per file)
+                여러 파일을 한 번에 업로드할 수 있습니다 (최대 1GB/파일)
               </p>
-            </label>
+            </div>
           </div>
 
           {/* 선택된 파일 목록 */}
           {selectedFiles.length > 0 && (
             <div className="border rounded-lg p-4">
-              <div className="flex items-center justify-between mb-3">
-                <h4 className="text-sm font-medium">
-                  선택된 파일 ({selectedFiles.length}개)
-                </h4>
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={() => setSelectedFiles([])}
-                  disabled={isUploading}
-                >
-                  전체 제거
-                </Button>
-              </div>
-              <div className="max-h-48 overflow-auto space-y-2">
-                {selectedFiles.map((file, index) => (
-                  <div
-                    key={index}
-                    className="flex items-center justify-between p-2 rounded bg-muted/50"
-                  >
-                    <div className="flex items-center gap-2 flex-1 min-w-0">
-                      <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
-                      <div className="flex-1 min-w-0">
-                        <p className="text-sm truncate">{file.name}</p>
-                        <p className="text-xs text-muted-foreground">
-                          {(file.size / 1024 / 1024).toFixed(2)} MB
-                        </p>
-                      </div>
-                    </div>
+              {isUploading ? (
+                // 업로드 중: 진행도 표시
+                <FileUploadProgressList fileProgresses={fileProgresses} />
+              ) : (
+                // 대기 중: 삭제 버튼 표시
+                <>
+                  <div className="flex items-center justify-between mb-3">
+                    <h4 className="text-sm font-medium">
+                      선택된 파일 ({selectedFiles.length}개)
+                    </h4>
                     <Button
                       variant="ghost"
                       size="sm"
-                      onClick={() => handleRemoveFile(index)}
-                      disabled={isUploading}
+                      onClick={clearFiles}
                     >
-                      <X className="h-4 w-4" />
+                      전체 제거
                     </Button>
                   </div>
-                ))}
-              </div>
+                  <div className="max-h-48 overflow-auto space-y-2">
+                    {selectedFiles.map((file, index) => (
+                      <div
+                        key={index}
+                        className="flex items-center justify-between p-2 rounded bg-muted/50"
+                      >
+                        <div className="flex items-center gap-2 flex-1 min-w-0">
+                          <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
+                          <div className="flex-1 min-w-0">
+                            <p className="text-sm truncate">{file.name}</p>
+                            <p className="text-xs text-muted-foreground">
+                              {(file.size / 1024 / 1024).toFixed(2)} MB
+                            </p>
+                          </div>
+                        </div>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => removeFile(index)}
+                        >
+                          <X className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    ))}
+                  </div>
+                </>
+              )}
             </div>
           )}
         </div>
-- 
cgit v1.2.3

