From 8547034e6d82e4d1184f35af2dbff67180d89dc8 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 26 Nov 2025 18:09:18 +0900 Subject: (김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 5 +- .env.production | 5 +- .../dolce-upload-v3/dolce-upload-page-v3.tsx | 585 +++++++++++++++++ .../partners/(partners)/dolce-upload-v3/page.tsx | 49 ++ app/api/dolce/download/route.ts | 15 +- db/schema/dolce/dolce.ts | 89 +++ db/schema/index.ts | 2 + lib/bidding/detail/service.ts | 34 +- lib/dolce-v2/actions.ts | 605 ++++++++++++++++++ .../add-and-modify-detail-drawing-dialog-v2.tsx | 695 +++++++++++++++++++++ lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx | 372 +++++++++++ lib/dolce-v2/dialogs/sync-items-dialog.tsx | 376 +++++++++++ .../dialogs/upload-files-to-detail-dialog-v2.tsx | 247 ++++++++ lib/dolce-v2/sync-service.ts | 414 ++++++++++++ lib/dolce/actions.ts | 9 +- lib/dolce/table/detail-drawing-columns.tsx | 81 +-- lib/dolce/table/drawing-list-table-v2.tsx | 2 +- lib/dolce/table/file-list-columns.tsx | 45 +- lib/dolce/table/gtt-drawing-list-columns.tsx | 23 +- 19 files changed, 3562 insertions(+), 91 deletions(-) create mode 100644 app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx create mode 100644 app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx create mode 100644 db/schema/dolce/dolce.ts create mode 100644 lib/dolce-v2/actions.ts create mode 100644 lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx create mode 100644 lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx create mode 100644 lib/dolce-v2/dialogs/sync-items-dialog.tsx create mode 100644 lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx create mode 100644 lib/dolce-v2/sync-service.ts diff --git a/.env.development b/.env.development index 2dbf998a..29063c3c 100644 --- a/.env.development +++ b/.env.development @@ -192,4 +192,7 @@ EDP_MASTER_DATA_SYNC_CRON="0 23 * * *" REVALIDATION_SECRET="biwjeofijosdkfjoiwejfksdjf1" # 오픈 전, 벤더에게 특정 메뉴 보이지 않기, 운영 배포시 true로 설정할 것 (나준규프로 요청사항) -NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false \ No newline at end of file +NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false + +# DOLCE Local Uplaod Directory +DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce" \ No newline at end of file diff --git a/.env.production b/.env.production index eade8da1..6e29b4dc 100644 --- a/.env.production +++ b/.env.production @@ -194,4 +194,7 @@ EDP_MASTER_DATA_SYNC_CRON="0 23 * * *" REVALIDATION_SECRET="biwjeofijosdkfjoiwejfksdjf1" # 오픈 전, 벤더에게 특정 메뉴 보이지 않기, 운영 배포시 true로 설정할 것 (나준규프로 요청사항) -NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false \ No newline at end of file +NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false + +# DOLCE Local Uplaod Directory +DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce" \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx b/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx new file mode 100644 index 00000000..513cfe1e --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx @@ -0,0 +1,585 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { InfoIcon, RefreshCw, Search, Upload, Plus, Cloud } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + FileInfoItem, + fetchDwgReceiptList, + getVendorSessionInfo, + fetchVendorProjects, + fetchDetailDwgReceiptListV2, + fetchFileInfoListV2, + fetchPendingSyncItems, + deleteLocalFile, +} from "@/lib/dolce-v2/actions"; +import { DrawingListTableV2 } from "@/lib/dolce/table/drawing-list-table-v2"; +import { drawingListColumns } from "@/lib/dolce/table/drawing-list-columns"; +import { createGttDrawingListColumns, DocumentType } from "@/lib/dolce/table/gtt-drawing-list-columns"; +import { createDetailDrawingColumns } from "@/lib/dolce/table/detail-drawing-columns"; +import { createFileListColumns } from "@/lib/dolce/table/file-list-columns"; + +// 다이얼로그 (V2/V3) +import { B4BulkUploadDialogV3Sync } from "@/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3"; +import { AddAndModifyDetailDrawingDialogV2 } from "@/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2"; +import { UploadFilesToDetailDialogV2 } from "@/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2"; +import { SyncItemsDialog } from "@/lib/dolce-v2/dialogs/sync-items-dialog"; + +interface DolceUploadPageV3Props { + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default function DolceUploadPageV3({ searchParams }: DolceUploadPageV3Props) { + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng, "dolce"); + + // URL에서 초기 프로젝트 코드 + const initialProjNo = (searchParams.projNo as string) || ""; + + // 상태 관리 + const [drawings, setDrawings] = useState([]); + const [projects, setProjects] = useState>([]); + const [vendorInfo, setVendorInfo] = useState<{ + userId: string; + userName: string; + email: string; + vendorCode: string; + vendorName: string; + drawingKind: "B3" | "B4"; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + // 필터 상태 + const [projNo, setProjNo] = useState(initialProjNo); + const [drawingNo, setDrawingNo] = useState(""); + const [drawingName, setDrawingName] = useState(""); + const [discipline, setDiscipline] = useState(""); + const [manager, setManager] = useState(""); + const [documentType, setDocumentType] = useState("ALL"); // B4 전용 + + // 선택된 도면 및 상세도면 + const [selectedDrawing, setSelectedDrawing] = useState(null); + const [detailDrawings, setDetailDrawings] = useState([]); + const [selectedDetail, setSelectedDetail] = useState(null); + const [files, setFiles] = useState([]); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [, setIsLoadingFiles] = useState(false); + + // 다이얼로그 상태 + const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + + // 동기화 상태 + const [pendingSyncCount, setPendingSyncCount] = useState(0); + + // 미동기화 건수 확인 + const checkPendingSync = useCallback(async () => { + if (!projNo || !vendorInfo) return; + try { + const items = await fetchPendingSyncItems({ projectNo: projNo, userId: vendorInfo.userId }); + setPendingSyncCount(items.length); + } catch (e) { + console.error("Failed to check pending sync items", e); + } + }, [projNo, vendorInfo]); + + // 초기 데이터 로드 + const loadInitialData = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const [vendorInfoData, projectsData] = await Promise.all([ + getVendorSessionInfo(), + fetchVendorProjects(), + ]); + + setVendorInfo(vendorInfoData as typeof vendorInfo); + setProjects(projectsData); + + if (initialProjNo) { + const drawingsData = await fetchDwgReceiptList({ + project: initialProjNo, + drawingKind: vendorInfoData.drawingKind, + drawingVendor: vendorInfoData.drawingKind === "B3" ? vendorInfoData.vendorCode : "", + }); + setDrawings(drawingsData); + } + } catch (err) { + console.error("초기 데이터 로드 실패:", err); + setError(err instanceof Error ? err.message : t("page.initialLoadError")); + toast.error(t("page.initialLoadError")); + } finally { + setIsLoading(false); + } + }, [initialProjNo, t]); + + // 도면 목록 조회 + const loadDrawings = useCallback(async () => { + if (!projNo || !vendorInfo) return; + + try { + setIsRefreshing(true); + setError(null); + + const drawingsData = await fetchDwgReceiptList({ + project: projNo, + drawingKind: vendorInfo.drawingKind, + drawingVendor: vendorInfo.drawingKind === "B3" ? vendorInfo.vendorCode : "", + }); + + setDrawings(drawingsData); + toast.success(t("page.drawingLoadSuccess")); + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (err) { + console.error("도면 로드 실패:", err); + setError(err instanceof Error ? err.message : t("page.drawingLoadError")); + toast.error(t("page.drawingLoadError")); + } finally { + setIsRefreshing(false); + } + }, [projNo, vendorInfo, t, checkPendingSync]); + + // 상세도면 목록 로드 (V2 API 사용) + const loadDetailDrawings = useCallback(async () => { + if (!selectedDrawing) { + setDetailDrawings([]); + setSelectedDetail(null); + return; + } + + try { + setIsLoadingDetails(true); + // V2: 로컬 임시 저장 건 포함 조회 + const data = await fetchDetailDwgReceiptListV2({ + project: selectedDrawing.ProjectNo, + drawingNo: selectedDrawing.DrawingNo, + discipline: selectedDrawing.Discipline, + drawingKind: selectedDrawing.DrawingKind, + userId: vendorInfo?.userId || "", + }); + setDetailDrawings(data); + + if (data.length > 0) { + setSelectedDetail(data[0]); + } else { + setSelectedDetail(null); + } + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (error) { + console.error("상세도면 로드 실패:", error); + toast.error(t("detailDialog.detailLoadError")); + setDetailDrawings([]); + setSelectedDetail(null); + } finally { + setIsLoadingDetails(false); + } + }, [selectedDrawing, vendorInfo, t, checkPendingSync]); + + // 파일 목록 로드 (V2 API 사용) + const loadFiles = useCallback(async () => { + if (!selectedDetail) { + setFiles([]); + return; + } + + try { + setIsLoadingFiles(true); + // V2: 로컬 임시 파일 포함 조회 + const data = await fetchFileInfoListV2(selectedDetail.UploadId); + setFiles(data); + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (error) { + console.error("파일 목록 로드 실패:", error); + toast.error(t("detailDialog.fileLoadError")); + setFiles([]); + } finally { + setIsLoadingFiles(false); + } + }, [selectedDetail, t, checkPendingSync]); + + // 동기화 완료 핸들러 + const handleSyncComplete = useCallback(() => { + checkPendingSync(); + if (selectedDrawing) loadDetailDrawings(); + if (selectedDetail) loadFiles(); + }, [checkPendingSync, selectedDrawing, selectedDetail, loadDetailDrawings, loadFiles]); + + // 초기 데이터 로드 + useEffect(() => { + loadInitialData(); + }, [loadInitialData]); + + // 프로젝트 변경 시 자동 검색 + useEffect(() => { + if (projNo && vendorInfo) { + loadDrawings(); + } + }, [projNo, vendorInfo, loadDrawings]); + + // 선택된 도면 변경 시 상세도면 로드 + useEffect(() => { + loadDetailDrawings(); + }, [selectedDrawing, loadDetailDrawings]); + + // 선택된 상세도면 변경 시 파일 목록 로드 + useEffect(() => { + loadFiles(); + }, [selectedDetail, loadFiles]); + + const handleDrawingClick = (drawing: UnifiedDwgReceiptItem) => { + setSelectedDrawing(drawing); + }; + + const handleSearch = () => { + loadDrawings(); + }; + + const handleRefresh = () => { + loadDrawings(); + }; + + const handleRefreshDetails = () => { + loadDetailDrawings(); + }; + + // 완료 핸들러들 + const handleBulkUploadComplete = () => { + loadDrawings(); + checkPendingSync(); + }; + const handleAddComplete = () => { + setAddDialogOpen(false); + loadDetailDrawings(); + checkPendingSync(); + }; + const handleUploadComplete = () => { + setUploadFilesDialogOpen(false); + loadFiles(); + checkPendingSync(); + }; + + const handleDeleteFile = async (file: FileInfoItem) => { + if (!confirm(lng === "ko" ? "정말로 파일을 삭제하시겠습니까?" : "Are you sure you want to delete this file?")) return; + + try { + const result = await deleteLocalFile(file.FileId); + if (result.success) { + toast.success(lng === "ko" ? "파일이 삭제되었습니다." : "File deleted."); + loadFiles(); + checkPendingSync(); + } else { + throw new Error(result.error); + } + } catch (e) { + console.error("File delete failed", e); + toast.error(lng === "ko" ? "파일 삭제 실패" : "File delete failed"); + } + }; + + const handleDownload = async (file: FileInfoItem) => { + try { + toast.info(t("detailDialog.downloadPreparing")); + const response = await fetch("/api/dolce/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileId: file.FileId, + userId: file.CreateUserId, + fileName: file.FileName, + }), + }); + + if (!response.ok) throw new Error(t("detailDialog.downloadError")); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = file.FileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success(t("detailDialog.downloadSuccess")); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error(t("detailDialog.downloadError")); + } + }; + + // 필터 로직 + const filteredDrawings = useMemo(() => { + let result = drawings.filter((drawing) => { + if (drawingNo && !drawing.DrawingNo.toLowerCase().includes(drawingNo.toLowerCase())) return false; + if (drawingName && !drawing.DrawingName.toLowerCase().includes(drawingName.toLowerCase())) return false; + if (discipline && !drawing.Discipline?.toLowerCase().includes(discipline.toLowerCase())) return false; + if (manager && !drawing.Manager.toLowerCase().includes(manager.toLowerCase()) && + !drawing.ManagerENM?.toLowerCase().includes(manager.toLowerCase())) return false; + return true; + }); + + if (vendorInfo?.drawingKind === "B4" && documentType !== "ALL") { + result = result.filter((drawing) => { + if (drawing.DrawingKind !== "B4") return false; + const gttDrawing = drawing as { DrawingMoveGbn?: string }; + if (documentType === "SHI_INPUT") return gttDrawing.DrawingMoveGbn === "도면제출"; + else if (documentType === "GTT_DELIVERABLES") return gttDrawing.DrawingMoveGbn === "도면입수"; + return true; + }); + } + return result; + }, [drawings, drawingNo, drawingName, discipline, manager, vendorInfo?.drawingKind, documentType]); + + const getDetailDrawingId = (detail: DetailDwgReceiptItem) => `${detail.RegisterId}_${detail.UploadId}`; + const getDrawingId = (drawing: UnifiedDwgReceiptItem) => `${drawing.ProjectNo}_${drawing.DrawingNo}_${drawing.Discipline}`; + + const canAddDetailDrawing = vendorInfo && ( + vendorInfo.drawingKind === "B3" || + (vendorInfo.drawingKind === "B4" && selectedDrawing && 'DrawingMoveGbn' in selectedDrawing && selectedDrawing.DrawingMoveGbn === "도면입수") + ); + + // 파일 리스트 컬럼 정의 + const fileColumns = createFileListColumns({ + onDownload: handleDownload, + onDelete: handleDeleteFile, + lng + }); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {error && {error}} + + {/* 헤더 및 Sync 컨트롤 */} +
+
+

+ Dolce Upload V3 Sync Enabled +

+
+
+ +
+
+ + {!projNo && {t("page.selectProject")}} + + {/* 필터 카드 */} + + {t("filter.title")} + +
+
+ + +
+ {/* 기타 필터들 */} +
setDrawingNo(e.target.value)} />
+
setDrawingName(e.target.value)} />
+
setDiscipline(e.target.value)} />
+
setManager(e.target.value)} />
+ {vendorInfo?.drawingKind === "B4" && ( +
+ + +
+ )} +
+
+ + {vendorInfo?.drawingKind === "B4" && ( + + )} + +
+
+
+ + {/* 메인 컨텐츠 영역 */} + + {t("drawingList.title")} + + + + + +
+ + + {t("detailDialog.detailListTitle")} +
+ + {canAddDetailDrawing && } +
+
+ + + +
+ + + + {t("detailDialog.fileListTitle")} + {selectedDetail && canAddDetailDrawing && ( + + )} + + + + + +
+ + {/* 다이얼로그 영역 */} + {vendorInfo && vendorInfo.drawingKind === "B4" && projNo && ( + + )} + + {vendorInfo && selectedDrawing && ( + + )} + + {vendorInfo && selectedDetail && ( + + )} + + {/* 동기화 다이얼로그 */} + {vendorInfo && projNo && ( + + )} +
+ ); +} diff --git a/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx b/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx new file mode 100644 index 00000000..f62f486b --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx @@ -0,0 +1,49 @@ +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import DolceUploadPageV3 from "./dolce-upload-page-v3"; +import { Shell } from "@/components/shell"; + +export const metadata = { + title: "조선 벤더문서 업로드(DOLCE) V3", + description: "조선 설계문서 업로드 및 관리 - 오프라인 동기화 지원", +}; + +function DolceUploadSkeleton() { + return ( +
+ + +
+ ); +} + +export default async function DolceUploadPageWrapper({ + params, + searchParams, +}: { + params: Promise<{ lng: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { lng } = await params; + const resolvedParams = await searchParams; + + return ( + +
+
+

+ {lng === "ko" ? "DOLCE 도면 업로드 V3 (동기화)" : "DOLCE Drawing Upload V3 (Sync)"} +

+

+ {lng === "ko" ? "임시 저장 및 서버 동기화 기능을 지원합니다." : "Supports temporary save and server synchronization."} +

+
+
+ + }> + + +
+ ); +} diff --git a/app/api/dolce/download/route.ts b/app/api/dolce/download/route.ts index 9d5eb601..94fe35b4 100644 --- a/app/api/dolce/download/route.ts +++ b/app/api/dolce/download/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { downloadDolceFile } from "@/lib/dolce/actions"; +import { downloadDolceFile, downloadLocalFile } from "@/lib/dolce-v2/actions"; export async function POST(request: NextRequest) { try { @@ -15,6 +15,19 @@ export async function POST(request: NextRequest) { console.log("[DOLCE Download API] 요청:", { fileId, userId, fileName }); + // 로컬 파일 다운로드 처리 + if (String(fileId).startsWith("LOCAL_")) { + const { buffer, fileName: localFileName } = await downloadLocalFile(fileId); + + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(localFileName)}`, + "Content-Length": buffer.byteLength.toString(), + }, + }); + } + // DOLCE API를 통해 파일 다운로드 const { blob, fileName: downloadFileName } = await downloadDolceFile({ fileId, diff --git a/db/schema/dolce/dolce.ts b/db/schema/dolce/dolce.ts new file mode 100644 index 00000000..378d29d2 --- /dev/null +++ b/db/schema/dolce/dolce.ts @@ -0,0 +1,89 @@ +import { pgSchema, varchar, timestamp, jsonb, text, index, serial, boolean, uuid, integer } from "drizzle-orm/pg-core"; + +export const dolceSchema = pgSchema("dolce"); + +/** + * 돌체 스키마: 동기화 기능을 만들기 위해, 로컬에 임시 저장을 위한 퍼시스턴스 레이어를 구성 + * + * 전체적인 설명: + * 외부 시스템인 돌체는 문서관리 시스템임. + * 우리 시스템은 돌체와 API 를 통해, 벤더가 문서를 넣어줄 수 있게 하는 역할을 함. + * + * 특정 프로젝트에 B3, B4 라는 타입으로 문서를 관리함 + * B3는 일반 벤더, B4는 GTT 라는 특정 벤더에 대한 타입임. + * + * 전체적인 구조는 B3, B4가 유사함 + * - 도면리스트: 외부시스템에서 관리하므로, 우리는 받기만 함 + * + * - 상세도면리스트: 특정 도면에 소속된 개별 도면임. + * Revision을 관리하기 위해 생긴 레이어이며, 개별 도면에 연결된, 파일이 첨부된 실제 수발신하는 도면 단위라고 보면 됨. + * 외부시스템(DOLCE)에서 리스트를 API로 응답함. 개별 도면별로 응답함. + * 우리 시스템에서 벤더가 생성한다. (생성 API를 호출함) 단, 외부 시스템 측에서도 작업해 응답 결과를 다르게 줄 수도 있음. + * 우리 시스템에서 벤더가 수정할 수 있다. (수정 API를 호출함) 수정시에도 로컬 측에 저장하고, 동기화시 수정된 내용을 외부시스템에 전송한다. + * 우리 시스템에서 벤더가 상세도면을 생성했을 때, 우선 우리 시스템 DB에만 생성하고, API 응답 리스트에 로컬DB 임시저장값을 추가해서 보여줄 것임. + * + * - 파일리스트: 외부시스템에서 관리하며, 개별 상세도면에 첨부된 파일 리스트임. + * 외부시스템(DOLCE)에서 특정 상세도면의 파일 리스트를 API로 조회할 수 있음. 개별 상세도면에 할당된 uploadId 별로 파일리스트를 응답함. + * 우리 시스템에서 벤더가 파일을 추가하기도 함. 이 경우, 우선 우리 시스템 DB에 추가한 파일 정보를 임시 저장하고, API 응답 리스트에 로컬DB 임시저장값을 추가해서 보여줄 것임. + * + * 동기화 기능 + * - 우리 시스템에서 상세도면 추가 및 첨부파일을 추가했던 건들을 외부시스템에 전송해주는 기능임 + * - 임시 저장시 dolce에 생성/수정 요청을 할 수 있도록 필요한 정보를 저장해야 함. 이것은 스키마의 작성 기준임 + * - 동기화 하기 이전에, 우리 시스템에서 임시로 저장한 건들은 삭제할 수 있도록 함. (soft 삭제가 아니고 hard 삭제) + * + * 동기화 목록 구성 방법 + * - isSynced==false 인 건들로 목록을 구성한다. + * - 상세도면 추가 작업 건은, 상세도면+첨부파일을 하나의 동기화 건으로 구성한다. + * - 파일 추가 작업 건은, 개별 파일들을 동기화 건으로 구성한다. + * - B4 Bulk 업로드 건은, 개별 파일들을 동기화 건으로 구성한다. + * - 사용자는 본인이 임시 저장한 건들만을 선택해서 동기화할 수 있도록 한다. + * + * 사용자가 로컬에 파일 업로드하는 부분의 구현 + * 1. 환경변수 DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce" 가 설정되어 있음. (없으면 경로 만들기) + * 2. 로컬에서 저장할 때는 파일 메타데이터를 별도로 저장 (로컬 파일 경로는 API 호출시 사용하지 않으나, 로컬 건들도 조회 및 다운로드가 가능해야 하므로) + * 3. 동기화되지 않아 로컬에만 있는 건들은 파일 다운로드시 로컬에서 다운로드 + * 4. 동기화 성공한 경우, 로컬에 저장된 파일은 삭제 + */ + +export const dolceSyncList = dolceSchema.table("dolce_sync_list", { + id: uuid("id").primaryKey().defaultRandom(), + + // [필수] 작업 유형 + // "ADD_DETAIL": 상세도면 추가 (메타데이터 + 파일) + // "MOD_DETAIL": 상세도면 수정 (메타데이터) + // "ADD_FILE": 기존 상세도면에 파일 추가 (파일) + // "B4_BULK": B4 일괄 업로드 + type: varchar("type", { length: 50 }).notNull(), + + // [중요] 조회 성능 및 필터링을 위한 메타데이터 컬럼 (JSONB에서 자주 쓰는 값 추출) + projectNo: varchar("project_no", { length: 50 }).notNull(), // 프로젝트별 조회용 + drawingNo: varchar("drawing_no", { length: 100 }), // 특정 도면 조회용 + uploadId: varchar("upload_id", { length: 100 }), // 업로드ID 기준 조회용 + + userId: varchar("user_id", { length: 50 }).notNull(), // 내 작업만 보기용 + userName: varchar("user_name", { length: 100 }), // [추가] UI 표시용 + vendorCode: varchar("vendor_code", { length: 50 }), // [추가] 벤더별 필터링용 + + // [데이터] + // 실제 API 호출에 필요한 Body 데이터 + 로컬 파일 경로 정보 포함 + // 예: { meta: { ...API Body... }, files: [{ localPath: "...", originalName: "..." }] } + payload: jsonb("payload").notNull(), + + // [상태] + isSynced: boolean("is_synced").default(false).notNull(), + syncAttempts: integer("sync_attempts").default(0).notNull(), + lastError: text("last_error"), // 실패 사유 + + // [응답] + responseCode: varchar("response_code", { length: 50 }), + response: text("response"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => ({ + // 인덱스 설정: 프로젝트별 미동기화 건 조회 속도 향상 + projectIdx: index("dolce_sync_project_idx").on(table.projectNo, table.isSynced), + userIdx: index("dolce_sync_user_idx").on(table.userId, table.isSynced), + // [추가] 벤더별 조회 인덱스 + vendorIdx: index("dolce_sync_vendor_idx").on(table.projectNo, table.vendorCode, table.isSynced), +})); diff --git a/db/schema/index.ts b/db/schema/index.ts index 1155740b..df4ca424 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -85,3 +85,5 @@ export * from './avl/vendor-pool'; // === Email Logs 스키마 === export * from './emailLogs'; export * from './emailWhitelist'; +// Dolce 로컬 저장용 스키마 +export * from './dolce/dolce'; \ No newline at end of file diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index e49c0628..c9ad43ff 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -9,6 +9,7 @@ import { unstable_cache } from "@/lib/unstable-cache"; import { sendEmail } from '@/lib/mail/sendEmail' import { saveFile } from '@/lib/file-stroage' import { sendBiddingNoticeSms } from '@/lib/users/auth/passwordUtil' +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise { @@ -786,6 +787,7 @@ export async function markAsDisposal(biddingId: number, userId: string) { // 입찰 등록 ( 본입찰 초대 발송) export async function registerBidding(biddingId: number, userId: string) { + debugLog('registerBidding started', { biddingId, userId }) try { // 본입찰에서 개별적으로 추가한 업체들 조회 const selectedCompanies = await db @@ -800,6 +802,8 @@ export async function registerBidding(biddingId: number, userId: string) { eq(biddingCompanies.biddingId, biddingId) ) + debugLog('registerBidding: selectedCompanies fetched', { count: selectedCompanies.length, selectedCompanies }) + // 입찰 정보 조회 const biddingInfo = await db .select() @@ -808,14 +812,18 @@ export async function registerBidding(biddingId: number, userId: string) { .limit(1) if (biddingInfo.length === 0) { + debugError('registerBidding: Bidding info not found', { biddingId }) return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } } const bidding = biddingInfo[0] + debugLog('registerBidding: bidding info fetched', { bidding }) const userName = await getUserNameById(userId) + debugLog('registerBidding: userName fetched', { userName }) await db.transaction(async (tx) => { + debugLog('registerBidding: Transaction started') // 1. 입찰 상태를 오픈으로 변경 await tx .update(biddings) @@ -840,9 +848,11 @@ export async function registerBidding(biddingId: number, userId: string) { eq(biddingCompanies.companyId, company.companyId) )) } + debugLog('registerBidding: Transaction completed (status updated, companies invited)') }) // 3. 선정된 업체들에게 본입찰 초대 메일 발송 + debugLog('registerBidding: Sending emails...') for (const company of selectedCompanies) { if (company.contactEmail) { try { @@ -864,12 +874,14 @@ export async function registerBidding(biddingId: number, userId: string) { language: 'ko' } }) + debugLog(`registerBidding: Email sent to ${company.contactEmail}`) } catch (emailError) { - console.error(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) + debugError(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) } } } // 4. 입찰 공고 SMS 알림 전송 + debugLog('registerBidding: Sending SMS...') for (const company of selectedCompanies) { // biddingCompaniesContacts에서 모든 연락처 전화번호 조회 const contactInfos = await db @@ -890,12 +902,12 @@ export async function registerBidding(biddingId: number, userId: string) { try { const smsResult = await sendBiddingNoticeSms(contactPhone, bidding.title); if (smsResult.success) { - console.log(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`); + debugLog(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`); } else { - console.error(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`); + debugError(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`); } } catch (smsError) { - console.error(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError) + debugError(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError) } } } @@ -908,21 +920,25 @@ export async function registerBidding(biddingId: number, userId: string) { revalidateTag('pr-items') revalidatePath(`/evcp/bid/${biddingId}`) + debugSuccess(`registerBidding: Success. Invited ${selectedCompanies.length} companies.`) return { success: true, message: `입찰이 성공적으로 등록되었습니다. ${selectedCompanies.length}개 업체에 초대 메일을 발송했습니다.` } } catch (error) { - console.error('Failed to register bidding:', error) - return { success: false, error: '입찰 등록에 실패했습니다.' } + debugError('Failed to register bidding:', error) + return { success: false, error: `입찰 등록에 실패했습니다. ${error}` } } } // 업체 선정 사유 업데이트 export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) { + debugLog('updateVendorSelectionReason started', { biddingId, selectedCompanyId, selectionReason, userId }) try { const userName = await getUserNameById(userId) + debugLog('updateVendorSelectionReason: userName fetched', { userName }) + // vendorSelectionResults 테이블에 삽입 또는 업데이트 await db .insert(vendorSelectionResults) @@ -946,13 +962,17 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } }) + debugLog('updateVendorSelectionReason: DB updated') + // 캐시 무효화 revalidateTag(`bidding-${biddingId}`) revalidateTag('quotation-vendors') revalidatePath(`/evcp/bid/${biddingId}`) + + debugSuccess('updateVendorSelectionReason: Success') return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' } } catch (error) { - console.error('Failed to update vendor selection reason:', error) + debugError('Failed to update vendor selection reason:', error) return { success: false, error: '업체 선정 사유 업데이트에 실패했습니다.' } } } 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 { + // 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 { + // 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> { + 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(); + + 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(""); + const [registerKind, setRegisterKind] = useState(""); + const [revision, setRevision] = useState(""); + const [revisionError, setRevisionError] = useState(""); + const [comment, setComment] = useState(""); + 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 ( + + + + + {mode === "edit" ? t("editDetailDialog.title") : t("addDetailDialog.title")} + + + +
+ {/* 도면 정보 표시 */} + {mode === "add" && drawing && ( + + + +
{drawing.DrawingNo}
+
{drawing.DrawingName}
+
+
+ )} + + {mode === "edit" && detailDrawing && ( + + + +
{detailDrawing.DrawingNo} - Rev. {detailDrawing.DrawingRevNo}
+
{detailDrawing.DrawingName}
+
+
+ )} + + {/* 도면용도 선택 (Add 모드에서만 표시) */} + {mode === "add" && ( +
+ + +
+ )} + + {/* 등록종류 선택 */} +
+ + + {revisionRule && ( +

+ {t("addDetailDialog.revisionFormatPrefix")}{revisionRule} +

+ )} +
+ + {/* Revision 입력 */} + {drawingUsage !== "CMT" && ( +
+ + handleRevisionChange(e.target.value)} + placeholder={t("addDetailDialog.revisionPlaceholder")} + disabled={!registerKind} + className={revisionError ? "border-red-500 focus-visible:ring-red-500" : ""} + /> + {revisionError && ( +

+ {revisionError} +

+ )} + {!revisionError && revision && ( +

+ {t("addDetailDialog.revisionValid")} +

+ )} +
+ )} + + {/* Comment 입력 */} +
+ +