From c569697d0da1b081c2413857010c69fd8fdab3cc Mon Sep 17 00:00:00 2001 From: ilhom Date: Mon, 29 Jun 2026 11:49:28 +0700 Subject: [PATCH] feat(frontend): chunked routing import + fix route_sequences key column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SheetGroupConfig.keyColumn override: each sheet group can specify its own key column; worker uses groupCfg.keyColumn ?? msg.keyColumn - BULK_ROUTING_CONFIG: route_sequences and route_rms now use "route_head_legacy_product_id" (the actual column name in the Excel export) instead of the incorrect "route_head_legacy_id" - BulkImportDialog: adds chunked import path (parsing → ready → chunked_importing → chunked_done/failed) alongside the existing validate flow; files > 5 MB only show Chunked Import button Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../finance/costing/bulk-import-dialog.tsx | 285 ++++++++++++++++-- src/lib/excel/chunked-importer.ts | 19 +- src/lib/excel/chunked-importer.worker.ts | 11 +- 3 files changed, 287 insertions(+), 28 deletions(-) diff --git a/src/components/finance/costing/bulk-import-dialog.tsx b/src/components/finance/costing/bulk-import-dialog.tsx index 7a0ef73..c2241a0 100644 --- a/src/components/finance/costing/bulk-import-dialog.tsx +++ b/src/components/finance/costing/bulk-import-dialog.tsx @@ -10,13 +10,16 @@ import { FileSpreadsheet, Loader2, Upload, + XCircle, } from "lucide-react" +import { useRouter } from "next/navigation" import { toast } from "sonner" import { Dialog } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" import { Table, TableBody, @@ -37,18 +40,42 @@ import { downloadBulkProductRoutingTemplate, exportBulkProductRouting, validateBulkProductRoutingFile, + getImportJob, } from "@/services/finance/cost-import-api" -import type { BulkSheetValidationResult, BulkValidationResult } from "@/types/finance/cost-import" +import { + chunkExcelFile, + BULK_ROUTING_CONFIG, + type ChunkResult, +} from "@/lib/excel/chunked-importer" +import type { BulkValidationResult } from "@/types/finance/cost-import" export interface BulkImportDialogProps { open: boolean onOpenChange: (open: boolean) => void } -type Step = "upload" | "validating" | "validated" | "submitting" | "done" +// "validate" flow: upload → validating → validated → submitting → done +// "chunked" flow: upload → parsing → ready → chunked_importing → chunked_done | chunked_failed +type Step = + | "upload" + | "validating" | "validated" | "submitting" | "done" + | "parsing" | "ready" | "chunked_importing" | "chunked_done" | "chunked_failed" + +interface ChunkStatus { + index: number + keyCount: number + status: "pending" | "uploading" | "polling" | "done" | "partial" | "failed" +} + +const VALIDATE_SIZE_LIMIT = 5 * 1024 * 1024 // 5 MB — matches server-side validate limit +const POLL_MS = 3_000 +const MAX_POLL_ERRORS = 5 export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) { const fileRef = useRef(null) + const router = useRouter() + const abortRef = useRef(false) + const [file, setFile] = useState(null) const [step, setStep] = useState("upload") const [validation, setValidation] = useState(null) @@ -56,17 +83,30 @@ export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) const [jobId, setJobId] = useState(null) const [templateLoading, setTemplateLoading] = useState(false) - // Stable reset function — called from handleClose and when open changes + // Chunked state + const [parseStage, setParseStage] = useState("") + const [parsed, setParsed] = useState(null) + const [chunks, setChunks] = useState([]) + const [currentIdx, setCurrentIdx] = useState(0) + const [totalInserted, setTotalInserted] = useState(0) + const [failedChunks, setFailedChunks] = useState([]) + function resetState() { + abortRef.current = false setFile(null) setStep("upload") setValidation(null) setExpandedSheets(new Set()) setJobId(null) + setParsed(null) + setChunks([]) + setCurrentIdx(0) + setTotalInserted(0) + setFailedChunks([]) + setParseStage("") if (fileRef.current) fileRef.current.value = "" } - // Reset all state when dialog opens // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (open) resetState() }, [open]) @@ -115,7 +155,77 @@ export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) } } + // ── Chunked import flow ──────────────────────────────────────────────────── + async function handleChunkedImport() { + if (!file) return + setStep("parsing") + try { + const result = await chunkExcelFile(file, BULK_ROUTING_CONFIG, setParseStage) + setParsed(result) + setChunks(result.chunks.map((c) => ({ index: c.index, keyCount: c.keyCount, status: "pending" }))) + setStep("ready") + } catch (e) { + toast.error(`Parse failed: ${String(e)}`) + setStep("upload") + } + } + + async function startChunkedImport(fromChunk = 0) { + if (!parsed) return + abortRef.current = false + setStep("chunked_importing") + const failed: number[] = [] + + for (let i = fromChunk; i < parsed.chunks.length; i++) { + if (abortRef.current) break + setCurrentIdx(i) + updateChunk(i, "uploading") + + const chunk = parsed.chunks[i] + const chunkFile = new File([chunk.blob], chunk.fileName, { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + + let cJobId: number + try { + const res = await bulkImportProductMasterRouting(chunkFile, "update") + cJobId = res.jobId + updateChunk(i, "polling") + } catch (e) { + toast.error(`Chunk ${i + 1} upload failed: ${String(e)}`) + updateChunk(i, "failed") + failed.push(i) + continue + } + + let pollErrors = 0 + let done = false + while (!abortRef.current && !done) { + await new Promise((r) => setTimeout(r, POLL_MS)) + try { + const job = await getImportJob(cJobId) + if (job.status === "DONE" || job.status === "PARTIAL" || job.status === "FAILED") { + updateChunk(i, job.status !== "FAILED" ? (job.status === "DONE" ? "done" : "partial") : "failed") + if (job.status !== "FAILED") setTotalInserted((n) => n + (job.success ?? 0)) + else failed.push(i) + done = true + } + } catch { + if (++pollErrors >= MAX_POLL_ERRORS) { updateChunk(i, "failed"); failed.push(i); done = true } + } + } + } + + setFailedChunks(failed) + setStep(failed.length === 0 ? "chunked_done" : "chunked_failed") + } + + function updateChunk(index: number, status: ChunkStatus["status"]) { + setChunks((prev) => prev.map((c) => (c.index === index ? { ...c, status } : c))) + } + function handleClose() { + abortRef.current = true resetState() onOpenChange(false) } @@ -137,6 +247,10 @@ export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) const isSubmitting = step === "submitting" const isDone = step === "done" const canImport = step === "validated" && !hasErrors && (validation?.isValid ?? false) + const isChunkedFlow = ["parsing","ready","chunked_importing","chunked_done","chunked_failed"].includes(step) + const fileTooLargeForValidate = (file?.size ?? 0) > VALIDATE_SIZE_LIMIT + const doneCount = chunks.filter((c) => c.status === "done" || c.status === "partial").length + const progressPct = parsed ? Math.round((doneCount / parsed.chunks.length) * 100) : 0 return ( @@ -311,35 +425,149 @@ export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) )} - {/* Done */} + {/* Done (single-shot) */} {isDone && jobId !== null && (
Import dijadwalkan sebagai Job #{jobId}. Notifikasi akan dikirim saat selesai.
)} + + {/* File too large warning */} + {file && step === "upload" && fileTooLargeForValidate && ( +
+ + + File {(file.size / 1024 / 1024).toFixed(1)} MB melebihi batas validasi 5 MB. + Gunakan Chunked Import untuk melanjutkan. + +
+ )} + + {/* Chunked: parsing */} + {step === "parsing" && ( +
+ +
+

Parsing file…

+

{parseStage}

+
+
+ )} + + {/* Chunked: ready */} + {step === "ready" && parsed && ( +
+

File parsed

+
+

Products

{parsed.totalKeys.toLocaleString()}

+

Total rows

{parsed.totalRows.toLocaleString()}

+

Chunks

{parsed.chunks.length}

+
+

+ ~{BULK_ROUTING_CONFIG.chunkSize} products per chunk · 6 sheets each +

+
+ + If routing references intermediate products from other chunks, those rows + will show as "missing product" warnings (non-fatal). +
+
+ )} + + {/* Chunked: importing */} + {step === "chunked_importing" && parsed && ( +
+
+ Chunk {currentIdx + 1} / {parsed.chunks.length} + {totalInserted.toLocaleString()} rows imported +
+ +
+
+ {chunks.map((c) => )} +
+
+
+ )} + + {/* Chunked: done */} + {step === "chunked_done" && ( +
+ +
+

+ Import complete — {totalInserted.toLocaleString()} rows imported +

+ +
+
+ )} + + {/* Chunked: failed */} + {step === "chunked_failed" && ( +
+
+ +
+

+ {failedChunks.length} chunk{failedChunks.length > 1 ? "s" : ""} failed + {totalInserted > 0 && ` — ${totalInserted.toLocaleString()} rows already imported`} +

+
+
+
+
+ {chunks.map((c) => )} +
+
+
+ )} - - {file && step === "upload" && ( - )} - {canImport && ( - + )} + + {/* Chunked flow buttons */} + {file && step === "upload" && ( + + )} + {step === "ready" && ( + + )} + {step === "chunked_importing" && ( + + )} + {step === "chunked_failed" && failedChunks.length > 0 && ( + )} @@ -382,5 +610,24 @@ export function BulkExportButton({ ) } -// Re-export BulkSheetValidationResult for any consumers that import from this module -export type { BulkSheetValidationResult, BulkValidationResult } +function RoutingChunkBadge({ chunk, current }: { chunk: ChunkStatus; current: boolean }) { + const color: Record = { + pending: "bg-muted text-muted-foreground", + uploading: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + polling: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200", + done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", + partial: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200", + failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200", + } + return ( + + {chunk.index + 1} + {current && (chunk.status === "uploading" || chunk.status === "polling") && ( + + )} + + ) +} + +export type { BulkValidationResult } diff --git a/src/lib/excel/chunked-importer.ts b/src/lib/excel/chunked-importer.ts index 79384d3..5873180 100644 --- a/src/lib/excel/chunked-importer.ts +++ b/src/lib/excel/chunked-importer.ts @@ -68,18 +68,23 @@ export const PARAMS_ONLY_CONFIG: ChunkerConfig = { chunkSize: 300, } -/** Bulk product + routing import (product_master + route sheets). */ +/** + * Bulk product + routing import (product_master + route sheets). + * + * route_sequences and route_rms use "route_head_legacy_product_id" as their + * key column (same value as legacy_oracle_sys_id — the root product ID). + */ export const BULK_ROUTING_CONFIG: ChunkerConfig = { sheetGroups: [ - { baseName: "product_master", outputName: "product_master" }, - { baseName: "product_parameters", outputName: "product_parameters" }, + { baseName: "product_master", outputName: "product_master" }, + { baseName: "product_parameters", outputName: "product_parameters" }, { baseName: "product_applicable_params", outputName: "product_applicable_params" }, - { baseName: "route_head", outputName: "route_head" }, - { baseName: "route_sequences", outputName: "route_sequences" }, - { baseName: "route_rms", outputName: "route_rms" }, + { baseName: "route_head", outputName: "route_head" }, + { baseName: "route_sequences", outputName: "route_sequences", keyColumn: "route_head_legacy_product_id" }, + { baseName: "route_rms", outputName: "route_rms", keyColumn: "route_head_legacy_product_id" }, ], keyColumn: "legacy_oracle_sys_id", - chunkSize: 200, // smaller — 6 sheets per product + chunkSize: 200, } // ── Core function ────────────────────────────────────────────────────────── diff --git a/src/lib/excel/chunked-importer.worker.ts b/src/lib/excel/chunked-importer.worker.ts index c1f03cf..03ea1d5 100644 --- a/src/lib/excel/chunked-importer.worker.ts +++ b/src/lib/excel/chunked-importer.worker.ts @@ -23,6 +23,12 @@ export interface SheetGroupConfig { baseName: string /** Sheet name written into each output chunk file. */ outputName: string + /** + * Per-sheet key column override. Falls back to StartMessage.keyColumn when omitted. + * Use when a sheet uses a different column name for the same logical key + * (e.g. route_sequences uses "route_head_legacy_product_id" instead of "legacy_oracle_sys_id"). + */ + keyColumn?: string } export interface StartMessage { @@ -137,11 +143,12 @@ async function handleStart(msg: StartMessage) { headers = (rows[0] as (string | null)[]).map((h) => h != null ? String(h).trim() : "" ) - keyColIdx = (headers as string[]).indexOf(msg.keyColumn) + const effectiveKeyCol = groupCfg.keyColumn ?? msg.keyColumn + keyColIdx = (headers as string[]).indexOf(effectiveKeyCol) if (keyColIdx === -1) { emit({ type: "error", - message: `Sheet "${matching[0]}" does not contain required key column "${msg.keyColumn}". Found: ${headers.filter(Boolean).join(", ")}`, + message: `Sheet "${matching[0]}" does not contain key column "${effectiveKeyCol}". Found: ${headers.filter(Boolean).join(", ")}`, }) return }