summaryrefslogtreecommitdiff
path: root/lib/dolce-v2
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
parent3131dce1f0c90d960f53bd384045b41023064bc4 (diff)
(김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등
Diffstat (limited to 'lib/dolce-v2')
-rw-r--r--lib/dolce-v2/actions.ts605
-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
-rw-r--r--lib/dolce-v2/sync-service.ts414
6 files changed, 2709 insertions, 0 deletions
diff --git a/lib/dolce-v2/actions.ts b/lib/dolce-v2/actions.ts
new file mode 100644
index 00000000..020da94e
--- /dev/null
+++ b/lib/dolce-v2/actions.ts
@@ -0,0 +1,605 @@
+"use server";
+
+import {
+ fetchDwgReceiptList as fetchDwgOriginal,
+ fetchDetailDwgReceiptList as fetchDetailOriginal,
+ fetchFileInfoList as fetchFileOriginal,
+ getVendorSessionInfo as getVendorSessionInfoOriginal,
+ fetchVendorProjects as fetchVendorProjectsOriginal,
+ downloadDolceFile as downloadDolceFileOriginal,
+ // 타입들 재사용
+ DwgReceiptItem, GttDwgReceiptItem, UnifiedDwgReceiptItem, DetailDwgReceiptItem, FileInfoItem,
+ DetailDwgEditRequest, B4MappingSaveItem
+} from "@/lib/dolce/actions";
+import db from "@/db/db";
+import { dolceSyncList } from "@/db/schema/dolce/dolce";
+import { eq, and, desc } from "drizzle-orm";
+import { saveToLocalBuffer, syncItem, getLocalFile, deleteLocalItem, deleteLocalFileFromItem } from "./sync-service";
+
+// 타입 재-export
+export type {
+ DwgReceiptItem, GttDwgReceiptItem, UnifiedDwgReceiptItem,
+ DetailDwgReceiptItem, FileInfoItem, DetailDwgEditRequest
+};
+
+// Re-export 함수들을 명시적인 async 함수로 래핑
+export async function getVendorSessionInfo() {
+ return getVendorSessionInfoOriginal();
+}
+
+export async function fetchVendorProjects() {
+ return fetchVendorProjectsOriginal();
+}
+
+export async function downloadDolceFile(params: {
+ fileId: string;
+ userId: string;
+ fileName: string;
+}) {
+ return downloadDolceFileOriginal(params);
+}
+
+// ============================================================================
+// 조회 액션 (로컬 데이터 병합)
+// ============================================================================
+
+/**
+ * 1. 도면 리스트 조회 (변경 없음 - 도면 리스트 자체는 외부 시스템 기준)
+ */
+export async function fetchDwgReceiptList(params: {
+ project: string;
+ drawingKind: string;
+ drawingMoveGbn?: string;
+ drawingNo?: string;
+ drawingName?: string;
+ drawingVendor?: string;
+ discipline?: string;
+}) {
+ return fetchDwgOriginal(params);
+}
+
+/**
+ * 2. 상세도면 리스트 조회 (로컬 임시 저장 건 병합)
+ */
+export async function fetchDetailDwgReceiptListV2(params: {
+ project: string;
+ drawingNo: string;
+ discipline: string;
+ drawingKind: string;
+ userId?: string;
+}): Promise<DetailDwgReceiptItem[]> {
+ // 1. 외부 API 조회
+ const originalList = await fetchDetailOriginal(params);
+
+ // 2. 로컬 DB 조회 (미동기화된 ADD_DETAIL 건)
+ // projectNo와 drawingNo로 필터링
+ const localItems = await db.query.dolceSyncList.findMany({
+ where: and(
+ eq(dolceSyncList.projectNo, params.project),
+ eq(dolceSyncList.drawingNo, params.drawingNo),
+ eq(dolceSyncList.type, "ADD_DETAIL"),
+ eq(dolceSyncList.isSynced, false)
+ ),
+ orderBy: [desc(dolceSyncList.createdAt)],
+ });
+
+ // 3. 로컬 데이터를 DetailDwgReceiptItem 형식으로 변환하여 추가
+ const mergedList = [...originalList];
+
+ for (const item of localItems) {
+ const payload = item.payload as { meta: { dwgList: DetailDwgEditRequest[] } };
+ const dwgRequest = payload.meta.dwgList[0]; // 보통 1개씩 요청함
+
+ if (dwgRequest) {
+ // 임시 객체 생성
+ const tempItem: DetailDwgReceiptItem = {
+ Category: dwgRequest.Category,
+ CategoryENM: dwgRequest.Category, // 임시: 코드값 사용
+ CategoryNM: dwgRequest.Category, // 임시
+ CreateDt: item.createdAt.toISOString(),
+ CreateUserENM: "",
+ CreateUserId: item.userId,
+ CreateUserNM: "", // 이름은 별도 조회 필요하나 생략
+ Discipline: dwgRequest.Discipline,
+ DrawingKind: dwgRequest.DrawingKind,
+ DrawingName: dwgRequest.DrawingName,
+ DrawingNo: dwgRequest.DrawingNo,
+ DrawingRevNo: dwgRequest.DrawingRevNo || "",
+ DrawingUsage: "TEMP", // 임시 표시
+ DrawingUsageENM: "Temporary Saved",
+ DrawingUsageNM: "임시저장",
+ Manager: dwgRequest.Manager,
+ Mode: "ADD",
+ OFDC_NO: "",
+ ProjectNo: dwgRequest.ProjectNo,
+ Receiver: dwgRequest.Receiver,
+ RegCompanyCode: dwgRequest.RegCompanyCode,
+ RegCompanyENM: "",
+ RegCompanyNM: "",
+ RegisterDesc: dwgRequest.RegisterDesc,
+ RegisterGroup: dwgRequest.RegisterGroupId,
+ RegisterGroupId: dwgRequest.RegisterGroupId,
+ RegisterId: 0, // 임시 ID
+ RegisterKind: dwgRequest.RegisterKind,
+ RegisterKindENM: dwgRequest.RegisterKind, // 임시: 코드값 사용
+ RegisterKindNM: dwgRequest.RegisterKind,
+ RegisterSerialNo: dwgRequest.RegisterSerialNo,
+ SHINote: null,
+ Status: "EVCP Saved", // 작성중
+ UploadId: dwgRequest.UploadId,
+ };
+
+ // 리스트 상단에 추가 (혹은 날짜순 정렬)
+ mergedList.unshift(tempItem);
+ }
+ }
+
+ return mergedList;
+}
+
+/**
+ * 3. 파일 리스트 조회 (로컬 임시 저장 건 병합)
+ */
+export async function fetchFileInfoListV2(uploadId: string): Promise<FileInfoItem[]> {
+ // 1. 외부 API 조회
+ const originalList = await fetchFileOriginal(uploadId);
+
+ // 2. 로컬 DB 조회 (이 uploadId에 대해 추가된 파일들)
+ // ADD_DETAIL(새 도면 생성 시 파일) 또는 ADD_FILE(기존 도면에 파일 추가) 모두 해당될 수 있음.
+ // upload_id 컬럼으로 조회
+ const localItems = await db.query.dolceSyncList.findMany({
+ where: and(
+ eq(dolceSyncList.uploadId, uploadId),
+ eq(dolceSyncList.isSynced, false)
+ ),
+ });
+
+ const mergedList = [...originalList];
+
+ for (const item of localItems) {
+ const payload = item.payload as { files: Array<{ originalName: string, size: number, localPath: string }> };
+
+ if (payload.files && payload.files.length > 0) {
+ payload.files.forEach((file, index) => {
+ const tempFile: FileInfoItem = {
+ CreateDt: item.createdAt.toISOString(),
+ CreateUserId: item.userId,
+ Deleted: "False",
+ FileDescription: "Local Temp File",
+ FileId: `LOCAL_${item.id}_${index}`, // 로컬 파일 식별자
+ FileName: file.originalName,
+ FileRelativePath: file.localPath, // 로컬 경로 (다운로드 시 구분 필요)
+ FileSeq: String(9999 + index), // 임시 시퀀스
+ FileServerId: "LOCAL",
+ FileSize: String(file.size),
+ FileTitle: null,
+ FileWriteDT: item.createdAt.toISOString(),
+ OwnerUserId: item.userId,
+ SourceDrmYn: "N",
+ SystemId: "EVCP",
+ TableName: "Temp",
+ UploadId: uploadId,
+ UseYn: "True"
+ };
+ mergedList.push(tempFile);
+ });
+ }
+ }
+
+ return mergedList;
+}
+
+// ============================================================================
+// 저장 액션 (로컬 버퍼링)
+// ============================================================================
+
+/**
+ * 4. 상세도면 추가/수정 (로컬 저장)
+ */
+export async function editDetailDwgReceiptV2(
+ formData: FormData
+): Promise<{ success: boolean, syncId: string }> {
+ try {
+ const dwgListJson = formData.get("dwgList") as string;
+ const userId = formData.get("userId") as string;
+ const userNm = formData.get("userNm") as string;
+ const vendorCode = formData.get("vendorCode") as string;
+ const email = formData.get("email") as string;
+
+ if (!dwgListJson || !userId) {
+ throw new Error("Required parameters are missing");
+ }
+
+ const dwgList = JSON.parse(dwgListJson) as DetailDwgEditRequest[];
+ const request = dwgList[0]; // 보통 1건 처리
+ const type = request.Mode === "ADD" ? "ADD_DETAIL" : "MOD_DETAIL";
+
+ // FormData에서 파일 추출
+ const files: File[] = [];
+ // file_0, file_1 ... 형식으로 전송된다고 가정
+ const fileCount = parseInt((formData.get("fileCount") as string) || "0");
+
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`);
+ if (file instanceof File) {
+ files.push(file);
+ }
+ }
+
+ const savedItem = await saveToLocalBuffer({
+ type,
+ projectNo: request.ProjectNo,
+ userId,
+ userName: userNm, // [추가]
+ vendorCode: vendorCode, // [추가]
+ drawingNo: request.DrawingNo,
+ uploadId: request.UploadId,
+ metaData: {
+ dwgList,
+ userId,
+ userNm,
+ vendorCode,
+ email
+ },
+ files
+ });
+
+ return { success: true, syncId: savedItem.id };
+
+ } catch (error) {
+ console.error("상세도면 로컬 저장 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 5. 파일 추가 (로컬 저장)
+ */
+export async function uploadFilesToDetailDrawingV2(
+ formData: FormData
+): Promise<{ success: boolean, syncId?: string, error?: string }> {
+ try {
+ const uploadId = formData.get("uploadId") as string;
+ const userId = formData.get("userId") as string;
+ const fileCount = parseInt(formData.get("fileCount") as string);
+ const projectNo = formData.get("projectNo") as string;
+ const vendorCode = formData.get("vendorCode") as string;
+
+ // [추가] 메타데이터 추출
+ const drawingNo = formData.get("drawingNo") as string;
+ const revNo = formData.get("revNo") as string;
+ const drawingName = formData.get("drawingName") as string;
+ const discipline = formData.get("discipline") as string;
+ const registerKind = formData.get("registerKind") as string;
+
+ if (!uploadId || !userId) {
+ throw new Error("Required parameters are missing");
+ }
+
+ const files: File[] = [];
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`) as File;
+ if (file) files.push(file);
+ }
+
+ const savedItem = await saveToLocalBuffer({
+ type: "ADD_FILE",
+ projectNo: projectNo || "UNKNOWN",
+ userId,
+ vendorCode,
+ drawingNo: drawingNo || undefined, // [추가] DB drawingNo 컬럼 저장
+ uploadId,
+ metaData: {
+ uploadId,
+ userId,
+ vendorCode,
+ // [추가]
+ drawingNo,
+ revNo,
+ drawingName,
+ discipline,
+ registerKind
+ },
+ files
+ });
+
+ return { success: true, syncId: savedItem.id };
+ } catch (error) {
+ console.error("파일 로컬 저장 실패:", error);
+ return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
+ }
+}
+
+/**
+ * 6. 동기화 실행 액션
+ */
+export async function syncDolceItem(id: string) {
+ return syncItem(id);
+}
+
+/**
+ * 7. 미동기화 아이템 목록 조회 (내것만 - Sync 버튼 카운트용)
+ */
+export async function fetchPendingSyncItems(params: {
+ projectNo: string;
+ userId: string;
+}): Promise<Array<{ id: string; type: string; desc: string }>> {
+ try {
+ const items = await db.query.dolceSyncList.findMany({
+ where: and(
+ eq(dolceSyncList.projectNo, params.projectNo),
+ eq(dolceSyncList.userId, params.userId),
+ eq(dolceSyncList.isSynced, false)
+ ),
+ orderBy: [desc(dolceSyncList.createdAt)],
+ });
+
+ return items.map((item) => {
+ let desc = "";
+
+ if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") {
+ desc = `${item.type === "ADD_DETAIL" ? "Add" : "Mod"} Drawing ${item.drawingNo}`;
+ } else if (item.type === "ADD_FILE") {
+ desc = `Add Files to ${item.uploadId}`;
+ } else if (item.type === "B4_BULK") {
+ desc = `Bulk Upload ${item.drawingNo}`;
+ }
+
+ return {
+ id: item.id,
+ type: item.type,
+ desc,
+ };
+ });
+ } catch (error) {
+ console.error("미동기화 목록 조회 실패:", error);
+ return [];
+ }
+}
+
+// 상세 동기화 정보 인터페이스
+export interface PendingSyncItemDetail {
+ id: string; // syncId
+ type: string;
+ createdAt: Date;
+ userId: string;
+ userName: string;
+
+ // 도면 정보
+ drawingNo: string;
+ drawingName: string;
+ discipline: string;
+
+ // 상세도면 정보
+ revision: string;
+ registerKind: string; // 접수종류
+
+ // 파일 정보
+ files: Array<{
+ name: string;
+ size: number;
+ }>;
+}
+
+/**
+ * 8. 프로젝트 전체 미동기화 아이템 목록 조회 (동기화 다이얼로그용 - 상세 정보 포함)
+ */
+export async function fetchProjectPendingSyncItems(params: {
+ projectNo: string;
+ currentUserId: string;
+ currentVendorCode: string;
+}): Promise<{
+ myItems: PendingSyncItemDetail[];
+ otherItems: PendingSyncItemDetail[];
+}> {
+ try {
+ // 1. 내 아이템 조회 (userId 이용)
+ const myItemsPromise = db.query.dolceSyncList.findMany({
+ where: and(
+ eq(dolceSyncList.projectNo, params.projectNo),
+ eq(dolceSyncList.userId, params.currentUserId),
+ eq(dolceSyncList.isSynced, false)
+ ),
+ orderBy: [desc(dolceSyncList.createdAt)],
+ });
+
+ // 2. 같은 벤더의 다른 사용자 아이템 조회 (vendorCode 이용, userId 제외)
+ const otherItemsPromise = db.query.dolceSyncList.findMany({
+ where: and(
+ eq(dolceSyncList.projectNo, params.projectNo),
+ eq(dolceSyncList.vendorCode, params.currentVendorCode),
+ eq(dolceSyncList.isSynced, false)
+ ),
+ orderBy: [desc(dolceSyncList.createdAt)],
+ });
+
+ const [myDbItems, otherDbItems] = await Promise.all([myItemsPromise, otherItemsPromise]);
+
+ // 아이템 상세 정보 파싱 헬퍼
+ const parseItem = (item: typeof dolceSyncList.$inferSelect): PendingSyncItemDetail => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payload = item.payload as any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const files = payload.files?.map((f: any) => ({ name: f.originalName, size: f.size })) || [];
+ const meta = payload.meta || {};
+
+ let drawingNo = item.drawingNo || "";
+ let drawingName = "";
+ let discipline = "";
+ let revision = "";
+ let registerKind = "";
+
+ if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") {
+ const dwgRequest = meta.dwgList?.[0];
+ if (dwgRequest) {
+ drawingNo = dwgRequest.DrawingNo;
+ drawingName = dwgRequest.DrawingName;
+ discipline = dwgRequest.Discipline;
+ revision = dwgRequest.DrawingRevNo || "";
+ registerKind = dwgRequest.RegisterKind || "";
+ }
+ } else if (item.type === "ADD_FILE") {
+ // ADD_FILE의 경우 meta에 저장된 정보 사용
+ drawingNo = meta.drawingNo || item.drawingNo || "";
+ drawingName = meta.drawingName || "";
+ discipline = meta.discipline || "";
+ revision = meta.revNo || "";
+ registerKind = meta.registerKind || "";
+ } else if (item.type === "B4_BULK") {
+ // B4_BULK의 경우 첫 번째 매핑 정보 사용 (보통 같은 도면)
+ const firstMapping = meta.mappingSaveLists?.[0];
+ if (firstMapping) {
+ drawingNo = firstMapping.DrawingNo;
+ drawingName = firstMapping.DrawingName;
+ discipline = firstMapping.Discipline;
+ revision = firstMapping.RevNo;
+ registerKind = firstMapping.RegisterKindCode || "";
+ }
+ }
+
+ return {
+ id: item.id,
+ type: item.type,
+ createdAt: item.createdAt,
+ userId: item.userId,
+ userName: item.userName || item.userId,
+ drawingNo,
+ drawingName,
+ discipline,
+ revision,
+ registerKind,
+ files,
+ };
+ };
+
+ // 내 아이템 매핑
+ const myItems = myDbItems.map(parseItem);
+
+ // 다른 사용자 아이템 매핑
+ const otherItems = otherDbItems
+ .filter(item => item.userId !== params.currentUserId)
+ .map(parseItem);
+
+ return { myItems, otherItems };
+ } catch (error) {
+ console.error("프로젝트 미동기화 목록 조회 실패:", error);
+ return { myItems: [], otherItems: [] };
+ }
+}
+
+// B4 일괄 업로드 (로컬 저장 버전)
+// ============================================================================
+
+/**
+ * B4 일괄 업로드 V3 (로컬 저장)
+ */
+export async function bulkUploadB4FilesV3(
+ formData: FormData
+): Promise<{ success: boolean, syncIds: string[], error?: string }> {
+ try {
+ // FormData에서 메타데이터 추출
+ const projectNo = formData.get("projectNo") as string;
+ const userId = formData.get("userId") as string;
+ const userNm = formData.get("userNm") as string;
+ const email = formData.get("email") as string;
+ const vendorCode = formData.get("vendorCode") as string;
+ const fileCount = parseInt(formData.get("fileCount") as string);
+
+ const syncIds: string[] = [];
+
+ // 그룹핑: UploadId 기준 (하나의 상세도면에 여러 파일이 들어갈 수 있음)
+ const groups = new Map<string, {
+ files: File[],
+ mappings: B4MappingSaveItem[], // 타입을 명시적으로 지정
+ drawingNo: string
+ }>();
+
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`) as File;
+ const mappingJson = formData.get(`mappingData_${i}`) as string;
+
+ if (!file || !mappingJson) continue;
+
+ const mapping = JSON.parse(mappingJson);
+ const uploadId = mapping.UploadId;
+
+ if (!groups.has(uploadId)) {
+ groups.set(uploadId, { files: [], mappings: [], drawingNo: mapping.DrawingNo });
+ }
+
+ groups.get(uploadId)!.files.push(file);
+ groups.get(uploadId)!.mappings.push(mapping);
+ }
+
+ // 각 그룹(상세도면 단위)별로 로컬 저장
+ for (const [uploadId, group] of groups.entries()) {
+ const savedItem = await saveToLocalBuffer({
+ type: "B4_BULK",
+ projectNo,
+ userId,
+ userName: userNm, // [추가]
+ vendorCode: vendorCode, // [추가]
+ drawingNo: group.drawingNo,
+ uploadId,
+ metaData: {
+ mappingSaveLists: group.mappings,
+ userInfo: { userId, userName: userNm, vendorCode, email }
+ },
+ files: group.files
+ });
+ syncIds.push(savedItem.id);
+ }
+
+ return { success: true, syncIds };
+
+ } catch (error) {
+ console.error("B4 일괄 업로드 로컬 저장 실패:", error);
+ return { success: false, syncIds: [], error: error instanceof Error ? error.message : "Unknown error" };
+ }
+}
+
+/**
+ * 9. 로컬 파일 다운로드 (Buffer 반환)
+ */
+export async function downloadLocalFile(fileId: string) {
+ return getLocalFile(fileId);
+}
+
+/**
+ * 10. 로컬 상세도면 삭제
+ */
+export async function deleteLocalDetailDrawing(uploadId: string) {
+ try {
+ // Find the item by uploadId and type
+ // We assume one ADD_DETAIL per uploadId for now (as per prepareB4DetailDrawingsV2 logic)
+ const item = await db.query.dolceSyncList.findFirst({
+ where: and(
+ eq(dolceSyncList.uploadId, uploadId),
+ eq(dolceSyncList.type, "ADD_DETAIL"),
+ eq(dolceSyncList.isSynced, false)
+ )
+ });
+
+ if (item) {
+ await deleteLocalItem(item.id);
+ return { success: true };
+ }
+ return { success: false, error: "Item not found" };
+ } catch (e) {
+ console.error("Failed to delete local drawing", e);
+ return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
+ }
+}
+
+/**
+ * 11. 로컬 파일 삭제
+ */
+export async function deleteLocalFile(fileId: string) {
+ try {
+ await deleteLocalFileFromItem(fileId);
+ return { success: true };
+ } catch (e) {
+ console.error("Failed to delete local file", e);
+ return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
+ }
+}
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>
+ );
+}
diff --git a/lib/dolce-v2/sync-service.ts b/lib/dolce-v2/sync-service.ts
new file mode 100644
index 00000000..ea56b239
--- /dev/null
+++ b/lib/dolce-v2/sync-service.ts
@@ -0,0 +1,414 @@
+"use server";
+
+import fs from "fs/promises";
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
+import db from "@/db/db";
+import { dolceSyncList } from "@/db/schema/dolce/dolce";
+import { eq, and } from "drizzle-orm";
+import {
+ dolceApiCall,
+ uploadFilesToDetailDrawing as apiUploadFiles,
+ saveB4MappingBatch as apiSaveB4Mapping,
+ DetailDwgEditRequest,
+ B4MappingSaveItem
+} from "@/lib/dolce/actions"; // 기존 API 호출 로직 재사용 (타입 등)
+
+const LOCAL_UPLOAD_DIR = process.env.DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY || "/evcp/data/dolce";
+
+// 파일 저장 결과 인터페이스
+interface SavedFile {
+ originalName: string;
+ localPath: string;
+ size: number;
+ mimeType?: string;
+}
+
+/**
+ * 로컬 디렉토리 준비
+ */
+async function ensureUploadDir() {
+ try {
+ await fs.access(LOCAL_UPLOAD_DIR);
+ } catch {
+ await fs.mkdir(LOCAL_UPLOAD_DIR, { recursive: true });
+ }
+}
+
+/**
+ * 로컬에 파일 저장
+ */
+async function saveFileToLocal(file: File): Promise<SavedFile> {
+ await ensureUploadDir();
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const uniqueName = `${uuidv4()}_${file.name}`;
+ const localPath = path.join(LOCAL_UPLOAD_DIR, uniqueName);
+
+ await fs.writeFile(localPath, buffer);
+
+ return {
+ originalName: file.name,
+ localPath,
+ size: file.size,
+ mimeType: file.type,
+ };
+}
+
+/**
+ * 동기화 아이템 DB 저장 (버퍼링)
+ */
+export async function saveToLocalBuffer(params: {
+ type: "ADD_DETAIL" | "MOD_DETAIL" | "ADD_FILE" | "B4_BULK";
+ projectNo: string;
+ userId: string;
+ userName?: string; // [추가]
+ vendorCode?: string; // [추가]
+ drawingNo?: string;
+ uploadId?: string; // 상세도면 추가/수정/파일추가 시 필수
+ metaData: any; // API 호출에 필요한 데이터
+ files?: File[]; // 업로드할 파일들 (있으면 로컬 저장)
+}) {
+ const { type, projectNo, userId, userName, vendorCode, drawingNo, uploadId, metaData, files } = params;
+
+ // 1. 파일 로컬 저장 처리
+ const savedFiles: SavedFile[] = [];
+ if (files && files.length > 0) {
+ for (const file of files) {
+ const saved = await saveFileToLocal(file);
+ savedFiles.push(saved);
+ }
+ }
+
+ // 2. Payload 구성
+ const payload = {
+ meta: metaData,
+ files: savedFiles,
+ };
+
+ // 3. DB 저장
+ const [inserted] = await db.insert(dolceSyncList).values({
+ type,
+ projectNo,
+ drawingNo,
+ uploadId,
+ userId,
+ userName, // [추가]
+ vendorCode, // [추가]
+ payload,
+ isSynced: false,
+ }).returning();
+
+ return inserted;
+}
+
+/**
+ * 개별 아이템 동기화 실행
+ */
+export async function syncItem(id: string) {
+ // 1. 아이템 조회
+ const item = await db.query.dolceSyncList.findFirst({
+ where: eq(dolceSyncList.id, id),
+ });
+
+ if (!item) throw new Error("Item not found");
+ if (item.isSynced) return { success: true, message: "Already synced" };
+
+ const payload = item.payload as { meta: any; files: SavedFile[] };
+ const { meta, files } = payload;
+
+ try {
+ // 2. 타입별 API 호출 수행
+ if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") {
+ // 상세도면 추가/수정
+ // meta: { dwgList: DetailDwgEditRequest[], userId, userNm, vendorCode, email }
+
+ // 상세도면 메타데이터 전송
+ await dolceApiCall("DetailDwgReceiptMgmtEdit", {
+ DwgList: meta.dwgList,
+ UserID: meta.userId,
+ UserNM: meta.userNm,
+ VENDORCODE: meta.vendorCode,
+ EMAIL: meta.email,
+ });
+
+ // 파일이 있다면 전송 (ADD_DETAIL의 경우)
+ if (files && files.length > 0) {
+ // uploadId는 meta.dwgList[0].UploadId 에 있다고 가정
+ const uploadId = meta.dwgList[0]?.UploadId;
+ if (uploadId) {
+ await uploadLocalFiles(uploadId, meta.userId, files);
+ }
+ }
+
+ } else if (item.type === "ADD_FILE") {
+ // 파일 추가
+ // meta: { uploadId, userId }
+ await uploadLocalFiles(meta.uploadId, meta.userId, files);
+
+ } else if (item.type === "B4_BULK") {
+ // B4 일괄 업로드 (메타데이터 + 파일)
+ // meta: { mappingSaveLists: B4MappingSaveItem[], userInfo: {...} }
+
+ // 파일 먼저 업로드 (각 파일별로 uploadId가 다를 수 있음 - payload 구조에 따라 다름)
+ // B4 Bulk의 경우, meta.mappingSaveLists에 UploadId가 있고, files와 1:1 매칭되거나 그룹핑되어야 함.
+ // 여기서는 복잡도를 줄이기 위해, payload.files 순서와 mappingSaveLists 순서가 같거나
+ // meta 정보 안에 파일 매핑 정보가 있다고 가정해야 함.
+
+ // *설계 단순화*: B4 Bulk의 경우 파일별로 saveToLocalBuffer를 따로 부르지 않고 한방에 불렀다면,
+ // 여기서 순회하며 처리.
+
+ // 1. 파일 업로드
+ // B4 일괄 업로드 로직은 파일 업로드 -> 결과 수신 -> 매핑 저장 순서임.
+ // 하지만 여기서는 이미 메타데이터가 만들어져 있으므로,
+ // 파일 업로드(UploadId 기준) -> 매핑 저장 순으로 진행.
+
+ // 파일마다 UploadId가 다를 수 있으므로 Grouping 필요
+ const fileMap = new Map<string, SavedFile>();
+ files.forEach(f => fileMap.set(f.originalName, f));
+
+ // UploadId별 파일 그룹핑
+ const uploadGroups = new Map<string, { userId: string; files: SavedFile[] }>();
+
+ for (const mapping of meta.mappingSaveLists as B4MappingSaveItem[]) {
+ if (!uploadGroups.has(mapping.UploadId)) {
+ uploadGroups.set(mapping.UploadId, { userId: meta.userInfo.userId, files: [] });
+ }
+ const savedFile = fileMap.get(mapping.FileNm);
+ if (savedFile) {
+ uploadGroups.get(mapping.UploadId)!.files.push(savedFile);
+ }
+ }
+
+ // 그룹별 파일 업로드 수행
+ for (const [uploadId, group] of uploadGroups.entries()) {
+ if (group.files.length > 0) {
+ await uploadLocalFiles(uploadId, group.userId, group.files);
+ }
+ }
+
+ // 2. 매핑 정보 저장
+ await apiSaveB4Mapping(meta.mappingSaveLists, meta.userInfo);
+ }
+
+ // 3. 성공 처리 (DB 업데이트 + 로컬 파일 삭제)
+ await db.update(dolceSyncList)
+ .set({
+ isSynced: true,
+ syncAttempts: (item.syncAttempts || 0) + 1,
+ responseCode: "200",
+ response: "Success",
+ updatedAt: new Date()
+ })
+ .where(eq(dolceSyncList.id, id));
+
+ // 로컬 파일 삭제
+ if (files && files.length > 0) {
+ for (const file of files) {
+ try {
+ await fs.unlink(file.localPath);
+ } catch (e) {
+ console.error(`Failed to delete local file: ${file.localPath}`, e);
+ }
+ }
+ }
+
+ return { success: true };
+
+ } catch (error) {
+ console.error(`Sync failed for item ${id}:`, error);
+
+ // 실패 처리
+ await db.update(dolceSyncList)
+ .set({
+ syncAttempts: (item.syncAttempts || 0) + 1,
+ lastError: error instanceof Error ? error.message : "Unknown error",
+ updatedAt: new Date()
+ })
+ .where(eq(dolceSyncList.id, id));
+
+ throw error;
+ }
+}
+
+/**
+ * 로컬 파일들을 실제 서버로 업로드하는 헬퍼 함수
+ * (기존 uploadFilesToDetailDrawing 로직을 로컬 파일용으로 변형)
+ */
+async function uploadLocalFiles(uploadId: string, userId: string, files: SavedFile[]) {
+ // 1. 기존 파일 시퀀스 확인 등은 생략하고 바로 PWPUploadService 호출
+ // (기존 API 액션 재사용이 어려우므로 여기서 fetch로 직접 구현)
+
+ // 기존 파일 개수 조회 (Seq 생성을 위해)
+ const existingFiles = await dolceApiCall<{
+ FileInfoListResult: Array<{ FileSeq: string }>;
+ }>("FileInfoList", {
+ uploadId: uploadId,
+ });
+ const startSeq = existingFiles.FileInfoListResult.length + 1;
+
+ const uploadResults = [];
+ const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111";
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const fileId = uuidv4();
+
+ // 로컬 파일 읽기
+ const fileBuffer = await fs.readFile(file.localPath);
+
+ // 업로드 API 호출
+ const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`;
+ const uploadResponse = await fetch(uploadUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream" },
+ body: fileBuffer,
+ });
+
+ if (!uploadResponse.ok) throw new Error(`File upload failed: ${uploadResponse.status}`);
+
+ const fileRelativePath = await uploadResponse.text();
+
+ uploadResults.push({
+ FileId: fileId,
+ UploadId: uploadId,
+ FileSeq: startSeq + i,
+ FileName: file.originalName,
+ FileRelativePath: fileRelativePath,
+ FileSize: file.size,
+ FileCreateDT: new Date().toISOString(),
+ FileWriteDT: new Date().toISOString(),
+ OwnerUserId: userId,
+ });
+ }
+
+ // 결과 통보
+ const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`;
+ const resultResponse = await fetch(resultServiceUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(uploadResults),
+ });
+
+ if (!resultResponse.ok) throw new Error("Upload notification failed");
+
+ const resultText = await resultResponse.text();
+ if (resultText !== "Success") throw new Error(`Upload notification failed: ${resultText}`);
+}
+
+/**
+ * 로컬 파일 다운로드 (View용)
+ */
+export async function getLocalFile(fileId: string): Promise<{ buffer: Buffer; fileName: string }> {
+ // Format: LOCAL_{id}_{index}
+ const parts = fileId.replace("LOCAL_", "").split("_");
+ if (parts.length < 2) throw new Error("Invalid file ID format");
+
+ const id = parts[0];
+ const index = parseInt(parts[1]);
+
+ const item = await db.query.dolceSyncList.findFirst({
+ where: eq(dolceSyncList.id, id),
+ });
+
+ if (!item) throw new Error("Item not found");
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payload = item.payload as { files: SavedFile[]; meta: any };
+ if (!payload.files || !payload.files[index]) {
+ throw new Error("File not found in item");
+ }
+
+ const file = payload.files[index];
+
+ try {
+ const buffer = await fs.readFile(file.localPath);
+ return {
+ buffer,
+ fileName: file.originalName
+ };
+ } catch (e) {
+ console.error(`Failed to read local file: ${file.localPath}`, e);
+ throw new Error("Failed to read local file");
+ }
+}
+
+/**
+ * 로컬 아이템 삭제 (상세도면 삭제용)
+ * 관련 파일도 로컬 디스크에서 삭제
+ */
+export async function deleteLocalItem(id: string) {
+ const item = await db.query.dolceSyncList.findFirst({
+ where: eq(dolceSyncList.id, id),
+ });
+
+ if (!item) return;
+
+ // Delete files from disk
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payload = item.payload as { files?: SavedFile[] };
+ if (payload.files) {
+ for (const file of payload.files) {
+ try {
+ await fs.unlink(file.localPath);
+ } catch (e) {
+ console.warn(`Failed to delete local file: ${file.localPath}`, e);
+ }
+ }
+ }
+
+ // Delete DB entry
+ await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id));
+}
+
+/**
+ * 로컬 파일 삭제 (개별 파일 삭제용)
+ */
+export async function deleteLocalFileFromItem(fileId: string) {
+ // Format: LOCAL_{id}_{index}
+ const parts = fileId.replace("LOCAL_", "").split("_");
+ if (parts.length < 2) throw new Error("Invalid file ID format");
+
+ const id = parts[0];
+ const index = parseInt(parts[1]);
+
+ const item = await db.query.dolceSyncList.findFirst({
+ where: eq(dolceSyncList.id, id),
+ });
+
+ if (!item) throw new Error("Item not found");
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payload = item.payload as { files: SavedFile[]; meta: any };
+ if (!payload.files || !payload.files[index]) {
+ return;
+ }
+
+ // Delete file from disk
+ const fileToDelete = payload.files[index];
+ try {
+ await fs.unlink(fileToDelete.localPath);
+ } catch (e) {
+ console.warn(`Failed to delete local file: ${fileToDelete.localPath}`, e);
+ }
+
+ // Remove from payload
+ const newFiles = [...payload.files];
+ newFiles.splice(index, 1); // Remove at index
+
+ // Update DB
+ await db.update(dolceSyncList)
+ .set({
+ payload: {
+ ...payload,
+ files: newFiles
+ },
+ updatedAt: new Date()
+ })
+ .where(eq(dolceSyncList.id, id));
+
+ // If no files left and it was ADD_FILE type, delete the item
+ if (newFiles.length === 0 && item.type === "ADD_FILE") {
+ await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id));
+ }
+}