diff options
Diffstat (limited to 'lib/dolce/dialogs')
| -rw-r--r-- | lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 376 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-bulk-upload-dialog.tsx | 608 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-upload-validation-dialog.tsx | 353 | ||||
| -rw-r--r-- | lib/dolce/dialogs/detail-drawing-dialog.tsx | 311 | ||||
| -rw-r--r-- | lib/dolce/dialogs/upload-files-to-detail-dialog.tsx | 314 |
5 files changed, 1962 insertions, 0 deletions
diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx new file mode 100644 index 00000000..290a226b --- /dev/null +++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useDropzone } from "react-dropzone"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +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 { v4 as uuidv4 } from "uuid"; + +interface AddDetailDrawingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + drawing: UnifiedDwgReceiptItem | null; + vendorCode: string; + userId: string; + userName: string; + userEmail: string; + onComplete: () => void; + drawingKind: "B3" | "B4"; // 추가 +} + +// B3 벤더의 선택 옵션 +const B3_DRAWING_USAGE_OPTIONS = [ + { value: "APP", label: "APPROVAL (승인용)" }, + { value: "WOR", label: "WORKING (작업용)" }, +]; + +const B3_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string; revisionRule: string }>> = { + APP: [ + { value: "APPR", label: "승인용 도면 (Full)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, + { value: "APPR-P", label: "승인용 도면 (Partial)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, + ], + WOR: [ + { value: "WORK", label: "작업용 입수도면 (Full)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, + { value: "WORK-P", label: "작업용 입수도면 (Partial)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, + ], +}; + +// B4 벤더(GTT)의 선택 옵션 +const B4_DRAWING_USAGE_OPTIONS = [ + { value: "REC", label: "RECEIVE (입수용)" }, +]; + +const B4_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string; revisionRule: string }>> = { + REC: [ + { value: "RECP", label: "Pre. 도면입수", revisionRule: "예: R00, R01, R02, R03" }, + { value: "RECW", label: "Working 도면입수", revisionRule: "예: R00, R01, R02, R03" }, + ], +}; + +export function AddDetailDrawingDialog({ + open, + onOpenChange, + drawing, + vendorCode, + userId, + userName, + userEmail, + onComplete, + drawingKind, +}: AddDetailDrawingDialogProps) { + const [drawingUsage, setDrawingUsage] = useState<string>(""); + const [registerKind, setRegisterKind] = useState<string>(""); + const [revision, setRevision] = useState<string>(""); + const [files, setFiles] = useState<File[]>([]); + 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 resetForm = () => { + setDrawingUsage(""); + setRegisterKind(""); + setRevision(""); + setFiles([]); + }; + + // 제출 + const handleSubmit = async () => { + if (!drawing) return; + + // 유효성 검사 + if (!drawingUsage) { + toast.error("도면용도를 선택하세요"); + return; + } + if (!registerKind) { + toast.error("등록종류를 선택하세요"); + return; + } + if (!revision.trim()) { + toast.error("Revision을 입력하세요"); + return; + } + if (files.length === 0) { + toast.error("최소 1개 이상의 파일을 첨부해야 합니다"); + return; + } + + try { + setIsSubmitting(true); + + // 파일 업로드 ID 생성 + const uploadId = uuidv4(); + + // 상세도면 추가 + const result = await editDetailDwgReceipt({ + dwgList: [ + { + Mode: "ADD", + Status: "Draft", + RegisterId: 0, + ProjectNo: drawing.ProjectNo, + Discipline: drawing.Discipline, + DrawingKind: drawing.DrawingKind, + DrawingNo: drawing.DrawingNo, + DrawingName: drawing.DrawingName, + RegisterGroupId: drawing.RegisterGroupId, + RegisterSerialNo: 0, // 자동 증가 + RegisterKind: registerKind, + DrawingRevNo: revision, + Category: "TS", // To SHI (벤더가 SHI에게 제출) + Receiver: null, + Manager: "", + RegisterDesc: "", + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ], + userId, + userNm: userName, + vendorCode, + email: userEmail, + }); + + if (result > 0) { + // 파일 업로드 처리 (상세도면 추가 후) + 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); + }); + + const uploadResult = await uploadFilesToDetailDrawing(formData); + + if (uploadResult.success) { + toast.success(`상세도면 추가 및 ${uploadResult.uploadedCount}개 파일 업로드 완료`); + } else { + toast.warning(`상세도면은 추가되었으나 파일 업로드 실패: ${uploadResult.error}`); + } + } else { + toast.success("상세도면이 추가되었습니다"); + } + + resetForm(); + onComplete(); + } else { + toast.error("상세도면 추가에 실패했습니다"); + } + } catch (error) { + console.error("상세도면 추가 실패:", error); + toast.error("상세도면 추가 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + resetForm(); + onOpenChange(false); + }; + + // DrawingUsage가 변경되면 RegisterKind 초기화 + const handleDrawingUsageChange = (value: string) => { + setDrawingUsage(value); + setRegisterKind(""); + }; + + // 현재 선택 가능한 DrawingUsage 및 RegisterKind 옵션 + const drawingUsageOptions = drawingKind === "B4" ? B4_DRAWING_USAGE_OPTIONS : B3_DRAWING_USAGE_OPTIONS; + const registerKindOptionsMap = drawingKind === "B4" ? B4_REGISTER_KIND_OPTIONS : B3_REGISTER_KIND_OPTIONS; + + const registerKindOptions = drawingUsage + ? registerKindOptionsMap[drawingUsage] || [] + : []; + + // 선택된 RegisterKind의 Revision Rule + const revisionRule = registerKindOptions.find((opt) => opt.value === registerKind)?.revisionRule || ""; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>상세도면 추가</DialogTitle> + </DialogHeader> + + <div className="space-y-6"> + {/* 도면 정보 표시 */} + {drawing && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <div className="font-medium">{drawing.DrawingNo}</div> + <div className="text-sm text-muted-foreground">{drawing.DrawingName}</div> + </AlertDescription> + </Alert> + )} + + {/* 도면용도 선택 */} + <div className="space-y-2"> + <Label>도면용도 (Drawing Usage)</Label> + <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> + <SelectTrigger> + <SelectValue placeholder="도면용도를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {drawingUsageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* 등록종류 선택 */} + <div className="space-y-2"> + <Label>등록종류 (Register Kind)</Label> + <Select + value={registerKind} + onValueChange={setRegisterKind} + disabled={!drawingUsage} + > + <SelectTrigger> + <SelectValue placeholder="등록종류를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {registerKindOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + {revisionRule && ( + <p className="text-sm text-muted-foreground"> + Revision 입력 형식: {revisionRule} + </p> + )} + </div> + + {/* Revision 입력 */} + <div className="space-y-2"> + <Label>Revision</Label> + <Input + value={revision} + onChange={(e) => setRevision(e.target.value)} + placeholder="예: A, B, R00, R01" + disabled={!registerKind} + /> + </div> + + {/* 파일 업로드 */} + <div className="space-y-2"> + <Label>첨부파일 (필수) *</Label> + <div + {...getRootProps()} + className={` + border-2 border-dashed rounded-lg p-8 text-center cursor-pointer + transition-colors + ${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25"} + ${files.length > 0 ? "py-4" : ""} + `} + > + <input {...getInputProps()} /> + {files.length === 0 ? ( + <div className="space-y-2"> + <Upload className="h-8 w-8 mx-auto text-muted-foreground" /> + <div> + <p className="text-sm font-medium"> + 파일을 드래그하거나 클릭하여 선택 + </p> + <p className="text-xs text-muted-foreground"> + 여러 파일을 한 번에 업로드할 수 있습니다 + </p> + </div> + </div> + ) : ( + <div className="space-y-2"> + <p className="text-sm font-medium"> + {files.length}개 파일 선택됨 + </p> + <p className="text-xs text-muted-foreground"> + 추가로 파일을 드래그하거나 클릭하여 더 추가할 수 있습니다 + </p> + </div> + )} + </div> + + {/* 선택된 파일 목록 */} + {files.length > 0 && ( + <div className="space-y-2 mt-4"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center gap-2 p-2 border rounded-lg" + > + <FileIcon className="h-4 w-4 text-muted-foreground" /> + <span className="flex-1 text-sm truncate">{file.name}</span> + <span className="text-xs text-muted-foreground"> + {(file.size / 1024).toFixed(2)} KB + </span> + <Button + variant="ghost" + size="icon" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={handleCancel} disabled={isSubmitting}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting}> + {isSubmitting ? "처리 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx new file mode 100644 index 00000000..f4816328 --- /dev/null +++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx @@ -0,0 +1,608 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FolderOpen, Loader2, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { Progress } from "@/components/ui/progress"; +import { + validateB4FileName, + B4UploadValidationDialog, + type FileValidationResult, +} from "./b4-upload-validation-dialog"; +import { + checkB4MappingStatus, + bulkUploadB4Files, + type MappingCheckItem, + type B4BulkUploadResult, +} from "../actions"; + +interface B4BulkUploadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + userName: string; + userEmail: string; + vendorCode: string; + onUploadComplete?: () => void; +} + +// B4 GTT 옵션 +const B4_DRAWING_USAGE_OPTIONS = [ + { value: "REC", label: "RECEIVE (입수용)" }, +]; + +const B4_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string }>> = { + REC: [ + { value: "RECP", label: "Pre. 도면입수" }, + { value: "RECW", label: "Working 도면입수" }, + ], +}; + +type UploadStep = "settings" | "files" | "validation" | "uploading" | "complete"; + +export function B4BulkUploadDialog({ + open, + onOpenChange, + projectNo, + userId, + userName, + userEmail, + vendorCode, + onUploadComplete, +}: B4BulkUploadDialogProps) { + const [currentStep, setCurrentStep] = useState<UploadStep>("settings"); + const [drawingUsage, setDrawingUsage] = useState<string>("REC"); + const [registerKind, setRegisterKind] = useState<string>(""); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [validationResults, setValidationResults] = useState<FileValidationResult[]>([]); + const [showValidationDialog, setShowValidationDialog] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadResult, setUploadResult] = useState<B4BulkUploadResult | null>(null); + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + setCurrentStep("settings"); + setDrawingUsage("REC"); + setRegisterKind(""); + setSelectedFiles([]); + setValidationResults([]); + setShowValidationDialog(false); + setIsDragging(false); + setUploadProgress(0); + setUploadResult(null); + } + }, [open]); + + // 파일 선택 핸들러 + const handleFilesChange = (files: File[]) => { + if (files.length === 0) return; + + // 중복 제거 + const existingNames = new Set(selectedFiles.map((f) => f.name)); + const newFiles = files.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); + } + }; + + // 파일 제거 + const handleRemoveFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + // 1단계 완료 (설정) + const handleSettingsNext = () => { + if (!registerKind) { + toast.error("등록종류를 선택하세요"); + return; + } + setCurrentStep("files"); + }; + + // 2단계 완료 (파일 선택) + const handleFilesNext = () => { + if (selectedFiles.length === 0) { + toast.error("파일을 선택해주세요"); + return; + } + setCurrentStep("validation"); + handleValidate(); + }; + + // 검증 시작 + const handleValidate = async () => { + try { + // 1단계: 파일명 파싱 + const parseResults: FileValidationResult[] = selectedFiles.map((file) => { + const validation = validateB4FileName(file.name); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + // 파싱에 실패한 파일이 있으면 바로 검증 다이얼로그 표시 + const parsedFiles = parseResults.filter((r) => r.valid && r.parsed); + if (parsedFiles.length === 0) { + setValidationResults(parseResults); + setShowValidationDialog(true); + return; + } + + // 2단계: 매핑 현황 조회 + const mappingCheckItems: MappingCheckItem[] = parsedFiles.map((r) => ({ + DrawingNo: r.parsed!.drawingNo, + RevNo: r.parsed!.revNo, + FileNm: r.file.name, + })); + + const mappingResults = await checkB4MappingStatus( + projectNo, + mappingCheckItems + ); + + // 3단계: 검증 결과 병합 + const finalResults: FileValidationResult[] = parseResults.map((parseResult) => { + if (!parseResult.valid || !parseResult.parsed) { + return parseResult; + } + + // 매핑 결과 찾기 + const mappingResult = mappingResults.find( + (m) => + m.DrawingNo === parseResult.parsed!.drawingNo && + m.RevNo === parseResult.parsed!.revNo + ); + + if (!mappingResult) { + return { + ...parseResult, + mappingStatus: "not_found" as const, + error: "DOLCE 시스템에서 도면을 찾을 수 없습니다", + }; + } + + // RegisterGroupId가 0이거나 MappingYN이 N이면 도면이 존재하지 않음 + if (mappingResult.RegisterGroupId === 0 || mappingResult.MappingYN === "N") { + return { + ...parseResult, + mappingStatus: "not_found" as const, + error: "해당 도면번호가 프로젝트에 등록되어 있지 않습니다", + }; + } + + // DrawingMoveGbn이 "도면입수"가 아니면 업로드 불가 + if (mappingResult.DrawingMoveGbn !== "도면입수") { + return { + ...parseResult, + mappingStatus: "not_found" as const, + error: "도면입수(GTT Deliverables)인 도면만 업로드 가능합니다", + }; + } + + // MappingYN이 Y이고 도면입수인 경우 업로드 가능 + return { + ...parseResult, + mappingStatus: "available" as const, + drawingName: mappingResult.DrawingName || undefined, + registerGroupId: mappingResult.RegisterGroupId, + }; + }); + + setValidationResults(finalResults); + setShowValidationDialog(true); + } catch (error) { + console.error("검증 실패:", error); + toast.error( + error instanceof Error ? error.message : "검증 중 오류가 발생했습니다" + ); + } + }; + + // 업로드 확인 + const handleConfirmUpload = async (validFiles: FileValidationResult[]) => { + setIsUploading(true); + setCurrentStep("uploading"); + setShowValidationDialog(false); + + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) { + clearInterval(progressInterval); + return prev; + } + return prev + 10; + }); + }, 500); + + try { + // FormData 생성 + const formData = new FormData(); + formData.append("projectNo", projectNo); + formData.append("userId", userId); + formData.append("userName", userName); + formData.append("userEmail", userEmail); + formData.append("vendorCode", vendorCode); + formData.append("registerKind", registerKind); // RegisterKind 추가 + + // 파일 및 메타데이터 추가 + validFiles.forEach((fileResult, index) => { + formData.append(`file_${index}`, fileResult.file); + formData.append(`drawingNo_${index}`, fileResult.parsed!.drawingNo); + formData.append(`revNo_${index}`, fileResult.parsed!.revNo); + formData.append(`fileName_${index}`, fileResult.file.name); + formData.append( + `registerGroupId_${index}`, + String(fileResult.registerGroupId || 0) + ); + }); + + formData.append("fileCount", String(validFiles.length)); + + // 서버 액션 호출 + const result: B4BulkUploadResult = await bulkUploadB4Files(formData); + + clearInterval(progressInterval); + setUploadProgress(100); + setUploadResult(result); + + if (result.success) { + setCurrentStep("complete"); + toast.success( + `${result.successCount}/${validFiles.length}개 파일 업로드 완료` + ); + } else { + setCurrentStep("files"); + toast.error(result.error || "업로드 실패"); + } + } catch (error) { + console.error("업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" + ); + } finally { + setIsUploading(false); + } + }; + + const registerKindOptions = drawingUsage + ? B4_REGISTER_KIND_OPTIONS[drawingUsage] || [] + : []; + + const handleDrawingUsageChange = (value: string) => { + setDrawingUsage(value); + setRegisterKind(""); + }; + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>B4 일괄 업로드</DialogTitle> + <DialogDescription> + {currentStep === "settings" && "업로드 설정을 선택하세요"} + {currentStep === "files" && "파일명 형식: [버림] [DrawingNo] [RevNo].[확장자] (예: testfile GTT-DE-007 R01.pdf)"} + {currentStep === "validation" && "파일 검증 중..."} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 1단계: 설정 입력 */} + {currentStep === "settings" && ( + <> + {/* 도면용도 선택 */} + <div className="space-y-2"> + <Label>도면용도 (Drawing Usage) *</Label> + <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> + <SelectTrigger> + <SelectValue placeholder="도면용도를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {B4_DRAWING_USAGE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* 등록종류 선택 */} + <div className="space-y-2"> + <Label>등록종류 (Register Kind) *</Label> + <Select + value={registerKind} + onValueChange={setRegisterKind} + disabled={!drawingUsage} + > + <SelectTrigger> + <SelectValue placeholder="등록종류를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {registerKindOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <p className="text-sm text-muted-foreground"> + 선택한 등록종류가 모든 파일에 적용됩니다 + </p> + </div> + </> + )} + + {/* 2단계: 파일 선택 */} + {currentStep === "files" && ( + <> + {/* 파일 선택 영역 */} + <div + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${ + isDragging + ? "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="b4-file-upload" + /> + <label + htmlFor="b4-file-upload" + className="flex flex-col items-center justify-center cursor-pointer" + > + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragging ? "text-primary" : "text-muted-foreground" + }`} + /> + <p + className={`text-sm transition-colors ${ + isDragging + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragging + ? "파일을 여기에 놓으세요" + : "클릭하거나 파일을 드래그하여 선택"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP + </p> + </label> + </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([])} + > + 전체 제거 + </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-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> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(index)} + > + 제거 + </Button> + </div> + ))} + </div> + </div> + )} + </> + )} + + {/* 3단계: 검증 중 표시 */} + {currentStep === "validation" && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> + <p className="text-sm text-muted-foreground"> + 파일 검증 중입니다... + </p> + </div> + )} + + {/* 4단계: 업로드 진행 중 */} + {currentStep === "uploading" && ( + <div className="space-y-6 py-8"> + <div className="flex flex-col items-center"> + <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> + <h3 className="text-lg font-semibold mb-2">파일 업로드 중...</h3> + <p className="text-sm text-muted-foreground"> + 잠시만 기다려주세요 + </p> + </div> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} className="h-2" /> + </div> + </div> + )} + + {/* 5단계: 업로드 완료 */} + {currentStep === "complete" && uploadResult && ( + <div className="space-y-6 py-8"> + <div className="flex flex-col items-center"> + <CheckCircle2 className="h-16 w-16 text-green-500 mb-4" /> + <h3 className="text-lg font-semibold mb-2">업로드 완료!</h3> + <p className="text-sm text-muted-foreground"> + {uploadResult.successCount}개 파일이 성공적으로 업로드되었습니다 + </p> + </div> + + {uploadResult.failCount && uploadResult.failCount > 0 && ( + <div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> + <p className="text-sm text-yellow-800 dark:text-yellow-200"> + {uploadResult.failCount}개 파일 업로드 실패 + </p> + </div> + )} + + <div className="flex justify-center"> + <Button + onClick={() => { + onOpenChange(false); + onUploadComplete?.(); + }} + > + 확인 + </Button> + </div> + </div> + )} + </div> + + {/* 푸터 버튼 (uploading, complete 단계에서는 숨김) */} + {currentStep !== "uploading" && currentStep !== "complete" && currentStep !== "validation" && ( + <DialogFooter> + {currentStep === "settings" && ( + <> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button + onClick={handleSettingsNext} + disabled={!registerKind} + > + 다음 + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + + {currentStep === "files" && ( + <> + <Button + variant="outline" + onClick={() => setCurrentStep("settings")} + > + <ChevronLeft className="mr-2 h-4 w-4" /> + 이전 + </Button> + <Button + onClick={handleFilesNext} + disabled={selectedFiles.length === 0} + > + 검증 시작 + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + </DialogFooter> + )} + </DialogContent> + </Dialog> + + {/* 검증 다이얼로그 */} + <B4UploadValidationDialog + open={showValidationDialog} + onOpenChange={setShowValidationDialog} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + /> + </> + ); +} + diff --git a/lib/dolce/dialogs/b4-upload-validation-dialog.tsx b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx new file mode 100644 index 00000000..b274d604 --- /dev/null +++ b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, XCircle, AlertCircle, Upload } from "lucide-react"; + +export interface ParsedFileInfo { + drawingNo: string; + revNo: string; + fileName: string; +} + +export interface FileValidationResult { + file: File; + valid: boolean; + parsed?: ParsedFileInfo; + error?: string; + mappingStatus?: "available" | "not_found"; + drawingName?: string; + registerGroupId?: number; +} + +interface B4UploadValidationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + validationResults: FileValidationResult[]; + onConfirmUpload: (validFiles: FileValidationResult[]) => void; + isUploading: boolean; +} + +/** + * B4 파일명 검증 함수 + * 형식: [버림] [DrawingNo] [RevNo].[확장자] + * 예시: "testfile GTT-DE-007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01" + */ +export function validateB4FileName(fileName: string): { + valid: boolean; + parsed?: ParsedFileInfo; + error?: string; +} { + try { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex === -1) { + return { + valid: false, + error: "파일 확장자가 없습니다", + }; + } + + const extension = fileName.substring(lastDotIndex + 1); + const nameWithoutExt = fileName.substring(0, lastDotIndex); + + // 공백으로 분리 + const parts = nameWithoutExt.split(" ").filter(p => p.trim() !== ""); + + // 최소 3개 파트 필요: [버림], DrawingNo, RevNo + if (parts.length < 3) { + return { + valid: false, + error: `공백이 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [버림] [DrawingNo] [RevNo].[확장자]`, + }; + } + + // 첫 번째 토큰은 버림 + const drawingNo = parts[1]; + const revNo = parts[2]; + + // 필수 항목이 비어있지 않은지 확인 + if (!drawingNo || drawingNo.trim() === "") { + return { + valid: false, + error: "도면번호(DrawingNo)가 비어있습니다", + }; + } + + if (!revNo || revNo.trim() === "") { + return { + valid: false, + error: "리비전 번호(RevNo)가 비어있습니다", + }; + } + + return { + valid: true, + parsed: { + drawingNo: drawingNo.trim(), + revNo: revNo.trim(), + fileName: fileName, + }, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + +/** + * B4 업로드 전 파일 검증 다이얼로그 + */ +export function B4UploadValidationDialog({ + open, + onOpenChange, + validationResults, + onConfirmUpload, + isUploading, +}: B4UploadValidationDialogProps) { + const validFiles = validationResults.filter((r) => r.valid && r.mappingStatus === "available"); + const notFoundFiles = validationResults.filter((r) => r.valid && r.mappingStatus === "not_found"); + const invalidFiles = validationResults.filter((r) => !r.valid); + + const handleUpload = () => { + if (validFiles.length > 0) { + onConfirmUpload(validFiles); + } + }; + + const handleCancel = () => { + if (!isUploading) { + onOpenChange(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>B4 일괄 업로드 검증</DialogTitle> + <DialogDescription> + 선택한 파일의 파일명 형식과 매핑 가능 여부를 검증합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 overflow-auto flex-1 pr-2"> + {/* 요약 통계 */} + <div className="grid grid-cols-4 gap-3"> + <div className="rounded-lg border p-3"> + <div className="text-sm text-muted-foreground">전체</div> + <div className="text-2xl font-bold">{validationResults.length}</div> + </div> + <div className="rounded-lg border p-3 bg-green-50 dark:bg-green-950/30"> + <div className="text-sm text-green-600 dark:text-green-400">업로드 가능</div> + <div className="text-2xl font-bold text-green-600 dark:text-green-400"> + {validFiles.length} + </div> + </div> + <div className="rounded-lg border p-3 bg-orange-50 dark:bg-orange-950/30"> + <div className="text-sm text-orange-600 dark:text-orange-400">도면 없음</div> + <div className="text-2xl font-bold text-orange-600 dark:text-orange-400"> + {notFoundFiles.length} + </div> + </div> + <div className="rounded-lg border p-3 bg-red-50 dark:bg-red-950/30"> + <div className="text-sm text-red-600 dark:text-red-400">형식 오류</div> + <div className="text-2xl font-bold text-red-600 dark:text-red-400"> + {invalidFiles.length} + </div> + </div> + </div> + + {/* 경고 메시지 */} + {validFiles.length === 0 && ( + <Alert variant="destructive"> + <XCircle className="h-4 w-4" /> + <AlertDescription> + 업로드 가능한 파일이 없습니다. 파일명 형식을 확인하거나 이미 매핑된 파일은 제외해주세요. + </AlertDescription> + </Alert> + )} + + {(notFoundFiles.length > 0 || invalidFiles.length > 0) && validFiles.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 일부 파일에 문제가 있습니다. 업로드 가능한 {validFiles.length}개 파일만 업로드됩니다. + </AlertDescription> + </Alert> + )} + + {/* 파일 목록 */} + <div className="max-h-[50vh] overflow-auto rounded-md border p-4"> + <div className="space-y-4"> + {/* 업로드 가능 파일 */} + {validFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-semibold text-green-600 dark:text-green-400 flex items-center gap-2"> + <CheckCircle2 className="h-4 w-4" /> + 업로드 가능 ({validFiles.length}개) + </h4> + {validFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.parsed && ( + <div className="flex flex-wrap gap-1 mt-2"> + <Badge variant="outline" className="text-xs"> + 도면: {result.parsed.drawingNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Rev: {result.parsed.revNo} + </Badge> + {result.drawingName && ( + <Badge variant="outline" className="text-xs"> + {result.drawingName} + </Badge> + )} + </div> + )} + </div> + <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + + {/* 도면을 찾을 수 없는 파일 */} + {notFoundFiles.length > 0 && ( + <div className="space-y-2 mt-4"> + <h4 className="text-sm font-semibold text-orange-600 dark:text-orange-400 flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 도면을 찾을 수 없음 ({notFoundFiles.length}개) + </h4> + {notFoundFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + <div className="text-xs text-orange-700 dark:text-orange-300 mt-1"> + ✗ 해당 도면번호가 프로젝트에 등록되어 있지 않습니다 + </div> + {result.parsed && ( + <div className="flex flex-wrap gap-1 mt-2"> + <Badge variant="outline" className="text-xs"> + 도면: {result.parsed.drawingNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Rev: {result.parsed.revNo} + </Badge> + </div> + )} + </div> + <XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + + {/* 형식 오류 파일 */} + {invalidFiles.length > 0 && ( + <div className="space-y-2 mt-4"> + <h4 className="text-sm font-semibold text-red-600 dark:text-red-400 flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 파일명 형식 오류 ({invalidFiles.length}개) + </h4> + {invalidFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.error && ( + <div className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ {result.error} + </div> + )} + </div> + <XCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + </div> + </div> + + {/* 형식 안내 */} + <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-sm font-medium text-blue-900 dark:text-blue-100 mb-1"> + 📋 올바른 파일명 형식 + </div> + <code className="text-xs text-blue-700 dark:text-blue-300"> + [버림] [DrawingNo] [RevNo].[확장자] + </code> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + 예: testfile GTT-DE-007 R01.pdf + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 첫 번째 단어는 무시되며, 공백으로 구분됩니다 + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 네 번째 이상의 단어가 있으면 무시됩니다 + </div> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button + variant="outline" + onClick={handleCancel} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUpload} + disabled={validFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="h-4 w-4 mr-2" /> + 업로드 ({validFiles.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/dolce/dialogs/detail-drawing-dialog.tsx b/lib/dolce/dialogs/detail-drawing-dialog.tsx new file mode 100644 index 00000000..a06c9688 --- /dev/null +++ b/lib/dolce/dialogs/detail-drawing-dialog.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Plus, RefreshCw, Upload, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + FileInfoItem, + fetchDetailDwgReceiptList, + fetchFileInfoList, +} from "../actions"; +import { DrawingListTable } from "../table/drawing-list-table"; +import { detailDrawingColumns } from "../table/detail-drawing-columns"; +import { createFileListColumns } from "../table/file-list-columns"; +import { AddDetailDrawingDialog } from "./add-detail-drawing-dialog"; +import { UploadFilesToDetailDialog } from "./upload-files-to-detail-dialog"; + +interface DetailDrawingDialogProps { + drawing: UnifiedDwgReceiptItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; + vendorCode: string; + userId: string; + userName: string; + userEmail: string; + drawingKind: "B3" | "B4"; +} + +export function DetailDrawingDialog({ + drawing, + open, + onOpenChange, + vendorCode, + userId, + userName, + userEmail, + drawingKind, +}: DetailDrawingDialogProps) { + const [detailDrawings, setDetailDrawings] = useState<DetailDwgReceiptItem[]>([]); + const [selectedDetail, setSelectedDetail] = useState<DetailDwgReceiptItem | null>(null); + const [files, setFiles] = useState<FileInfoItem[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false); + + // 상세도면 목록 로드 + const loadDetailDrawings = useCallback(async () => { + if (!drawing) return; + + try { + setIsLoading(true); + const data = await fetchDetailDwgReceiptList({ + project: drawing.ProjectNo, + drawingNo: drawing.DrawingNo, + discipline: drawing.Discipline, + drawingKind: drawing.DrawingKind, + userId: "", // 조회 시 모든 사용자의 상세도면을 보기 위해 빈 문자열 전달 + }); + setDetailDrawings(data); + + // 첫 번째 상세도면 자동 선택 + if (data.length > 0 && !selectedDetail) { + setSelectedDetail(data[0]); + } + } catch (error) { + console.error("상세도면 로드 실패:", error); + toast.error("상세도면 로드에 실패했습니다"); + } finally { + setIsLoading(false); + } + }, [drawing, selectedDetail]); + + // 파일 목록 로드 + const loadFiles = useCallback(async () => { + if (!selectedDetail) { + setFiles([]); + return; + } + + try { + setIsLoadingFiles(true); + const data = await fetchFileInfoList(selectedDetail.UploadId); + setFiles(data); + } catch (error) { + console.error("파일 목록 로드 실패:", error); + toast.error("파일 목록 로드에 실패했습니다"); + } finally { + setIsLoadingFiles(false); + } + }, [selectedDetail]); + + // 다이얼로그 열릴 때 데이터 로드 + useEffect(() => { + if (open && drawing) { + loadDetailDrawings(); + } else { + setDetailDrawings([]); + setSelectedDetail(null); + setFiles([]); + } + }, [open, drawing, loadDetailDrawings]); + + // 선택된 상세도면 변경 시 파일 목록 로드 + useEffect(() => { + if (selectedDetail) { + loadFiles(); + } + }, [selectedDetail, loadFiles]); + + const handleDownload = async (file: FileInfoItem) => { + try { + toast.info("파일 다운로드를 준비 중입니다..."); + + // 파일 생성자의 userId를 사용하여 다운로드 + const response = await fetch("/api/dolce/download", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fileId: file.FileId, + userId: file.CreateUserId, // 파일 생성자의 ID 사용 + fileName: file.FileName, + }), + }); + + if (!response.ok) { + throw new Error("파일 다운로드 실패"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = file.FileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("파일 다운로드가 완료되었습니다"); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }; + + const handleRefresh = () => { + loadDetailDrawings(); + }; + + const handleAddComplete = () => { + setAddDialogOpen(false); + loadDetailDrawings(); + }; + + const handleUploadComplete = () => { + setUploadFilesDialogOpen(false); + loadFiles(); + }; + + const fileColumns = createFileListColumns({ onDownload: handleDownload }); + + // RegisterId + UploadId 조합으로 고유 ID 생성 + const getDetailDrawingId = (detail: DetailDwgReceiptItem) => { + return `${detail.RegisterId}_${detail.UploadId}`; + }; + + // B4인 경우 "도면입수"인 건만 상세도면 추가 및 파일 첨부 가능 + // B3인 경우 모든 건에 대해 가능 + const canAddDetailDrawing = drawingKind === "B3" || + (drawingKind === "B4" && drawing && 'DrawingMoveGbn' in drawing && drawing.DrawingMoveGbn === "도면입수"); + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[95vw] h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex flex-col gap-1"> + <span>상세도면 정보</span> + {drawing && ( + <span className="text-sm font-normal text-muted-foreground"> + {drawing.DrawingNo} | 프로젝트: {drawing.ProjectNo} | Discipline: {drawing.Discipline} | 종류: {drawing.DrawingKind} + </span> + )} + </DialogTitle> + </DialogHeader> + + <div className="flex-1 overflow-hidden flex flex-col gap-4"> + {/* 상단: 상세도면 리스트 */} + <Card className="flex-1 overflow-hidden flex flex-col"> + <CardHeader className="flex-row items-center justify-between py-3"> + <CardTitle className="text-base">상세도면 목록</CardTitle> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isLoading} + > + <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} /> + 새로고침 + </Button> + {canAddDetailDrawing && ( + <Button + variant="default" + size="sm" + onClick={() => setAddDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + 상세도면 추가 + </Button> + )} + </div> + </CardHeader> + <CardContent className="flex-1 overflow-y-auto p-4"> + <DrawingListTable<DetailDwgReceiptItem, unknown> + columns={detailDrawingColumns} + data={detailDrawings} + onRowClick={setSelectedDetail} + selectedRow={selectedDetail || undefined} + getRowId={(row) => getDetailDrawingId(row)} + /> + </CardContent> + </Card> + + {/* 하단: 첨부파일 리스트 */} + <Card className="flex-1 overflow-hidden flex flex-col"> + <CardHeader className="flex-row items-center justify-between py-3"> + <CardTitle className="text-base"> + 첨부파일 목록 + {selectedDetail && ` - Rev. ${selectedDetail.DrawingRevNo}`} + </CardTitle> + {selectedDetail && canAddDetailDrawing && ( + <Button + variant="default" + size="sm" + onClick={() => setUploadFilesDialogOpen(true)} + > + <Upload className="h-4 w-4 mr-2" /> + 파일 업로드 + </Button> + )} + </CardHeader> + <CardContent className="flex-1 overflow-y-auto p-4"> + {!selectedDetail ? ( + <div className="h-full flex items-center justify-center text-muted-foreground"> + 상세도면을 선택하세요 + </div> + ) : isLoadingFiles ? ( + <div className="space-y-4"> + <div className="flex items-center justify-center gap-2 text-muted-foreground py-8"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span>Loading files...</span> + </div> + <div className="space-y-2"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + </div> + </div> + ) : ( + <DrawingListTable + columns={fileColumns} + data={files} + /> + )} + </CardContent> + </Card> + </div> + </DialogContent> + </Dialog> + + <AddDetailDrawingDialog + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + drawing={drawing} + vendorCode={vendorCode} + userId={userId} + userName={userName} + userEmail={userEmail} + onComplete={handleAddComplete} + drawingKind={drawingKind} + /> + + {selectedDetail && ( + <UploadFilesToDetailDialog + open={uploadFilesDialogOpen} + onOpenChange={setUploadFilesDialogOpen} + uploadId={selectedDetail.UploadId} + drawingNo={selectedDetail.DrawingNo} + revNo={selectedDetail.DrawingRevNo} + userId={userId} + onUploadComplete={handleUploadComplete} + /> + )} + </> + ); +} + diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx new file mode 100644 index 00000000..1d8ac582 --- /dev/null +++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx @@ -0,0 +1,314 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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"; + +interface UploadFilesToDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + uploadId: string; + drawingNo: string; + revNo: string; + userId: string; + onUploadComplete?: () => void; +} + +export function UploadFilesToDetailDialog({ + open, + onOpenChange, + uploadId, + drawingNo, + revNo, + userId, + onUploadComplete, +}: UploadFilesToDetailDialogProps) { + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + // 다이얼로그 닫을 때 초기화 + 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); + } + }; + + // 파일 제거 + const handleRemoveFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + // 업로드 처리 + const handleUpload = async () => { + if (selectedFiles.length === 0) { + toast.error("파일을 선택해주세요"); + return; + } + + 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); + }); + + // 서버 액션 호출 + const result: UploadFilesResult = await uploadFilesToDetailDrawing(formData); + + if (result.success) { + toast.success(`${result.uploadedCount}개 파일 업로드 완료`); + onOpenChange(false); + onUploadComplete?.(); + } else { + toast.error(result.error || "업로드 실패"); + } + } catch (error) { + console.error("업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" + ); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>파일 업로드</DialogTitle> + <DialogDescription> + {drawingNo} - Rev. {revNo}에 파일을 업로드합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 선택한 상세도면의 UploadId에 파일을 추가합니다. 파일 업로드 후 자동으로 메타데이터가 저장됩니다. + </AlertDescription> + </Alert> + + {/* 파일 선택 영역 */} + <div + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${ + isDragging + ? "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" + > + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragging ? "text-primary" : "text-muted-foreground" + }`} + /> + <p + className={`text-sm transition-colors ${ + isDragging + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragging + ? "파일을 여기에 놓으세요" + : "클릭하거나 파일을 드래그하여 선택"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP (max 1GB per file) + </p> + </label> + </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> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUpload} + disabled={selectedFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 ({selectedFiles.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + |
