summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-26 14:15:23 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-26 14:15:23 +0900
commit3131dce1f0c90d960f53bd384045b41023064bc4 (patch)
tree72d8b189deb3b72f706c45c63fd4cb039ddbee4c
parent530775fb70ea24471b122e1c308cca3a0cd591b1 (diff)
(김준회) dolce: b4 일괄업로드건 요구사항대로 수정, DrawingUsage-Comment 건들도 상세도면 생성 가능하도록 변경
(MatchBatchFileDwg/Edit 사용하도록 변경, 호출시 Category 및 status 값 하드코딩해 넣어주도록 변경)
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx8
-rw-r--r--lib/dolce/actions.ts14
-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/utils/code-translator.ts32
6 files changed, 783 insertions, 45 deletions
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/lib/dolce/actions.ts b/lib/dolce/actions.ts
index 8c5dfa1b..cd276fac 100644
--- a/lib/dolce/actions.ts
+++ b/lib/dolce/actions.ts
@@ -145,7 +145,7 @@ export interface DetailDwgEditRequest {
RegisterGroupId: number;
RegisterSerialNo: number;
RegisterKind: string;
- DrawingRevNo: string;
+ DrawingRevNo: string | null;
Category: string;
Receiver: string | null;
Manager: string;
@@ -716,14 +716,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;
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/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) },