diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
| commit | 8547034e6d82e4d1184f35af2dbff67180d89dc8 (patch) | |
| tree | 2e1835040f39adc7d0c410a108ebb558f9971a8b /lib/dolce-v2/dialogs | |
| parent | 3131dce1f0c90d960f53bd384045b41023064bc4 (diff) | |
(김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등
Diffstat (limited to 'lib/dolce-v2/dialogs')
| -rw-r--r-- | lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx | 695 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx | 372 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/sync-items-dialog.tsx | 376 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx | 247 |
4 files changed, 1690 insertions, 0 deletions
diff --git a/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx b/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx new file mode 100644 index 00000000..b8650b1a --- /dev/null +++ b/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx @@ -0,0 +1,695 @@ +"use client"; + +import { useState, useEffect } from "react"; +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 { Textarea } from "@/components/ui/textarea"; +import { Upload, X, FileIcon, Info, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + editDetailDwgReceiptV2, // V2 Action + deleteLocalDetailDrawing +} from "@/lib/dolce-v2/actions"; +import { v4 as uuidv4 } from "uuid"; +import { useFileUploadWithProgress } from "@/lib/dolce/hooks/use-file-upload-with-progress"; +// import { uploadFilesWithProgress } from "../utils/upload-with-progress"; // V2에서는 사용 안함 (Action에 포함) +import { + getB3DrawingUsageOptions, + getB3RegisterKindOptions, + getB4DrawingUsageOptions, + getB4RegisterKindOptions +} from "@/lib/dolce/utils/code-translator"; + +interface AddAndModifyDetailDrawingDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + drawing: UnifiedDwgReceiptItem | null; + vendorCode: string; + userId: string; + userName: string; + userEmail: string; + onComplete: () => void; + drawingKind: "B3" | "B4"; + lng: string; + mode?: "add" | "edit"; + detailDrawing?: DetailDwgReceiptItem | null; +} + +export function AddAndModifyDetailDrawingDialogV2({ + open, + onOpenChange, + drawing, + vendorCode, + userId, + userName, + userEmail, + onComplete, + drawingKind, + lng, + mode = "add", + detailDrawing = null, +}: AddAndModifyDetailDrawingDialogV2Props) { + const { t } = useTranslation(lng, "dolce"); + const [drawingUsage, setDrawingUsage] = useState<string>(""); + const [registerKind, setRegisterKind] = useState<string>(""); + const [revision, setRevision] = useState<string>(""); + const [revisionError, setRevisionError] = useState<string>(""); + const [comment, setComment] = useState<string>(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Edit 모드일 때 초기값 설정 + useEffect(() => { + if (mode === "edit" && detailDrawing && open) { + setDrawingUsage(detailDrawing.DrawingUsage || ""); + setRegisterKind(detailDrawing.RegisterKind || ""); + setRevision(detailDrawing.DrawingRevNo || ""); + setComment(detailDrawing.RegisterDesc || ""); + } else if (mode === "add" && open) { + // Add 모드로 열릴 때는 초기화 + resetForm(); + } + }, [mode, detailDrawing, open]); + + // 옵션 생성 (다국어 지원) + const drawingUsageOptions = drawingKind === "B3" + ? getB3DrawingUsageOptions(lng) + : getB4DrawingUsageOptions(lng); + + const registerKindOptions = drawingKind === "B3" + ? getB3RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: A, B, C 또는 R00, R01, R02" : "e.g. A, B, C or R00, R01, R02" + })) + : getB4RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: R00, R01, R02, R03" : "e.g. R00, R01, R02, R03" + })); + + // 파일 업로드 훅 사용 + const { + files, + removeFile, + clearFiles, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); + + // Revision 유효성 검증 함수 + const validateRevision = (value: string): string => { + if (!value.trim()) { + return t("addDetailDialog.revisionRequired"); + } + + const upperValue = value.toUpperCase().trim(); + + // A-Z 패턴 (단일 알파벳) + if (/^[A-Z]$/.test(upperValue)) { + return ""; + } + + // R00-R99 패턴 + if (/^R\d{2}$/.test(upperValue)) { + return ""; + } + + return t("addDetailDialog.revisionInvalidFormat"); + }; + + // Revision 입력 핸들러 + const handleRevisionChange = (value: string) => { + const processedValue = value.toUpperCase(); + setRevision(processedValue); + + // 값이 있을 때만 validation + if (processedValue.trim()) { + const error = validateRevision(processedValue); + setRevisionError(error); + } else { + setRevisionError(""); + } + }; + + // 폼 초기화 + const resetForm = () => { + setDrawingUsage(""); + setRegisterKind(""); + setRevision(""); + setRevisionError(""); + setComment(""); + clearFiles(); + }; + + // 제출 + const handleSubmit = async () => { + // 유효성 검사 + if (!registerKind) { + toast.error(t("addDetailDialog.selectRegisterKindError")); + return; + } + + if (drawingUsage !== "CMT") { + if (!revision.trim()) { + toast.error(t("addDetailDialog.selectRevisionError")); + setRevisionError(t("addDetailDialog.revisionRequired")); + return; + } + + // Revision 형식 검증 + const revisionValidationError = validateRevision(revision); + if (revisionValidationError) { + toast.error(revisionValidationError); + setRevisionError(revisionValidationError); + return; + } + } + + // Add 모드일 때만 파일 필수 + if (mode === "add") { + if (!drawing) return; + if (!drawingUsage) { + toast.error(t("addDetailDialog.selectDrawingUsageError")); + return; + } + if (files.length === 0) { + toast.error(t("addDetailDialog.selectFilesError")); + return; + } + } + + // Edit 모드일 때는 detailDrawing 필수 + if (mode === "edit" && !detailDrawing) { + toast.error(t("editDetailDialog.editError")); + return; + } + + try { + setIsSubmitting(true); + + // FormData 구성 + const formData = new FormData(); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("vendorCode", vendorCode); + formData.append("email", userEmail); + + if (mode === "add" && drawing) { + const uploadId = uuidv4(); + + const dwgList = [ + { + Mode: "ADD", + Status: "Submitted", + 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: drawingUsage === "CMT" ? null : revision, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: comment, + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + + // 파일 추가 + formData.append("fileCount", String(files.length)); + files.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + } else if (mode === "edit" && detailDrawing) { + const dwgList = [ + { + Mode: "MOD", + Status: detailDrawing.Status, + RegisterId: detailDrawing.RegisterId, + ProjectNo: detailDrawing.ProjectNo, + Discipline: detailDrawing.Discipline, + DrawingKind: detailDrawing.DrawingKind, + DrawingNo: detailDrawing.DrawingNo, + DrawingName: detailDrawing.DrawingName, + RegisterGroupId: detailDrawing.RegisterGroupId, + RegisterSerialNo: detailDrawing.RegisterSerialNo, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: detailDrawing.Category, + Receiver: detailDrawing.Receiver, + Manager: detailDrawing.Manager, + RegisterDesc: comment, + UploadId: detailDrawing.UploadId, + RegCompanyCode: detailDrawing.RegCompanyCode || vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + formData.append("fileCount", "0"); // 수정 시에는 메타데이터만 수정 (파일 수정은 별도) + } + + // Action 호출 + const result = await editDetailDwgReceiptV2(formData); + + if (result.success) { + toast.success(mode === "add" ? t("addDetailDialog.addSuccess") : t("editDetailDialog.editSuccess")); + resetForm(); + onComplete(); + onOpenChange(false); + } else { + throw new Error("Action failed"); + } + + } catch (error) { + console.error("상세도면 처리 실패:", error); + toast.error(mode === "add" ? t("addDetailDialog.addErrorMessage") : t("editDetailDialog.editErrorMessage")); + } finally { + setIsSubmitting(false); + } + }; + + // 삭제 핸들러 + const handleDelete = async () => { + if (!detailDrawing) return; + + if (!confirm(lng === "ko" ? "정말로 삭제하시겠습니까?" : "Are you sure you want to delete?")) return; + + try { + setIsSubmitting(true); + // uploadId만 있으면 됨 + const result = await deleteLocalDetailDrawing(detailDrawing.UploadId); + + if (result.success) { + toast.success(lng === "ko" ? "삭제되었습니다." : "Deleted successfully."); + onComplete(); + onOpenChange(false); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error("삭제 실패:", error); + toast.error(lng === "ko" ? "삭제 실패" : "Delete failed"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + // 유효성 검사 + if (!registerKind) { + toast.error(t("addDetailDialog.selectRegisterKindError")); + return; + } + + if (drawingUsage !== "CMT") { + if (!revision.trim()) { + toast.error(t("addDetailDialog.selectRevisionError")); + setRevisionError(t("addDetailDialog.revisionRequired")); + return; + } + + // Revision 형식 검증 + const revisionValidationError = validateRevision(revision); + if (revisionValidationError) { + toast.error(revisionValidationError); + setRevisionError(revisionValidationError); + return; + } + } + + // Add 모드일 때만 파일 필수 + if (mode === "add") { + if (!drawing) return; + if (!drawingUsage) { + toast.error(t("addDetailDialog.selectDrawingUsageError")); + return; + } + if (files.length === 0) { + toast.error(t("addDetailDialog.selectFilesError")); + return; + } + } + + // Edit 모드일 때는 detailDrawing 필수 + if (mode === "edit" && !detailDrawing) { + toast.error(t("editDetailDialog.editError")); + return; + } + + try { + setIsSubmitting(true); + + // FormData 구성 + const formData = new FormData(); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("vendorCode", vendorCode); + formData.append("email", userEmail); + + if (mode === "add" && drawing) { + const uploadId = uuidv4(); + + const dwgList = [ + { + Mode: "ADD", + Status: "Submitted", + 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: drawingUsage === "CMT" ? null : revision, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: comment, + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + + // 파일 추가 + formData.append("fileCount", String(files.length)); + files.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + } else if (mode === "edit" && detailDrawing) { + const dwgList = [ + { + Mode: "MOD", + Status: detailDrawing.Status, + RegisterId: detailDrawing.RegisterId, + ProjectNo: detailDrawing.ProjectNo, + Discipline: detailDrawing.Discipline, + DrawingKind: detailDrawing.DrawingKind, + DrawingNo: detailDrawing.DrawingNo, + DrawingName: detailDrawing.DrawingName, + RegisterGroupId: detailDrawing.RegisterGroupId, + RegisterSerialNo: detailDrawing.RegisterSerialNo, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: detailDrawing.Category, + Receiver: detailDrawing.Receiver, + Manager: detailDrawing.Manager, + RegisterDesc: comment, + UploadId: detailDrawing.UploadId, + RegCompanyCode: detailDrawing.RegCompanyCode || vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + formData.append("fileCount", "0"); // 수정 시에는 메타데이터만 수정 (파일 수정은 별도) + } + + // Action 호출 + const result = await editDetailDwgReceiptV2(formData); + + if (result.success) { + toast.success(mode === "add" ? t("addDetailDialog.addSuccess") : t("editDetailDialog.editSuccess")); + resetForm(); + onComplete(); + onOpenChange(false); + } else { + throw new Error("Action failed"); + } + + } catch (error) { + console.error("상세도면 처리 실패:", error); + toast.error(mode === "add" ? t("addDetailDialog.addErrorMessage") : t("editDetailDialog.editErrorMessage")); + } finally { + setIsSubmitting(false); + } + }; + + // DrawingUsage가 변경되면 RegisterKind 초기화 + const handleDrawingUsageChange = (value: string) => { + setDrawingUsage(value); + setRegisterKind(""); + setRevision(""); + setRevisionError(""); + }; + + // 선택된 RegisterKind의 Revision Rule + const revisionRule = registerKindOptions.find((opt) => opt.value === registerKind)?.revisionRule || ""; + + // 버튼 활성화 조건 + const isFormValid = mode === "add" + ? drawingUsage.trim() !== "" && + registerKind.trim() !== "" && + (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError)) && + files.length > 0 + : registerKind.trim() !== "" && + (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError)); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle> + {mode === "edit" ? t("editDetailDialog.title") : t("addDetailDialog.title")} + </DialogTitle> + </DialogHeader> + + <div className="space-y-6"> + {/* 도면 정보 표시 */} + {mode === "add" && 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> + )} + + {mode === "edit" && detailDrawing && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <div className="font-medium">{detailDrawing.DrawingNo} - Rev. {detailDrawing.DrawingRevNo}</div> + <div className="text-sm text-muted-foreground">{detailDrawing.DrawingName}</div> + </AlertDescription> + </Alert> + )} + + {/* 도면용도 선택 (Add 모드에서만 표시) */} + {mode === "add" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.drawingUsageLabel")}</Label> + <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> + <SelectTrigger> + <SelectValue placeholder={t("addDetailDialog.drawingUsagePlaceholder")} /> + </SelectTrigger> + <SelectContent> + {drawingUsageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {/* 등록종류 선택 */} + <div className="space-y-2"> + <Label>{t("addDetailDialog.registerKindLabel")}</Label> + <Select + value={registerKind} + onValueChange={setRegisterKind} + disabled={mode === "add" && !drawingUsage} + > + <SelectTrigger> + <SelectValue placeholder={t("addDetailDialog.registerKindPlaceholder")} /> + </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"> + {t("addDetailDialog.revisionFormatPrefix")}{revisionRule} + </p> + )} + </div> + + {/* Revision 입력 */} + {drawingUsage !== "CMT" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.revisionLabel")}</Label> + <Input + value={revision} + onChange={(e) => handleRevisionChange(e.target.value)} + placeholder={t("addDetailDialog.revisionPlaceholder")} + disabled={!registerKind} + className={revisionError ? "border-red-500 focus-visible:ring-red-500" : ""} + /> + {revisionError && ( + <p className="text-sm text-red-500 flex items-center gap-1"> + {revisionError} + </p> + )} + {!revisionError && revision && ( + <p className="text-sm text-green-600 flex items-center gap-1"> + {t("addDetailDialog.revisionValid")} + </p> + )} + </div> + )} + + {/* Comment 입력 */} + <div className="space-y-2"> + <Label>{t("addDetailDialog.commentLabel")}</Label> + <Textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + placeholder={t("addDetailDialog.commentPlaceholder")} + rows={3} + className="resize-none" + /> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.commentMaxLength")} + </p> + </div> + + {/* 파일 업로드 (Add 모드에서만 표시) */} + {mode === "add" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.attachmentLabel")}</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"> + {t("addDetailDialog.dragDropText")} + </p> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.fileInfo")} + </p> + </div> + </div> + ) : ( + <div className="space-y-2"> + <p className="text-sm font-medium"> + {t("addDetailDialog.filesSelected", { count: files.length })} + </p> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.addMoreFiles")} + </p> + </div> + )} + </div> + + {/* 선택된 파일 목록 */} + {files.length > 0 && ( + <div className="space-y-2 mt-4"> + <div className="flex items-center justify-between mb-2"> + <h4 className="text-sm font-medium"> + {t("addDetailDialog.selectedFiles", { count: files.length })} + </h4> + <Button + variant="ghost" + size="sm" + onClick={clearFiles} + > + {t("addDetailDialog.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center gap-2 p-2 border rounded-lg bg-muted/50" + > + <FileIcon 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> + <Button + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + )} + </div> + + <DialogFooter className="sm:justify-between"> + {mode === "edit" && detailDrawing?.Status === "EVCP Saved" && ( + <Button variant="destructive" onClick={handleDelete} disabled={isSubmitting} type="button"> + {isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />} + {lng === "ko" ? "삭제" : "Delete"} + </Button> + )} + <div className="flex gap-2 justify-end w-full"> + <Button variant="outline" onClick={handleCancel} disabled={isSubmitting}> + {t("addDetailDialog.cancelButton")} + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid}> + {isSubmitting + ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t("addDetailDialog.processingButton")}</> + : mode === "edit" + ? t("editDetailDialog.updateButton") + : t("addDetailDialog.addButton") + } + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx b/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx new file mode 100644 index 00000000..5cce514c --- /dev/null +++ b/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx @@ -0,0 +1,372 @@ +"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 { FolderOpen, Loader2, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { Progress } from "@/components/ui/progress"; +import { useTranslation } from "@/i18n/client"; +import { + validateB4FileName, + B4UploadValidationDialog, + type FileValidationResult, +} from "@/lib/dolce/dialogs/b4-upload-validation-dialog"; +import { + checkB4MappingStatus, + type MappingCheckResult, + type B4BulkUploadResult, +} from "@/lib/dolce/actions"; +import { bulkUploadB4FilesV3 } from "@/lib/dolce-v2/actions"; + +interface B4BulkUploadDialogV3SyncProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + userName: string; + userEmail: string; + vendorCode: string; + onUploadComplete?: () => void; + lng: string; +} + +type UploadStep = "files" | "validation" | "uploading" | "complete"; + +export function B4BulkUploadDialogV3Sync({ + open, + onOpenChange, + projectNo, + userId, + userName, + userEmail, + vendorCode, + onUploadComplete, + lng, +}: B4BulkUploadDialogV3SyncProps) { + const { t } = useTranslation(lng, "dolce"); + const [currentStep, setCurrentStep] = useState<UploadStep>("files"); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [validationResults, setValidationResults] = useState<FileValidationResult[]>([]); + const [mappingResultsMap, setMappingResultsMap] = useState<Map<string, MappingCheckResult>>(new Map()); + const [showValidationDialog, setShowValidationDialog] = useState(false); + const [isDragging, setIsDragging] = useState(false); + // const [uploadProgress, setUploadProgress] = useState(0); // 로컬 저장은 순식간이라 프로그레스 불필요 + const [uploadResult, setUploadResult] = useState<{ success: boolean, syncIds: string[], error?: string } | null>(null); + + // Reset on close + React.useEffect(() => { + if (!open) { + setCurrentStep("files"); + setSelectedFiles([]); + setValidationResults([]); + setMappingResultsMap(new Map()); + setShowValidationDialog(false); + setIsDragging(false); + setUploadResult(null); + } + }, [open]); + + // File Selection Handler (동일) + 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(t("bulkUpload.duplicateFileError")); + return; + } + setSelectedFiles((prev) => [...prev, ...newFiles]); + toast.success(t("bulkUpload.filesSelectedSuccess", { count: newFiles.length })); + }; + + // Drag & Drop Handlers (생략 - 코드 길이 줄임) + const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; + const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); 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)); }; + + // Step 1 Next: Validation (동일) + const handleFilesNext = () => { + if (selectedFiles.length === 0) { + toast.error(t("bulkUpload.selectFilesError")); + return; + } + setCurrentStep("validation"); + handleValidate(); + }; + + // Validation Process (V3 - 기존과 동일) + const handleValidate = async () => { + try { + // 1. Parse Filenames + 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. Call MatchBatchFileDwg to check mapping status + const mappingCheckItems = parsedFiles.map((r) => ({ + DrawingNo: r.parsed!.drawingNo, + RevNo: r.parsed!.revNo, + FileNm: r.file.name, + })); + + const mappingResults = await checkB4MappingStatus(projectNo, mappingCheckItems); + + const newMappingResultsMap = new Map<string, MappingCheckResult>(); + mappingResults.forEach((result) => { + newMappingResultsMap.set(result.FileNm, result); + }); + setMappingResultsMap(newMappingResultsMap); + + // 3. Merge results + const finalResults: FileValidationResult[] = parseResults.map((parseResult) => { + if (!parseResult.valid || !parseResult.parsed) return parseResult; + const mappingResult = newMappingResultsMap.get(parseResult.file.name); + + if (!mappingResult) return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notFound") }; + if (mappingResult.MappingYN !== "Y") return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notRegistered") }; + if (mappingResult.DrawingMoveGbn !== "도면입수") return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notGttDeliverables") }; + + return { + ...parseResult, + mappingStatus: "available" as const, + drawingName: mappingResult.DrawingName || undefined, + registerGroupId: mappingResult.RegisterGroupId, + }; + }); + + setValidationResults(finalResults); + setShowValidationDialog(true); + } catch (error) { + console.error("Validation failed:", error); + toast.error(error instanceof Error ? error.message : t("bulkUpload.validationError")); + setCurrentStep("files"); + } + }; + + // Confirm Upload & Save (V3 Sync - 수정됨) + const handleConfirmUpload = async (validFiles: FileValidationResult[]) => { + setIsUploading(true); + setCurrentStep("uploading"); + setShowValidationDialog(false); + + try { + // FormData 구성 + const formData = new FormData(); + formData.append("projectNo", projectNo); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("email", userEmail); + formData.append("vendorCode", vendorCode); + formData.append("registerKind", ""); // B4는 mappingData에 있음, 혹은 필요하다면 추가 + formData.append("fileCount", String(validFiles.length)); + + validFiles.forEach((fileResult, index) => { + formData.append(`file_${index}`, fileResult.file); + + const mappingData = mappingResultsMap.get(fileResult.file.name); + if (mappingData) { + // UploadId가 없으면 생성 + if (!mappingData.UploadId) { + mappingData.UploadId = uuidv4(); // 임시 ID 생성 (서버에서 그룹핑용) + } + formData.append(`mappingData_${index}`, JSON.stringify(mappingData)); + } + }); + + // Action 호출 + const result = await bulkUploadB4FilesV3(formData); + + setUploadResult(result); + setCurrentStep("complete"); + + if (result.success) { + toast.success(t("bulkUpload.uploadSuccessToast", { successCount: validFiles.length, total: validFiles.length })); + } else { + toast.error(result.error || t("bulkUpload.uploadError")); + } + + } catch (error) { + console.error("Upload process failed:", error); + toast.error(error instanceof Error ? error.message : t("bulkUpload.uploadError")); + setCurrentStep("files"); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{t("bulkUpload.title")} (Offline)</DialogTitle> + <DialogDescription> + {currentStep === "files" && t("bulkUpload.stepFiles")} + {currentStep === "validation" && t("bulkUpload.stepValidation")} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* Step 1: Files */} + {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-v3-sync" + /> + <label + htmlFor="b4-file-upload-v3-sync" + 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 text-muted-foreground"> + {isDragging ? t("bulkUpload.fileDropHere") : t("bulkUpload.fileSelectArea")} + </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"> + {t("bulkUpload.selectedFiles", { count: selectedFiles.length })} + </h4> + <Button variant="ghost" size="sm" onClick={() => setSelectedFiles([])}> + {t("bulkUpload.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-auto space-y-2"> + {selectedFiles.map((file, index) => ( + <div key={index} className="flex items-center justify-between p-2 rounded bg-muted/50"> + <p className="text-sm truncate">{file.name}</p> + <Button variant="ghost" size="sm" onClick={() => handleRemoveFile(index)}> + {t("bulkUpload.removeFile")} + </Button> + </div> + ))} + </div> + </div> + )} + </> + )} + + {/* Loading Indicator */} + {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">{t("bulkUpload.validating")}</p> + </div> + )} + + {/* Uploading (Saving locally) */} + {currentStep === "uploading" && ( + <div className="space-y-6 py-4"> + <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">Saving to Local...</h3> + <p className="text-sm text-muted-foreground">Please wait while we buffer your files.</p> + </div> + </div> + )} + + {/* Completion Screen */} + {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">Saved Locally</h3> + <p className="text-sm text-muted-foreground"> + {uploadResult.syncIds.length} items are ready to sync. + </p> + </div> + + <div className="flex justify-center"> + <Button onClick={() => { onOpenChange(false); onUploadComplete?.(); }}> + {t("bulkUpload.confirmButton")} + </Button> + </div> + </div> + )} + </div> + + {/* Footer */} + {currentStep !== "uploading" && currentStep !== "complete" && currentStep !== "validation" && ( + <DialogFooter> + {currentStep === "files" && ( + <> + <Button variant="outline" onClick={() => onOpenChange(false)}> + {t("bulkUpload.cancelButton")} + </Button> + <Button onClick={handleFilesNext} disabled={selectedFiles.length === 0}> + {t("bulkUpload.validateButton")} + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + </DialogFooter> + )} + </DialogContent> + </Dialog> + + {/* Validation Dialog */} + <B4UploadValidationDialog + open={showValidationDialog} + onOpenChange={(open) => { + setShowValidationDialog(open); + if (!open && currentStep !== "uploading" && currentStep !== "complete") { + setCurrentStep("files"); + } + }} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + /> + </> + ); +} + diff --git a/lib/dolce-v2/dialogs/sync-items-dialog.tsx b/lib/dolce-v2/dialogs/sync-items-dialog.tsx new file mode 100644 index 00000000..93ea6ae6 --- /dev/null +++ b/lib/dolce-v2/dialogs/sync-items-dialog.tsx @@ -0,0 +1,376 @@ +"use client"; + +import * as React from "react"; +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Loader2, RefreshCw, CheckCircle2, XCircle, FileText, FolderInput } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { fetchProjectPendingSyncItems, syncDolceItem, PendingSyncItemDetail } from "@/lib/dolce-v2/actions"; +import { format } from "date-fns"; + +interface SyncItemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + vendorCode: string; + onSyncComplete: () => void; + lng: string; +} + +// UI 표시용 Row 타입 (파일 단위로 확장) +interface DisplayRow { + rowId: string; // 유니크 키 (syncId + fileIndex) + syncId: string; // Sync Item ID (체크박스 그룹핑용) + type: string; + createdAt: Date; + userName?: string; + status: "pending" | "syncing" | "success" | "error"; + errorMessage?: string; + + // 표시 정보 + drawingNo: string; + drawingName: string; + discipline: string; + revision: string; + registerKind: string; + fileName: string; + fileSize: string; +} + +export function SyncItemsDialog({ + open, + onOpenChange, + projectNo, + userId, + vendorCode, + onSyncComplete, + lng, +}: SyncItemsDialogProps) { + const { t } = useTranslation(lng, "dolce"); + const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + + const [myRows, setMyRows] = useState<DisplayRow[]>([]); + const [otherRows, setOtherRows] = useState<DisplayRow[]>([]); + + // 선택된 Sync Item ID들 (파일 단위가 아니라 Sync Item 단위로 선택) + const [selectedSyncIds, setSelectedSyncIds] = useState<Set<string>>(new Set()); + + // 데이터 변환 헬퍼 + const convertToDisplayRows = (items: PendingSyncItemDetail[], defaultStatus: DisplayRow["status"] = "pending"): DisplayRow[] => { + return items.flatMap((item) => { + // 파일이 없으면 메타데이터만 있는 1개 행 생성 + if (!item.files || item.files.length === 0) { + return [{ + rowId: `${item.id}_meta`, + syncId: item.id, + type: item.type, + createdAt: item.createdAt, + userName: item.userName, + status: defaultStatus, + drawingNo: item.drawingNo, + drawingName: item.drawingName, + discipline: item.discipline, + revision: item.revision, + registerKind: item.registerKind, + fileName: "(Metadata Only)", + fileSize: "-", + }]; + } + + // 파일이 있으면 파일별로 행 생성 + return item.files.map((file, idx) => ({ + rowId: `${item.id}_file_${idx}`, + syncId: item.id, + type: item.type, + createdAt: item.createdAt, + userName: item.userName, + status: defaultStatus, + drawingNo: item.drawingNo, + drawingName: item.drawingName, + discipline: item.discipline, + revision: item.revision, + registerKind: item.registerKind, + fileName: file.name, + fileSize: (file.size / 1024 / 1024).toFixed(2) + " MB", + })); + }); + }; + + // 데이터 로드 + const loadData = async () => { + if (!open) return; + + setIsLoading(true); + try { + const { myItems, otherItems } = await fetchProjectPendingSyncItems({ + projectNo, + currentUserId: userId, + currentVendorCode: vendorCode, + }); + + setMyRows(convertToDisplayRows(myItems)); + setOtherRows(convertToDisplayRows(otherItems)); + + // 기본적으로 내 아이템 모두 선택 + setSelectedSyncIds(new Set(myItems.map(item => item.id))); + + } catch (error) { + console.error("Failed to load sync items:", error); + toast.error("Failed to load synchronization items."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (open) { + loadData(); + } + }, [open, projectNo, userId, vendorCode]); + + // 체크박스 핸들러 (Sync Item 단위로 토글) + const toggleSelect = (syncId: string) => { + const newSelected = new Set(selectedSyncIds); + if (newSelected.has(syncId)) { + newSelected.delete(syncId); + } else { + newSelected.add(syncId); + } + setSelectedSyncIds(newSelected); + }; + + const toggleSelectAll = () => { + // 현재 화면에 표시된 myRows에 포함된 모든 unique syncId 수집 + const allSyncIds = new Set(myRows.map(r => r.syncId)); + + if (selectedSyncIds.size === allSyncIds.size) { + setSelectedSyncIds(new Set()); + } else { + setSelectedSyncIds(allSyncIds); + } + }; + + // 동기화 실행 + const handleSync = async () => { + if (selectedSyncIds.size === 0) return; + + setIsSyncing(true); + + // 선택된 ID 목록 + const idsToSync = Array.from(selectedSyncIds); + let successCount = 0; + let failCount = 0; + + for (const id of idsToSync) { + // 상태: 동기화 중 (해당 syncId를 가진 모든 Row 업데이트) + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "syncing" } : r)); + + try { + await syncDolceItem(id); + + // 상태: 성공 + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "success" } : r)); + successCount++; + } catch (error) { + console.error(`Sync failed for ${id}:`, error); + // 상태: 실패 + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "error", errorMessage: error instanceof Error ? error.message : "Unknown error" } : r)); + failCount++; + } + } + + setIsSyncing(false); + + if (successCount > 0) { + toast.success(`Successfully synced ${successCount} items.`); + onSyncComplete(); // 부모에게 알림 (카운트 갱신 등) + } + + if (failCount > 0) { + toast.error(`Failed to sync ${failCount} items. Check the list for details.`); + } + }; + + return ( + <Dialog open={open} onOpenChange={(v) => !isSyncing && onOpenChange(v)}> + <DialogContent className="max-w-[90vw] w-[90vw] h-[90vh] max-h-[90vh] flex flex-col p-0 gap-0"> + <DialogHeader className="p-6 border-b flex-shrink-0"> + <DialogTitle>Server Synchronization</DialogTitle> + <DialogDescription> + Upload locally saved items to the external server. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden flex flex-col p-6 gap-6 bg-muted/10"> + {/* 내 아이템 (동기화 대상) */} + <div className="flex-1 flex flex-col min-h-0 border rounded-md bg-background shadow-sm"> + <div className="p-3 border-b flex items-center justify-between bg-muted/20"> + <h3 className="font-semibold text-sm flex items-center gap-2"> + <FolderInput className="h-4 w-4" /> + My Pending Items ({new Set(myRows.map(r => r.syncId)).size} items, {myRows.length} files) + </h3> + <Button variant="ghost" size="sm" onClick={loadData} disabled={isSyncing || isLoading}> + <RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} /> + </Button> + </div> + + <div className="flex-1 overflow-auto relative"> + <Table> + <TableHeader className="sticky top-0 z-10 bg-background"> + <TableRow> + <TableHead className="w-[40px]"> + <Checkbox + checked={myRows.length > 0 && selectedSyncIds.size === new Set(myRows.map(r => r.syncId)).size} + onCheckedChange={toggleSelectAll} + disabled={isSyncing || myRows.length === 0} + /> + </TableHead> + <TableHead className="w-[150px]">Drawing No</TableHead> + <TableHead className="min-w-[200px]">Drawing Name</TableHead> + <TableHead className="w-[100px]">Discipline</TableHead> + <TableHead className="w-[80px]">Rev</TableHead> + <TableHead className="w-[100px]">Kind</TableHead> + <TableHead className="min-w-[200px]">File Name</TableHead> + <TableHead className="w-[100px]">Size</TableHead> + <TableHead className="w-[140px]">Date</TableHead> + <TableHead className="w-[100px]">Status</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {isLoading ? ( + <TableRow> + <TableCell colSpan={10} className="text-center py-8"> + <Loader2 className="h-6 w-6 animate-spin mx-auto" /> + </TableCell> + </TableRow> + ) : myRows.length === 0 ? ( + <TableRow> + <TableCell colSpan={10} className="text-center py-8 text-muted-foreground"> + No pending items found. + </TableCell> + </TableRow> + ) : ( + myRows.map((row) => ( + <TableRow key={row.rowId} className="hover:bg-muted/5"> + <TableCell> + <Checkbox + checked={selectedSyncIds.has(row.syncId)} + onCheckedChange={() => toggleSelect(row.syncId)} + disabled={isSyncing || row.status === "success"} + /> + </TableCell> + <TableCell className="font-medium text-xs">{row.drawingNo || "-"}</TableCell> + <TableCell className="text-xs truncate max-w-[200px]" title={row.drawingName}>{row.drawingName || "-"}</TableCell> + <TableCell className="text-xs">{row.discipline || "-"}</TableCell> + <TableCell className="text-xs">{row.revision || "-"}</TableCell> + <TableCell className="text-xs">{row.registerKind || "-"}</TableCell> + <TableCell className="text-xs flex items-center gap-2"> + <FileText className="h-3 w-3 text-muted-foreground" /> + <span className="truncate max-w-[200px]" title={row.fileName}>{row.fileName}</span> + </TableCell> + <TableCell className="text-xs text-muted-foreground">{row.fileSize}</TableCell> + <TableCell className="text-xs text-muted-foreground"> + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + </TableCell> + <TableCell> + {row.status === "pending" && <span className="text-muted-foreground text-xs">Pending</span>} + {row.status === "syncing" && <Loader2 className="h-4 w-4 animate-spin text-primary" />} + {row.status === "success" && <CheckCircle2 className="h-4 w-4 text-green-500" />} + {row.status === "error" && <XCircle className="h-4 w-4 text-destructive" />} + {row.errorMessage && row.status === "error" && ( + <span className="sr-only">{row.errorMessage}</span> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + </div> + + {/* 다른 사용자 아이템 (참고용) */} + {otherRows.length > 0 && ( + <div className="h-1/3 flex flex-col min-h-0 border rounded-md bg-background shadow-sm opacity-90"> + <div className="p-3 border-b bg-muted/20"> + <h3 className="font-semibold text-sm text-muted-foreground flex items-center gap-2"> + <FolderInput className="h-4 w-4" /> + Other Users' Pending Items (Same Vendor) - {otherRows.length} files + </h3> + </div> + <ScrollArea className="flex-1"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">User</TableHead> + <TableHead className="w-[150px]">Drawing No</TableHead> + <TableHead className="min-w-[200px]">File Name</TableHead> + <TableHead className="w-[100px]">Size</TableHead> + <TableHead className="w-[140px]">Date</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {otherRows.map((row) => ( + <TableRow key={row.rowId}> + <TableCell className="text-xs font-medium">{row.userName}</TableCell> + <TableCell className="text-xs">{row.drawingNo}</TableCell> + <TableCell className="text-xs flex items-center gap-2"> + <FileText className="h-3 w-3 text-muted-foreground" /> + <span className="truncate max-w-[200px]" title={row.fileName}>{row.fileName}</span> + </TableCell> + <TableCell className="text-xs text-muted-foreground">{row.fileSize}</TableCell> + <TableCell className="text-xs text-muted-foreground"> + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + </div> + )} + </div> + + <DialogFooter className="p-6 border-t flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSyncing}> + Close + </Button> + <Button onClick={handleSync} disabled={isSyncing || selectedSyncIds.size === 0}> + {isSyncing ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Syncing... + </> + ) : ( + <> + <RefreshCw className="mr-2 h-4 w-4" /> + Sync Selected ({selectedSyncIds.size} items) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx b/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx new file mode 100644 index 00000000..c59f6d78 --- /dev/null +++ b/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx @@ -0,0 +1,247 @@ +"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 { useTranslation } from "@/i18n/client"; +import { useFileUploadWithProgress } from "@/lib/dolce/hooks/use-file-upload-with-progress"; +import { uploadFilesToDetailDrawingV2 } from "@/lib/dolce-v2/actions"; + +interface UploadFilesToDetailDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + uploadId: string; + drawingNo: string; + revNo: string; + // [추가] 메타데이터 저장을 위한 추가 정보 + drawingName?: string; + discipline?: string; + registerKind?: string; + + userId: string; + projectNo?: string; // V2에서는 projectNo 필요 (Sync List 조회 인덱스용) + vendorCode?: string; // V2: 동기화 필터링용 + onUploadComplete?: () => void; + lng: string; +} + +export function UploadFilesToDetailDialogV2({ + open, + onOpenChange, + uploadId, + drawingNo, + revNo, + drawingName, + discipline, + registerKind, + userId, + projectNo, + vendorCode, + onUploadComplete, + lng, +}: UploadFilesToDetailDialogV2Props) { + const { t } = useTranslation(lng, "dolce"); + const [isUploading, setIsUploading] = useState(false); + + // 파일 업로드 훅 사용 (UI용) + const { + files: selectedFiles, + removeFile, + clearFiles, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + clearFiles(); + } + }, [open, clearFiles]); + + // 업로드 처리 + const handleUpload = async () => { + if (selectedFiles.length === 0) { + toast.error(t("uploadFilesDialog.selectFilesError")); + return; + } + + setIsUploading(true); + + try { + const formData = new FormData(); + formData.append("uploadId", uploadId); + formData.append("userId", userId); + formData.append("fileCount", String(selectedFiles.length)); + if (projectNo) formData.append("projectNo", projectNo); + if (vendorCode) formData.append("vendorCode", vendorCode); + + // 메타데이터 추가 + formData.append("drawingNo", drawingNo); + formData.append("revNo", revNo); + if (drawingName) formData.append("drawingName", drawingName); + if (discipline) formData.append("discipline", discipline); + if (registerKind) formData.append("registerKind", registerKind); + + selectedFiles.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + const result = await uploadFilesToDetailDrawingV2(formData); + + if (result.success) { + toast.success(t("uploadFilesDialog.uploadSuccess", { count: selectedFiles.length })); + onOpenChange(false); + onUploadComplete?.(); + } else { + toast.error(result.error || t("uploadFilesDialog.uploadError")); + } + } catch (error) { + console.error("업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : t("uploadFilesDialog.uploadErrorMessage") + ); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{t("uploadFilesDialog.title")}</DialogTitle> + <DialogDescription> + {t("uploadFilesDialog.description", { drawingNo, revNo })} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {t("uploadFilesDialog.alertMessage")} + </AlertDescription> + </Alert> + + {/* 파일 선택 영역 */} + <div + {...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" + }`} + > + <input {...getInputProps()} /> + <div className="flex flex-col items-center justify-center"> + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragActive ? "text-primary" : "text-muted-foreground" + }`} + /> + <p + className={`text-sm transition-colors ${ + isDragActive + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragActive + ? t("uploadFilesDialog.dropHereText") + : t("uploadFilesDialog.dragDropText")} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {t("uploadFilesDialog.fileInfo")} + </p> + </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"> + {t("uploadFilesDialog.selectedFiles", { count: selectedFiles.length })} + </h4> + <Button + variant="ghost" + size="sm" + onClick={clearFiles} + > + {t("uploadFilesDialog.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-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> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + {t("uploadFilesDialog.cancelButton")} + </Button> + <Button + onClick={handleUpload} + disabled={selectedFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {t("uploadFilesDialog.uploadingButton")} + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + {t("uploadFilesDialog.uploadButton", { count: selectedFiles.length })} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} |
