summaryrefslogtreecommitdiff
path: root/lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx
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 /lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx
parent530775fb70ea24471b122e1c308cca3a0cd591b1 (diff)
(김준회) dolce: b4 일괄업로드건 요구사항대로 수정, DrawingUsage-Comment 건들도 상세도면 생성 가능하도록 변경
(MatchBatchFileDwg/Edit 사용하도록 변경, 호출시 Category 및 status 값 하드코딩해 넣어주도록 변경)
Diffstat (limited to 'lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx')
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog-v3.tsx690
1 files changed, 690 insertions, 0 deletions
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}
+ />
+ </>
+ );
+}
+