summaryrefslogtreecommitdiff
path: root/lib/dolce-v2/dialogs
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-26 18:09:18 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-26 18:09:18 +0900
commit8547034e6d82e4d1184f35af2dbff67180d89dc8 (patch)
tree2e1835040f39adc7d0c410a108ebb558f9971a8b /lib/dolce-v2/dialogs
parent3131dce1f0c90d960f53bd384045b41023064bc4 (diff)
(김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등
Diffstat (limited to 'lib/dolce-v2/dialogs')
-rw-r--r--lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx695
-rw-r--r--lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx372
-rw-r--r--lib/dolce-v2/dialogs/sync-items-dialog.tsx376
-rw-r--r--lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx247
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>
+ );
+}