summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development5
-rw-r--r--.env.production5
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx590
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx49
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx8
-rw-r--r--app/api/dolce/download/route.ts15
-rw-r--r--db/schema/dolce/dolce.ts89
-rw-r--r--db/schema/index.ts2
-rw-r--r--lib/bidding/detail/service.ts34
-rw-r--r--lib/dolce-v2/actions.ts605
-rw-r--r--lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx568
-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.ts425
-rw-r--r--lib/dolce/actions.ts23
-rw-r--r--lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx77
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx690
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog.tsx7
-rw-r--r--lib/dolce/dialogs/detail-drawing-dialog.tsx6
-rw-r--r--lib/dolce/table/detail-drawing-columns.tsx86
-rw-r--r--lib/dolce/table/drawing-list-table-v2.tsx2
-rw-r--r--lib/dolce/table/file-list-columns.tsx55
-rw-r--r--lib/dolce/table/gtt-drawing-list-columns.tsx23
-rw-r--r--lib/dolce/utils/code-translator.ts32
-rw-r--r--lib/dolce/utils/date-formatter.ts18
26 files changed, 4272 insertions, 137 deletions
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..55bedb38
--- /dev/null
+++ b/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx
@@ -0,0 +1,590 @@
+/* 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<UnifiedDwgReceiptItem[]>([]);
+ const [projects, setProjects] = useState<Array<{ code: string; name: string }>>([]);
+ 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<string | null>(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<DocumentType>("ALL"); // B4 전용
+
+ // 선택된 도면 및 상세도면
+ const [selectedDrawing, setSelectedDrawing] = useState<UnifiedDwgReceiptItem | null>(null);
+ const [detailDrawings, setDetailDrawings] = useState<DetailDwgReceiptItem[]>([]);
+ const [selectedDetail, setSelectedDetail] = useState<DetailDwgReceiptItem | null>(null);
+ const [files, setFiles] = useState<FileInfoItem[]>([]);
+ const [isLoadingDetails, setIsLoadingDetails] = useState(false);
+ const [, setIsLoadingFiles] = useState(false);
+ const [downloadingFileId, setDownloadingFileId] = useState<string | null>(null);
+
+ // 다이얼로그 상태
+ 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 {
+ setDownloadingFileId(file.FileId);
+ 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"));
+ } finally {
+ setDownloadingFileId(null);
+ }
+ };
+
+ // 필터 로직
+ 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,
+ downloadingFileId
+ });
+
+ if (isLoading) {
+ return (
+ <div className="space-y-4">
+ <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-32 w-full" /></CardContent></Card>
+ <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-96 w-full" /></CardContent></Card>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4 max-w-full overflow-x-hidden h-full flex flex-col">
+ {error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
+
+ {/* 헤더 및 Sync 컨트롤 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2">
+ Dolce Upload V3 <span className="text-xs font-normal bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-1 rounded-full">Sync Enabled</span>
+ </h2>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ size="sm"
+ variant={pendingSyncCount > 0 ? "default" : "outline"}
+ onClick={() => setSyncDialogOpen(true)} // 다이얼로그 열기
+ disabled={pendingSyncCount === 0 || !projNo}
+ >
+ <Cloud className="h-4 w-4 mr-2" />
+ Send to SHI
+ </Button>
+ </div>
+ </div>
+
+ {!projNo && <Alert><InfoIcon className="h-4 w-4" /><AlertDescription>{t("page.selectProject")}</AlertDescription></Alert>}
+
+ {/* 필터 카드 */}
+ <Card className="flex-shrink-0">
+ <CardHeader className="py-3"><CardTitle className="text-base">{t("filter.title")}</CardTitle></CardHeader>
+ <CardContent className="py-3">
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3">
+ <div className="space-y-1">
+ <Label className="text-xs">{t("filter.project")}</Label>
+ <Select value={projNo} onValueChange={setProjNo}>
+ <SelectTrigger className="h-8"><SelectValue placeholder={t("filter.projectPlaceholder")} /></SelectTrigger>
+ <SelectContent>
+ {projects.map((p) => <SelectItem key={p.code} value={p.code}>{p.code} - {p.name}</SelectItem>)}
+ </SelectContent>
+ </Select>
+ </div>
+ {/* 기타 필터들 */}
+ <div className="space-y-1"><Label className="text-xs">{t("filter.drawingNo")}</Label><Input className="h-8" value={drawingNo} onChange={(e) => setDrawingNo(e.target.value)} /></div>
+ <div className="space-y-1"><Label className="text-xs">{t("filter.drawingName")}</Label><Input className="h-8" value={drawingName} onChange={(e) => setDrawingName(e.target.value)} /></div>
+ <div className="space-y-1"><Label className="text-xs">{t("filter.discipline")}</Label><Input className="h-8" value={discipline} onChange={(e) => setDiscipline(e.target.value)} /></div>
+ <div className="space-y-1"><Label className="text-xs">{t("filter.manager")}</Label><Input className="h-8" value={manager} onChange={(e) => setManager(e.target.value)} /></div>
+ {vendorInfo?.drawingKind === "B4" && (
+ <div className="space-y-1">
+ <Label className="text-xs">{t("filter.documentType")}</Label>
+ <Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}>
+ <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
+ <SelectContent>
+ <SelectItem value="ALL">{t("filter.documentTypeAll")}</SelectItem>
+ <SelectItem value="GTT_DELIVERABLES">{t("filter.documentTypeGttDeliverables")}</SelectItem>
+ <SelectItem value="SHI_INPUT">{t("filter.documentTypeSHIInput")}</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ </div>
+ <div className="flex gap-2 mt-3 justify-end">
+ <Button size="sm" onClick={handleSearch} disabled={!projNo || isRefreshing}><Search className="h-4 w-4 mr-2" />{t("filter.searchButton")}</Button>
+ {vendorInfo?.drawingKind === "B4" && (
+ <Button size="sm" onClick={() => setBulkUploadDialogOpen(true)} disabled={!projNo || isRefreshing}>
+ <Upload className="h-4 w-4 mr-2" />{t("filter.bulkUploadButton")}
+ </Button>
+ )}
+ <Button size="sm" variant="outline" onClick={handleRefresh} disabled={!projNo || isRefreshing}><RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /></Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메인 컨텐츠 영역 */}
+ <Card className="flex-shrink-0" style={{ minHeight: "400px" }}>
+ <CardHeader className="py-3"><CardTitle className="text-base">{t("drawingList.title")}</CardTitle></CardHeader>
+ <CardContent className="p-0">
+ <DrawingListTableV2
+ columns={
+ vendorInfo?.drawingKind === "B4"
+ ? (createGttDrawingListColumns({ documentType, lng, t }) as unknown as typeof drawingListColumns as any)
+ : (drawingListColumns(lng, t) as unknown as typeof drawingListColumns as any)
+ }
+ data={filteredDrawings}
+ onRowClick={handleDrawingClick}
+ selectedRow={selectedDrawing || undefined}
+ getRowId={getDrawingId}
+ maxHeight="400px"
+ minHeight="300px"
+ />
+ </CardContent>
+ </Card>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
+ <Card className="flex flex-col min-h-0">
+ <CardHeader className="flex-row items-center justify-between py-3">
+ <CardTitle className="text-base">{t("detailDialog.detailListTitle")}</CardTitle>
+ <div className="flex gap-2">
+ <Button variant="outline" size="sm" onClick={handleRefreshDetails} disabled={!selectedDrawing}><RefreshCw className={`h-4 w-4 ${isLoadingDetails ? "animate-spin" : ""}`} /></Button>
+ {canAddDetailDrawing && <Button size="sm" onClick={() => setAddDialogOpen(true)} disabled={!selectedDrawing}><Plus className="h-4 w-4 mr-2" />{t("detailDialog.addDetailButton")}</Button>}
+ </div>
+ </CardHeader>
+ <CardContent className="p-0 flex-1">
+ <DrawingListTableV2
+ columns={createDetailDrawingColumns(lng, t) as any}
+ data={detailDrawings}
+ onRowClick={setSelectedDetail as any}
+ selectedRow={selectedDetail || undefined}
+ getRowId={getDetailDrawingId}
+ maxHeight="400px"
+ minHeight="300px"
+ />
+ </CardContent>
+ </Card>
+
+ <Card className="flex flex-col min-h-0">
+ <CardHeader className="flex-row items-center justify-between py-3">
+ <CardTitle className="text-base">{t("detailDialog.fileListTitle")}</CardTitle>
+ {selectedDetail && canAddDetailDrawing && (
+ <Button size="sm" onClick={() => setUploadFilesDialogOpen(true)}><Upload className="h-4 w-4 mr-2" />{t("detailDialog.uploadFilesButton")}</Button>
+ )}
+ </CardHeader>
+ <CardContent className="p-0 flex-1">
+ <DrawingListTableV2
+ columns={fileColumns as any}
+ data={files}
+ maxHeight="400px"
+ minHeight="300px"
+ />
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 다이얼로그 영역 */}
+ {vendorInfo && vendorInfo.drawingKind === "B4" && projNo && (
+ <B4BulkUploadDialogV3Sync
+ open={bulkUploadDialogOpen}
+ onOpenChange={setBulkUploadDialogOpen}
+ projectNo={projNo}
+ userId={vendorInfo.userId}
+ userName={vendorInfo.userName}
+ userEmail={vendorInfo.email}
+ vendorCode={vendorInfo.vendorCode}
+ onUploadComplete={handleBulkUploadComplete}
+ lng={lng}
+ />
+ )}
+
+ {vendorInfo && selectedDrawing && (
+ <AddAndModifyDetailDrawingDialogV2
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ drawing={selectedDrawing}
+ vendorCode={vendorInfo.vendorCode}
+ userId={vendorInfo.userId}
+ userName={vendorInfo.userName}
+ userEmail={vendorInfo.email}
+ onComplete={handleAddComplete}
+ drawingKind={vendorInfo.drawingKind}
+ lng={lng}
+ />
+ )}
+
+ {vendorInfo && selectedDetail && (
+ <UploadFilesToDetailDialogV2
+ open={uploadFilesDialogOpen}
+ onOpenChange={setUploadFilesDialogOpen}
+ uploadId={selectedDetail.UploadId}
+ drawingNo={selectedDetail.DrawingNo}
+ revNo={selectedDetail.DrawingRevNo}
+ // 추가된 props
+ drawingName={selectedDrawing?.DrawingName}
+ discipline={selectedDrawing?.Discipline}
+ registerKind={selectedDetail.RegisterKind}
+
+ userId={vendorInfo.userId}
+ projectNo={projNo}
+ vendorCode={vendorInfo.vendorCode} // 추가: Vendor Code
+ onUploadComplete={handleUploadComplete}
+ lng={lng}
+ />
+ )}
+
+ {/* 동기화 다이얼로그 */}
+ {vendorInfo && projNo && (
+ <SyncItemsDialog
+ open={syncDialogOpen}
+ onOpenChange={setSyncDialogOpen}
+ projectNo={projNo}
+ userId={vendorInfo.userId}
+ vendorCode={vendorInfo.vendorCode}
+ onSyncComplete={handleSyncComplete}
+ lng={lng}
+ />
+ )}
+ </div>
+ );
+}
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 (
+ <div className="space-y-4">
+ <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-32 w-full" /></CardContent></Card>
+ <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-96 w-full" /></CardContent></Card>
+ </div>
+ );
+}
+
+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 (
+ <Shell variant="fullscreen">
+ <div className="flex items-center justify-between flex-shrink-0">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {lng === "ko" ? "DOLCE 도면 업로드 V3 (동기화)" : "DOLCE Drawing Upload V3 (Sync)"}
+ </h2>
+ <p className="text-muted-foreground">
+ {lng === "ko" ? "임시 저장 및 서버 동기화 기능을 지원합니다." : "Supports temporary save and server synchronization."}
+ </p>
+ </div>
+ </div>
+
+ <Suspense fallback={<DolceUploadSkeleton />}>
+ <DolceUploadPageV3 searchParams={resolvedParams} />
+ </Suspense>
+ </Shell>
+ );
+}
diff --git a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx
index e03f6bc2..1bb876fb 100644
--- a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx
+++ b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx
@@ -28,8 +28,8 @@ import { DrawingListTable } from "@/lib/dolce/table/drawing-list-table";
import { drawingListColumns } from "@/lib/dolce/table/drawing-list-columns";
import { createGttDrawingListColumns, DocumentType } from "@/lib/dolce/table/gtt-drawing-list-columns";
import { DetailDrawingDialog } from "@/lib/dolce/dialogs/detail-drawing-dialog";
-// V2: MatchBatchFileDwg API를 사용하지 않는 새로운 일괄 업로드 (DetailDwgReceiptMgmtEdit 사용)
-import { B4BulkUploadDialogV2 } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog-v2";
+// V2: MatchBatchFileDwg+MatchBatchFileDwgEdit API 사용, 별도의 RegisterKind 선택 없이 결과값 기준으로 업로드
+import { B4BulkUploadDialogV3 } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog-v3";
// V1로 되돌리려면: 위 줄을 주석 처리하고 아래 줄의 주석을 해제하세요
// import { B4BulkUploadDialog } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog";
@@ -399,9 +399,9 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps)
)}
{/* B4 일괄 업로드 다이얼로그 (V2) */}
- {/* V2: MatchBatchFileDwg API를 사용하지 않는 새로운 방식 */}
+ {/* V2: MatchBatchFileDwg+MatchBatchFileDwgEdit API 사용, 별도의 RegisterKind 선택 없이 결과값 기준으로 업로드 */}
{vendorInfo && vendorInfo.drawingKind === "B4" && projNo && (
- <B4BulkUploadDialogV2
+ <B4BulkUploadDialogV3
open={bulkUploadDialogOpen}
onOpenChange={setBulkUploadDialogOpen}
projectNo={projNo}
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 06030c8a..8f9bf018 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<string> {
@@ -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)
}
}
}
@@ -906,21 +918,25 @@ export async function registerBidding(biddingId: number, userId: string) {
tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'quotation-details', 'pr-items']
});
+ 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)
@@ -944,13 +960,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<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..7577d133
--- /dev/null
+++ b/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx
@@ -0,0 +1,568 @@
+"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 = () => {
+ resetForm();
+ onOpenChange(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..7f866955
--- /dev/null
+++ b/lib/dolce-v2/sync-service.ts
@@ -0,0 +1,425 @@
+"use server";
+
+import fs from "fs/promises";
+import { createReadStream, createWriteStream } from "fs";
+import { Readable } from "stream";
+import { pipeline } from "stream/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 uniqueName = `${uuidv4()}_${file.name}`;
+ const localPath = path.join(LOCAL_UPLOAD_DIR, uniqueName);
+
+ // Stream: Web Stream (file.stream()) -> Node Writable Stream (fs.createWriteStream)
+ // Readable.fromWeb requires Node 18+
+ const readable = Readable.fromWeb(file.stream() as any);
+ const writable = createWriteStream(localPath);
+
+ await pipeline(readable, writable);
+
+ 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();
+
+ // 로컬 파일 읽기 (Stream)
+ const fileStream = createReadStream(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",
+ "Content-Length": file.size.toString()
+ },
+ body: fileStream as any, // Node stream as body
+ duplex: "half", // Required for Node streams
+ } as RequestInit & { duplex?: string });
+
+ 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));
+ }
+}
diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts
index 8c5dfa1b..5590ce8c 100644
--- a/lib/dolce/actions.ts
+++ b/lib/dolce/actions.ts
@@ -67,6 +67,10 @@ export interface GttDwgReceiptItem {
RegisterGroupId: number;
SGbn: string | null;
SHIDrawingNo: string | null;
+ // Added ENM fields
+ CategoryENM?: string;
+ DrawingUsageENM?: string;
+ RegisterKindENM?: string;
}
// 통합 도면 아이템 타입
@@ -145,7 +149,7 @@ export interface DetailDwgEditRequest {
RegisterGroupId: number;
RegisterSerialNo: number;
RegisterKind: string;
- DrawingRevNo: string;
+ DrawingRevNo: string | null;
Category: string;
Receiver: string | null;
Manager: string;
@@ -158,7 +162,7 @@ export interface DetailDwgEditRequest {
// 유틸리티 함수
// ============================================================================
-async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
+export async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
const url = `${DOLCE_API_URL}/Services/VDCSWebService.svc/${endpoint}`;
console.log(`[DOLCE API] Calling ${endpoint}:`, JSON.stringify(body, null, 2));
@@ -716,14 +720,22 @@ export interface B4MappingSaveItem {
export async function saveB4MappingBatch(
mappingSaveLists: B4MappingSaveItem[],
- userId: string
+ userInfo: {
+ userId: string;
+ userName: string;
+ vendorCode: string;
+ email: string;
+ }
): Promise<number> {
try {
const response = await dolceApiCall<{
MatchBatchFileDwgEditResult: number;
}>("MatchBatchFileDwgEdit", {
mappingSaveLists,
- UserID: userId,
+ UserID: userInfo.userId,
+ UserNM: userInfo.userName,
+ VENDORCODE: userInfo.vendorCode,
+ EMAIL: userInfo.email,
});
return response.MatchBatchFileDwgEditResult;
@@ -738,7 +750,7 @@ export async function saveB4MappingBatch(
* 형식: [버림] [문서번호토큰1] [문서번호토큰2] ... [리비전번호].[확장자]
* 예시: "testfile GTT DE 007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01"
*/
-async function parseB4FileName(fileName: string): Promise<{
+export async function parseB4FileName(fileName: string): Promise<{
valid: boolean;
drawingNo?: string;
revNo?: string;
@@ -1489,4 +1501,3 @@ export async function bulkUploadB4Files(
};
}
}
-
diff --git a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
index 87819693..673d48d6 100644
--- a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
+++ b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
@@ -164,18 +164,21 @@ export function AddAndModifyDetailDrawingDialog({
toast.error(t("addDetailDialog.selectRegisterKindError"));
return;
}
- 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;
+ 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 모드일 때만 파일 필수
@@ -219,7 +222,7 @@ export function AddAndModifyDetailDrawingDialog({
RegisterGroupId: drawing.RegisterGroupId,
RegisterSerialNo: 0, // 자동 증가
RegisterKind: registerKind,
- DrawingRevNo: revision,
+ DrawingRevNo: drawingUsage === "CMT" ? null : revision,
Category: "TS", // To SHI (벤더가 SHI에게 제출)
Receiver: null,
Manager: "",
@@ -293,7 +296,7 @@ export function AddAndModifyDetailDrawingDialog({
RegisterGroupId: detailDrawing.RegisterGroupId,
RegisterSerialNo: detailDrawing.RegisterSerialNo,
RegisterKind: registerKind,
- DrawingRevNo: revision,
+ DrawingRevNo: drawingUsage === "CMT" ? null : revision,
Category: detailDrawing.Category,
Receiver: detailDrawing.Receiver,
Manager: detailDrawing.Manager,
@@ -345,12 +348,10 @@ export function AddAndModifyDetailDrawingDialog({
const isFormValid = mode === "add"
? drawingUsage.trim() !== "" &&
registerKind.trim() !== "" &&
- revision.trim() !== "" &&
- !revisionError &&
+ (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError)) &&
files.length > 0
: registerKind.trim() !== "" &&
- revision.trim() !== "" &&
- !revisionError;
+ (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -429,26 +430,28 @@ export function AddAndModifyDetailDrawingDialog({
</div>
{/* Revision 입력 */}
- <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>
+ {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">
diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx
new file mode 100644
index 00000000..ea955420
--- /dev/null
+++ b/lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx
@@ -0,0 +1,690 @@
+"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 "./b4-upload-validation-dialog";
+import {
+ checkB4MappingStatus,
+ saveB4MappingBatch,
+ type MappingCheckResult,
+ type B4BulkUploadResult,
+ type B4MappingSaveItem,
+} from "../actions";
+import { uploadFilesWithProgress } from "../utils/upload-with-progress";
+import { FileUploadProgressList } from "../components/file-upload-progress-list";
+import type { FileUploadProgress } from "../hooks/use-file-upload-with-progress";
+import { v4 as uuidv4 } from "uuid";
+
+interface B4BulkUploadDialogV3Props {
+ 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 B4BulkUploadDialogV3({
+ open,
+ onOpenChange,
+ projectNo,
+ userId,
+ userName,
+ userEmail,
+ vendorCode,
+ onUploadComplete,
+ lng,
+}: B4BulkUploadDialogV3Props) {
+ 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<B4BulkUploadResult | null>(null);
+ const [fileProgresses, setFileProgresses] = useState<FileUploadProgress[]>([]);
+
+ // Reset on close
+ React.useEffect(() => {
+ if (!open) {
+ setCurrentStep("files");
+ setSelectedFiles([]);
+ setValidationResults([]);
+ setMappingResultsMap(new Map());
+ setShowValidationDialog(false);
+ setIsDragging(false);
+ setUploadProgress(0);
+ setUploadResult(null);
+ setFileProgresses([]);
+ }
+ }, [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();
+ if (e.currentTarget === e.target) {
+ setIsDragging(false);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ if (droppedFiles.length > 0) {
+ handleFilesChange(droppedFiles);
+ }
+ };
+
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // 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 {
+ console.log("[V3 Dialog] Validation started");
+
+ // 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 no files parsed correctly, show dialog immediately with errors
+ 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,
+ }));
+
+ console.log(`[V3 Dialog] Checking mapping for ${mappingCheckItems.length} files`);
+
+ const mappingResults = await checkB4MappingStatus(
+ projectNo,
+ mappingCheckItems
+ );
+
+ // Store mapping results for later use (upload/save)
+ 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"),
+ };
+ }
+
+ // According to prompt: "API 응답에서 매핑되지 않은 경우는, 파일명으로부터 파싱된 도면이 없는 경우임."
+ // Also "MappingYN 의 값이 현재 매핑이 되어있는지를 나타냄. Y인 건들은 저장 가능"
+ if (mappingResult.MappingYN !== "Y") {
+ return {
+ ...parseResult,
+ mappingStatus: "not_found" as const,
+ error: t("validation.notRegistered"), // Or specific message for MappingYN=N
+ };
+ }
+
+ // Check DrawingMoveGbn = "도면입수" (implied by requirements to use MatchBatchFileDwg with 도면입수)
+ 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,
+ };
+ });
+
+ console.log("[V3 Dialog] Validation complete");
+ setValidationResults(finalResults);
+ setShowValidationDialog(true);
+ } catch (error) {
+ console.error("[V3 Dialog] Validation failed:", error);
+ toast.error(
+ error instanceof Error ? error.message : t("bulkUpload.validationError")
+ );
+ // Go back to files step if validation crashes completely
+ setCurrentStep("files");
+ }
+ };
+
+ // Confirm Upload & Save (V3)
+ const handleConfirmUpload = async (validFiles: FileValidationResult[]) => {
+ setIsUploading(true);
+ setCurrentStep("uploading");
+ setShowValidationDialog(false);
+
+ try {
+ console.log(`[V3 Dialog] Upload started: ${validFiles.length} files`);
+
+ // 0. Initialize progress
+ const initialProgresses: FileUploadProgress[] = validFiles.map((fileResult) => ({
+ file: fileResult.file,
+ progress: 0,
+ status: "pending" as const,
+ }));
+ setFileProgresses(initialProgresses);
+
+ // 1. Group by DrawingNo + RevNo (to share UploadId if needed)
+ const uploadGroups = new Map<
+ string,
+ Array<{
+ file: File;
+ fileIndex: number; // Index in validFiles
+ mappingData: MappingCheckResult;
+ }>
+ >();
+
+ // Pre-process groups
+ validFiles.forEach((fileResult, index) => {
+ const mappingData = mappingResultsMap.get(fileResult.file.name);
+ if (!mappingData) return; // Should not happen for valid files
+
+ const groupKey = `${mappingData.DrawingNo}_${mappingData.RevNo}`;
+ if (!uploadGroups.has(groupKey)) {
+ uploadGroups.set(groupKey, []);
+ }
+ uploadGroups.get(groupKey)!.push({
+ file: fileResult.file,
+ fileIndex: index,
+ mappingData
+ });
+ });
+
+ let successCount = 0;
+ let failCount = 0;
+ let completedGroups = 0;
+ const results: B4BulkUploadResult["results"] = [];
+
+ // 2. Process each group
+ for (const [groupKey, groupItems] of uploadGroups.entries()) {
+ // Reuse UploadId from the first item's mapping data if available, else generate new
+ const firstItemMapping = groupItems[0].mappingData;
+ // Reuse existing UploadId if present in API response, otherwise generate new one
+ // The prompt says: "UploadId는 있으면 재활용하고, 없으면 UUID로 만들어줌"
+ const uploadId = firstItemMapping.UploadId || uuidv4();
+
+ console.log(`[V3 Dialog] Processing group ${groupKey}, UploadId: ${uploadId}`);
+
+ try {
+ // Update status to uploading
+ setFileProgresses((prev) =>
+ prev.map((fp, idx) =>
+ groupItems.some(item => item.fileIndex === idx)
+ ? { ...fp, status: "uploading" as const }
+ : fp
+ )
+ );
+
+ // A. Upload Files (Physical Upload)
+ const uploadResult = await uploadFilesWithProgress({
+ uploadId: uploadId,
+ userId: userId,
+ files: groupItems.map(item => item.file),
+ callbacks: {
+ onProgress: (fileIndexInGroup, progress) => {
+ const globalFileIndex = groupItems[fileIndexInGroup].fileIndex;
+ setFileProgresses((prev) =>
+ prev.map((fp, idx) =>
+ idx === globalFileIndex
+ ? { ...fp, progress, status: "uploading" as const }
+ : fp
+ )
+ );
+
+ // Overall progress approximation
+ const groupProgress = (completedGroups / uploadGroups.size) * 100;
+ const currentGroupProgress = (progress / 100) * (100 / uploadGroups.size);
+ setUploadProgress(Math.round(groupProgress + currentGroupProgress));
+ },
+ onFileComplete: (fileIndexInGroup) => {
+ const globalFileIndex = groupItems[fileIndexInGroup].fileIndex;
+ setFileProgresses((prev) =>
+ prev.map((fp, idx) =>
+ idx === globalFileIndex
+ ? { ...fp, progress: 100, status: "completed" as const }
+ : fp
+ )
+ );
+ },
+ onFileError: (fileIndexInGroup, error) => {
+ const globalFileIndex = groupItems[fileIndexInGroup].fileIndex;
+ console.error(`[V3 Dialog] File upload error:`, error);
+ setFileProgresses((prev) =>
+ prev.map((fp, idx) =>
+ idx === globalFileIndex
+ ? { ...fp, status: "error" as const, error }
+ : fp
+ )
+ );
+ }
+ }
+ });
+
+ if (!uploadResult.success) {
+ throw new Error(uploadResult.error || "File upload failed");
+ }
+
+ // B. Save Metadata (MatchBatchFileDwgEdit)
+ // Construct payload from mappingData + generated UploadId + hardcoded values as per prompt
+ const mappingSaveLists: B4MappingSaveItem[] = groupItems.map(item => {
+ const m = item.mappingData;
+ return {
+ CGbn: m.CGbn,
+ Category: "TS", // Hardcoded as per prompt
+ CheckBox: "0",
+ DGbn: m.DGbn,
+ DegreeGbn: m.DegreeGbn,
+ DeptGbn: m.DeptGbn,
+ Discipline: m.Discipline,
+ DrawingKind: "B4",
+ DrawingMoveGbn: "도면입수",
+ DrawingName: m.DrawingName,
+ DrawingNo: m.DrawingNo,
+ DrawingUsage: "입수용",
+ FileNm: item.file.name,
+ JGbn: m.JGbn,
+ Manager: m.Manager || "970043", // Fallback/Default
+ MappingYN: "Y",
+ NewOrNot: "N",
+ ProjectNo: projectNo,
+ RegisterGroup: 0,
+ RegisterGroupId: m.RegisterGroupId,
+ RegisterKindCode: m.RegisterKindCode,
+ RegisterSerialNo: m.RegisterSerialNo,
+ RevNo: m.RevNo,
+ SGbn: m.SGbn,
+ UploadId: uploadId // Used for all files in this group
+ };
+ });
+
+ await saveB4MappingBatch(mappingSaveLists, {
+ userId,
+ userName,
+ vendorCode,
+ email: userEmail,
+ });
+
+ console.log(`[V3 Dialog] Group ${groupKey} complete`);
+ successCount += groupItems.length;
+
+ groupItems.forEach(item => {
+ results.push({
+ drawingNo: item.mappingData.DrawingNo,
+ revNo: item.mappingData.RevNo || "",
+ fileName: item.file.name,
+ success: true
+ });
+ });
+
+ } catch (error) {
+ console.error(`[V3 Dialog] Group ${groupKey} failed:`, error);
+ failCount += groupItems.length;
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+
+ groupItems.forEach(item => {
+ results.push({
+ drawingNo: item.mappingData.DrawingNo,
+ revNo: item.mappingData.RevNo || "",
+ fileName: item.file.name,
+ success: false,
+ error: errorMessage
+ });
+ });
+ }
+
+ completedGroups++;
+ setUploadProgress(Math.round((completedGroups / uploadGroups.size) * 100));
+ }
+
+ // Finalize
+ const result: B4BulkUploadResult = {
+ success: successCount > 0,
+ successCount,
+ failCount,
+ results,
+ };
+
+ setUploadResult(result);
+ setCurrentStep("complete");
+
+ if (result.success) {
+ toast.success(
+ t("bulkUpload.uploadSuccessToast", {
+ successCount: result.successCount,
+ total: validFiles.length,
+ })
+ );
+ } else {
+ toast.error(result.error || t("bulkUpload.uploadError"));
+ }
+
+ } catch (error) {
+ console.error("[V3 Dialog] 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")} (V3)</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"
+ />
+ <label
+ htmlFor="b4-file-upload-v3"
+ className="flex flex-col items-center justify-center cursor-pointer"
+ >
+ <FolderOpen
+ className={`h-12 w-12 mb-3 transition-colors ${
+ isDragging ? "text-primary" : "text-muted-foreground"
+ }`}
+ />
+ <p
+ className={`text-sm transition-colors ${
+ isDragging
+ ? "text-primary font-medium"
+ : "text-muted-foreground"
+ }`}
+ >
+ {isDragging
+ ? t("bulkUpload.fileDropHere")
+ : t("bulkUpload.fileSelectArea")}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {t("bulkUpload.fileTypes")}
+ </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"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="text-sm truncate">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ {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 Progress */}
+ {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">{t("bulkUpload.uploading")}</h3>
+ <p className="text-sm text-muted-foreground">
+ {t("bulkUpload.uploadingWait")}
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>{t("bulkUpload.uploadProgress")}</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} className="h-2" />
+ </div>
+
+ {fileProgresses.length > 0 && (
+ <div className="border rounded-lg p-4 max-h-96 overflow-y-auto">
+ <FileUploadProgressList fileProgresses={fileProgresses} />
+ </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">{t("bulkUpload.uploadComplete")}</h3>
+ <p className="text-sm text-muted-foreground">
+ {t("bulkUpload.uploadSuccessMessage", { count: uploadResult.successCount })}
+ </p>
+ </div>
+
+ {uploadResult.failCount && uploadResult.failCount > 0 && (
+ <div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
+ <p className="text-sm text-yellow-800 dark:text-yellow-200">
+ {t("bulkUpload.uploadFailMessage", { count: uploadResult.failCount })}
+ </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") {
+ // If canceled during validation view (and not proceeding to upload), go back to file selection or close?
+ // Usually just close the validation dialog allows user to fix files in the main dialog,
+ // but here the main dialog is in "validation" state which is just a loader.
+ // So we should reset main dialog to "files" step.
+ setCurrentStep("files");
+ }
+ }}
+ validationResults={validationResults}
+ onConfirmUpload={handleConfirmUpload}
+ isUploading={isUploading}
+ />
+ </>
+ );
+}
+
diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx
index b7b25fca..64e67b8c 100644
--- a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx
+++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx
@@ -440,7 +440,12 @@ export function B4BulkUploadDialog({
UploadId: uploadId,
};
- await saveB4MappingBatch([mappingSaveItem], userId);
+ await saveB4MappingBatch([mappingSaveItem], {
+ userId,
+ userName,
+ vendorCode,
+ email: userEmail,
+ });
console.log(`[B4 업로드] 그룹 ${groupKey} 매핑 정보 저장 완료`);
diff --git a/lib/dolce/dialogs/detail-drawing-dialog.tsx b/lib/dolce/dialogs/detail-drawing-dialog.tsx
index afe4a4c2..99154296 100644
--- a/lib/dolce/dialogs/detail-drawing-dialog.tsx
+++ b/lib/dolce/dialogs/detail-drawing-dialog.tsx
@@ -59,6 +59,7 @@ export function DetailDrawingDialog({
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingDetailDrawing, setEditingDetailDrawing] = useState<DetailDwgReceiptItem | null>(null);
const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false);
+ const [downloadingFileId, setDownloadingFileId] = useState<string | null>(null);
// 상세도면 목록 로드
const loadDetailDrawings = useCallback(async () => {
@@ -126,6 +127,7 @@ export function DetailDrawingDialog({
const handleDownload = async (file: FileInfoItem) => {
try {
+ setDownloadingFileId(file.FileId);
toast.info(t("detailDialog.downloadPreparing"));
// 파일 생성자의 userId를 사용하여 다운로드
@@ -159,6 +161,8 @@ export function DetailDrawingDialog({
} catch (error) {
console.error("파일 다운로드 실패:", error);
toast.error(t("detailDialog.downloadError"));
+ } finally {
+ setDownloadingFileId(null);
}
};
@@ -187,7 +191,7 @@ export function DetailDrawingDialog({
loadFiles();
};
- const fileColumns = createFileListColumns({ onDownload: handleDownload, lng });
+ const fileColumns = createFileListColumns({ onDownload: handleDownload, lng, downloadingFileId });
// RegisterId + UploadId 조합으로 고유 ID 생성
const getDetailDrawingId = (detail: DetailDwgReceiptItem) => {
diff --git a/lib/dolce/table/detail-drawing-columns.tsx b/lib/dolce/table/detail-drawing-columns.tsx
index 747173af..215a0cff 100644
--- a/lib/dolce/table/detail-drawing-columns.tsx
+++ b/lib/dolce/table/detail-drawing-columns.tsx
@@ -13,54 +13,6 @@ import {
import { Button } from "@/components/ui/button";
import { Edit } from "lucide-react";
-// DOLCE API ENM 필드가 제대로 번역되지 않아 직접 매핑
-const DRAWING_USAGE_MAP: Record<string, { ko: string; en: string }> = {
- APP: { ko: "승인용", en: "Approval" },
- WOR: { ko: "작업용", en: "Working" },
- // 참조용은 eVCP에서 사용하지 않으나 추가해둠
- REF: { ko: "참조용", en: "Reference" },
- REC: { ko: "입수용", en: "GTT→SHI" },
- SUB: { ko: "제출용", en: "SHI→GTT" },
- CMT: { ko: "코멘트", en: "Comment" },
-};
-
-const REGISTER_KIND_MAP: Record<string, { ko: string; en: string }> = {
- APPR: { ko: "승인 제출용 도면(Full)", en: "For Approval(Full)" },
- APPP: { ko: "승인 제출용 도면(Partial)", en: "For Approval(Partial)" },
- WORK: { ko: "작업용 입수도면(Full)", en: "For Working(Full)" },
- WORP: { ko: "작업용 입수도면(Partial)", en: "For Working(Partial)" },
- RECW: { ko: "Working 도면입수(GTT→SHI)", en: "Working Dwg(GTT→SHI)" },
- RECP: { ko: "Pre. 도면입수(GTT→SHI)", en: "Pre. Dwg(GTT→SHI)" },
- GSUB: { ko: "SHI 입력 문서 (SHI→GTT)", en: "SHI Input Document(SHI→GTT)" },
- CMTQ: { ko: "Comment-TQ", en: "Comment-TQ" },
- CMTM: { ko: "MARK-UP", en: "MARK-UP" },
- // FMEA는 미사용 코드
- "FMEA-R1": { ko: "FMEA 1st Receipt", en: "FMEA 1st Receipt" },
- "FMEA-R2": { ko: "FMEA 2nd Receipt", en: "FMEA 2nd Receipt" },
- "FMEA-1": { ko: "FMEA 1st Submission", en: "FMEA 1st Submission" },
- "FMEA-2": { ko: "FMEA 2nd Submission", en: "FMEA 2nd Submission" },
- // 참조용 도면도 미사용 코드, 번역 받아야 적용 가능
- PREF: { ko: "본선용 참조도면", en: "본선용 참조도면" },
- RREF: { ko: "실적선 참조도면", en: "실적선 참조도면" },
- "PREP-P": { ko: "본선용 참조도면(Partial)", en: "본선용 참조도면(Partial)" },
- "RREP-P": { ko: "실적선 참조도면(Partial)", en: "실적선 참조도면(Partial)" },
-};
-
-// 카테고리는 API에서 ENM이 제공되는 것으로 가정 (필요시 추가)
-const translateDrawingUsage = (code: string | null, lng: string): string => {
- if (!code) return "";
- const mapped = DRAWING_USAGE_MAP[code];
- if (!mapped) return code;
- return lng === "en" ? mapped.en : mapped.ko;
-};
-
-const translateRegisterKind = (code: string | null, lng: string): string => {
- if (!code) return "";
- const mapped = REGISTER_KIND_MAP[code];
- if (!mapped) return code;
- return lng === "en" ? mapped.en : mapped.ko;
-};
-
// Comment Dialog Component
function CommentDialog({ comment }: { comment: string }) {
const [open, setOpen] = useState(false);
@@ -99,9 +51,14 @@ export function createDetailDrawingColumns(
{
accessorKey: "RegisterSerialNo",
header: t("detailDrawing.columns.serialNo"),
- minSize: 80,
+ minSize: 120,
cell: ({ row }) => {
- return <div className="text-center">{row.getValue("RegisterSerialNo")}</div>;
+ const val = row.getValue("RegisterSerialNo") as number;
+ const status = row.getValue("Status") as string;
+ if (val === 0 || status === "EVCP Saved") {
+ return <div className="text-center">-</div>;
+ }
+ return <div className="text-center">{val}</div>;
},
},
{
@@ -125,10 +82,8 @@ export function createDetailDrawingColumns(
header: t("detailDrawing.columns.category"),
minSize: 120,
cell: ({ row }) => {
- const categoryENM = row.getValue("CategoryENM") as string;
- const categoryNM = row.original.CategoryNM;
- // 영어인 경우 ENM, 한국어인 경우 NM 사용
- return <div>{lng === "en" ? (categoryENM || categoryNM) : (categoryNM || categoryENM)}</div>;
+ // 항상 CategoryENM 필드를 보여줌
+ return <div>{row.getValue("CategoryENM")}</div>;
},
},
// RegisterKind 로 DrawingUsage를 알 수 있으므로 주석 처리
@@ -144,14 +99,12 @@ export function createDetailDrawingColumns(
// },
// },
{
- accessorKey: "RegisterKind",
+ accessorKey: "RegisterKindENM",
header: t("detailDrawing.columns.registerKind"),
minSize: 180,
cell: ({ row }) => {
- // API 응답의 RegisterKind는 코드값이므로 직접 매핑하여 번역
- const kindCode = row.getValue("RegisterKind") as string;
- const translated = translateRegisterKind(kindCode, lng);
- return <div>{translated}</div>;
+ // 항상 RegisterKindENM 필드를 보여줌
+ return <div>{row.getValue("RegisterKindENM") || row.original.RegisterKind}</div>;
},
},
{
@@ -161,6 +114,11 @@ export function createDetailDrawingColumns(
cell: ({ row }) => {
const userENM = row.original.CreateUserENM;
const userNM = row.getValue("CreateUserNM") as string;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const localUserName = (row.original as any).userName;
+ if (localUserName) return <div>{localUserName}</div>;
+
return <div>{lng === "en" ? (userENM || userNM) : (userNM || userENM)}</div>;
},
},
@@ -193,12 +151,18 @@ export function createDetailDrawingColumns(
const isEditable = status === "Submitted";
return (
- <div className="flex items-center justify-center">
+ <div
+ className="flex items-center justify-center"
+ onClick={(e) => e.stopPropagation()}
+ >
<Button
variant="ghost"
size="sm"
disabled={!isEditable || !onEdit}
- onClick={() => onEdit && onEdit(row.original)}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (onEdit) onEdit(row.original);
+ }}
title={!isEditable ? t("editDetailDialog.statusSubmittedOnly") : t("editDetailDialog.editButton")}
>
<Edit className="h-4 w-4" />
diff --git a/lib/dolce/table/drawing-list-table-v2.tsx b/lib/dolce/table/drawing-list-table-v2.tsx
index 2ee80f11..420ed672 100644
--- a/lib/dolce/table/drawing-list-table-v2.tsx
+++ b/lib/dolce/table/drawing-list-table-v2.tsx
@@ -30,7 +30,7 @@ import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from "luci
// 도면 데이터의 기본 인터페이스
interface DrawingData {
- RegisterGroupId?: string | null;
+ RegisterGroupId?: string | number | null;
DrawingNo?: string | null;
Discipline?: string | null;
CreateDt?: string | Date | null;
diff --git a/lib/dolce/table/file-list-columns.tsx b/lib/dolce/table/file-list-columns.tsx
index 36a579a3..3018e240 100644
--- a/lib/dolce/table/file-list-columns.tsx
+++ b/lib/dolce/table/file-list-columns.tsx
@@ -3,23 +3,30 @@
import { ColumnDef } from "@tanstack/react-table";
import { FileInfoItem } from "../actions";
import { Button } from "@/components/ui/button";
-import { Download } from "lucide-react";
+import { Download, Trash2, Loader2 } from "lucide-react";
import { formatDolceDateTime } from "../utils/date-formatter";
interface FileListColumnsProps {
onDownload: (file: FileInfoItem) => void;
+ onDelete?: (file: FileInfoItem) => void;
lng?: string;
+ downloadingFileId?: string | null;
}
export const createFileListColumns = ({
onDownload,
+ onDelete,
lng = "ko",
+ downloadingFileId,
}: FileListColumnsProps): ColumnDef<FileInfoItem>[] => [
{
accessorKey: "FileSeq",
header: lng === "ko" ? "순번" : "No.",
minSize: 80,
cell: ({ row }) => {
+ if (row.original.FileServerId === "LOCAL") {
+ return <div className="text-center">-</div>;
+ }
return <div className="text-center">{row.getValue("FileSeq")}</div>;
},
},
@@ -55,18 +62,44 @@ export const createFileListColumns = ({
},
{
id: "actions",
- header: lng === "ko" ? "다운로드" : "Download",
- minSize: 120,
+ header: "Action",
+ minSize: 160,
cell: ({ row }) => {
+ const isLocal = row.original.FileServerId === "LOCAL";
+ const isDownloading = downloadingFileId === row.original.FileId;
+
return (
- <Button
- variant="outline"
- size="sm"
- onClick={() => onDownload(row.original)}
- >
- <Download className="h-4 w-4 mr-2" />
- {lng === "ko" ? "다운로드" : "Download"}
- </Button>
+ <div className="flex gap-2 items-center justify-center">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={isDownloading}
+ onClick={(e) => {
+ e.stopPropagation();
+ onDownload(row.original);
+ }}
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4 mr-2" />
+ )}
+ {lng === "ko" ? "다운로드" : "Download"}
+ </Button>
+ {isLocal && onDelete && (
+ <Button
+ variant="destructive"
+ size="sm"
+ disabled={isDownloading}
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(row.original);
+ }}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
);
},
},
diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx
index 093fc10c..da29c910 100644
--- a/lib/dolce/table/gtt-drawing-list-columns.tsx
+++ b/lib/dolce/table/gtt-drawing-list-columns.tsx
@@ -2,7 +2,6 @@
import { ColumnDef } from "@tanstack/react-table";
import { GttDwgReceiptItem } from "../actions";
-import { translateDrawingMoveGbn } from "../utils/code-translator";
import { formatDolceDateYYYYMMDD, formatDolceDateTime } from "../utils/date-formatter";
// Document Type 필터
@@ -52,12 +51,27 @@ export function createGttDrawingListColumns({
},
},
{
- accessorKey: "DrawingMoveGbn",
+ accessorKey: "CategoryENM",
header: t("drawingList.columns.category"),
minSize: 120,
cell: ({ row }) => {
- const value = row.getValue("DrawingMoveGbn") as string;
- return <div>{translateDrawingMoveGbn(value, lng)}</div>;
+ return <div>{row.getValue("CategoryENM")}</div>;
+ },
+ },
+ {
+ accessorKey: "DrawingUsageENM",
+ header: t("detailDrawing.columns.drawingUsage"),
+ minSize: 120,
+ cell: ({ row }) => {
+ return <div>{row.getValue("DrawingUsageENM")}</div>;
+ },
+ },
+ {
+ accessorKey: "RegisterKindENM",
+ header: t("detailDrawing.columns.registerKind"),
+ minSize: 180,
+ cell: ({ row }) => {
+ return <div>{row.getValue("RegisterKindENM")}</div>;
},
},
];
@@ -168,4 +182,3 @@ export function createGttDrawingListColumns({
return [...baseColumns, ...dateColumns, ...endColumns];
}
-
diff --git a/lib/dolce/utils/code-translator.ts b/lib/dolce/utils/code-translator.ts
index 19cb4217..f55d0691 100644
--- a/lib/dolce/utils/code-translator.ts
+++ b/lib/dolce/utils/code-translator.ts
@@ -23,6 +23,10 @@ export function translateB3DrawingUsage(code: string, lng: string): string {
ko: "작업용",
en: "Working",
},
+ "CMT": {
+ ko: "Comment",
+ en: "Comment",
+ },
};
return translations[code]?.[lng] || code;
@@ -47,6 +51,10 @@ export function translateB3RegisterKind(code: string, lng: string): string {
ko: "작업용 입수도면(Partial)",
en: "For Working(Partial)",
},
+ "CMTV": {
+ ko: "Comment",
+ en: "Comment",
+ },
};
return translations[code]?.[lng] || code;
@@ -63,6 +71,10 @@ export function translateB4DrawingUsage(code: string, lng: string): string {
ko: "제출용",
en: "SHI→GTT",
},
+ "CMT": {
+ ko: "Comment",
+ en: "Comment",
+ },
};
return translations[code]?.[lng] || code;
@@ -87,6 +99,14 @@ export function translateB4RegisterKind(code: string, lng: string): string {
ko: "Pre. 제출용(SHI→GTT)",
en: "Pre. Submission(SHI→GTT)",
},
+ "CMTM": {
+ ko: "Mark-Up",
+ en: "Mark-Up",
+ },
+ "CMTQ": {
+ ko: "TQ",
+ en: "TQ",
+ },
};
return translations[code]?.[lng] || code;
@@ -167,6 +187,7 @@ export function getB3DrawingUsageOptions(lng: string) {
return [
{ value: "APP", label: translateB3DrawingUsage("APP", lng) },
{ value: "WOR", label: translateB3DrawingUsage("WOR", lng) },
+ { value: "CMT", label: translateB3DrawingUsage("CMT", lng) },
];
}
@@ -176,6 +197,10 @@ export function getB3RegisterKindOptions(drawingUsage: string, lng: string) {
{ value: "APPR", label: translateB3RegisterKind("APPR", lng) },
{ value: "APPP", label: translateB3RegisterKind("APPP", lng) },
];
+ } else if (drawingUsage === "CMT") {
+ return [
+ { value: "CMTV", label: translateB3RegisterKind("CMTV", lng) },
+ ];
} else if (drawingUsage === "WOR") {
return [
{ value: "WORK", label: translateB3RegisterKind("WORK", lng) },
@@ -188,6 +213,7 @@ export function getB3RegisterKindOptions(drawingUsage: string, lng: string) {
export function getB4DrawingUsageOptions(lng: string) {
return [
{ value: "REC", label: translateB4DrawingUsage("REC", lng) },
+ { value: "CMT", label: translateB4DrawingUsage("CMT", lng) },
];
}
@@ -197,6 +223,12 @@ export function getB4RegisterKindOptions(drawingUsage: string, lng: string) {
{ value: "RECP", label: translateB4RegisterKind("RECP", lng) },
{ value: "RECW", label: translateB4RegisterKind("RECW", lng) },
];
+ } else if
+ (drawingUsage === "CMT") {
+ return [
+ { value: "CMTM", label: translateB4RegisterKind("CMTM", lng) },
+ { value: "CMTQ", label: translateB4RegisterKind("CMTQ", lng) },
+ ];
} else if (drawingUsage === "SUB") {
return [
{ value: "SUBP", label: translateB4RegisterKind("SUBP", lng) },
diff --git a/lib/dolce/utils/date-formatter.ts b/lib/dolce/utils/date-formatter.ts
index 83e78b0d..ebf9653a 100644
--- a/lib/dolce/utils/date-formatter.ts
+++ b/lib/dolce/utils/date-formatter.ts
@@ -6,12 +6,30 @@
*/
import { formatSwpDate } from "@/lib/swp/utils";
+import { format, parseISO, isValid } from "date-fns";
/**
* SWP와 동일한 방식
+ * Postgres Date 타입(ISO String) 처리 추가
*/
export function formatDolceDateTime(dateStr: string | null): string {
if (!dateStr) return "-";
+
+ // ISO string check (e.g. "2023-11-27T10:20:30.000Z" or similar)
+ if (dateStr.includes("-") && (dateStr.includes("T") || dateStr.includes(":"))) {
+ try {
+ const date = parseISO(dateStr);
+ if (isValid(date)) {
+ // Requested format: YYYY-MM-DD AM/PM HH:mm:ss
+ // date-fns format: yyyy-MM-dd a hh:mm:ss
+ return format(date, "yyyy-MM-dd a hh:mm:ss");
+ }
+ } catch (e) {
+ // ignore parse error, fallback to default
+ console.error("Date parse error", e);
+ }
+ }
+
return formatSwpDate(dateStr);
}