import React, { useEffect, useRef, useState } from 'react'; import { FileSpreadsheet, PlayCircle, Loader2, CheckCircle2, XCircle, AlertTriangle, ArrowRight, Table2, Layers, X, RotateCcw, Ban, Clock, MinusCircle, } from 'lucide-react'; import { api } from '../api/client'; /* * Several store spreadsheets -> one batch -> the same 11-stage pipeline. * * The multi-file sibling of StoreCatalogPanel, which is left exactly as it * was. What is different here is the unit of work: five files are ONE batch * with one id, so the question a colleague actually asks - "did the drop * land?" - has one answer instead of five. * * Two things are deliberately not like the single-file panel: * * - the enrichment toggles default OFF. At one file, image search is a * considered trade; at twenty it is thousands of outbound requests on a * box that is also serving the API. * - a file that fails does not fail the batch. The per-file table is the * point of the screen, and a `partial` batch is rendered as its own state * so four-of-five landing never reads as plain success. */ const POLL_MS = 3000; const TERMINAL = new Set(['done', 'failed', 'partial', 'cancelled']); const FILE_TONE = { done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20', failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30', running: 'bg-amber-500/10 text-amber-700 border-amber-500/20', queued: 'bg-white text-slate-500 border-ink-900/10', cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10', }; const FILE_ICON = { done: CheckCircle2, failed: XCircle, running: Loader2, queued: Clock, cancelled: MinusCircle, }; function fmtBytes(n) { if (!n) return ''; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } export function BatchCatalogPanel({ adoptBatch = null }) { const [files, setFiles] = useState([]); const [preview, setPreview] = useState(null); const [previewing, setPreviewing] = useState(false); const [starting, setStarting] = useState(false); const [error, setError] = useState(''); const [batch, setBatch] = useState(null); const [useLlm, setUseLlm] = useState(false); const [fetchImages, setFetchImages] = useState(false); const pollRef = useRef(null); const batchIdRef = useRef(null); // A batch started from the Review Inbox tab. Without this, switching tabs // after pressing "Start selected" would land on an empty panel and the run // would look like it had not happened. useEffect(() => { if (!adoptBatch?.batch_id) return; setBatch(adoptBatch); setFiles([]); setPreview(null); setError(''); if (!TERMINAL.has(adoptBatch.status)) batchIdRef.current = adoptBatch.batch_id; }, [adoptBatch]); // One interval for the component's lifetime, reading the id from a ref // rather than closing over it. Same reasoning as StoreCatalogPanel: starting // a second batch must not leave an older interval polling the previous one. useEffect(() => { pollRef.current = setInterval(async () => { const id = batchIdRef.current; if (!id) return; try { const next = await api.getCatalogBatch(id); setBatch(next); // Stop polling once there is nothing left to watch, so an admin tab // left open overnight is not hitting the API forever. if (TERMINAL.has(next.status)) batchIdRef.current = null; } catch { /* a dropped poll is not worth surfacing; the next tick retries */ } }, POLL_MS); return () => clearInterval(pollRef.current); }, []); const onPick = (e) => { setFiles(Array.from(e.target.files || [])); setPreview(null); setBatch(null); setError(''); // Clearing the input means re-picking the same files fires onChange again. e.target.value = ''; }; const removeAt = (index) => { setFiles((current) => current.filter((_, i) => i !== index)); setPreview(null); }; const handlePreview = async () => { if (!files.length) return; setPreviewing(true); setError(''); try { setPreview(await api.previewCatalogBatch(files)); } catch (err) { setError(err?.message || 'Could not read those files.'); } finally { setPreviewing(false); } }; const handleIngest = async () => { if (!files.length) return; setStarting(true); setError(''); try { const started = await api.ingestCatalogBatch(files, { use_llm: useLlm, fetch_images: fetchImages, }); batchIdRef.current = started.batch_id; setBatch(started); } catch (err) { setError(err?.message || 'Could not start the batch.'); } finally { setStarting(false); } }; const handleResume = async () => { if (!batch) return; setError(''); try { const resumed = await api.resumeCatalogBatch(batch.batch_id); batchIdRef.current = resumed.batch_id; setBatch(resumed); } catch (err) { setError(err?.message || 'Could not resume the batch.'); } }; const handleCancel = async () => { if (!batch) return; setError(''); try { setBatch(await api.cancelCatalogBatch(batch.batch_id)); } catch (err) { setError(err?.message || 'Could not cancel the batch.'); } }; const running = batch && !TERMINAL.has(batch.status); const totals = batch?.totals; return (
{/* ---- 1. File selection --------------------------------------- */}

Batch Catalog Ingestion

Upload one or many store product lists at once (.xlsx, .xls, .csv or .tsv). They run as a single batch through the same 11-stage pipeline — images, price bands, pack sizes, SKU, barcode, HSN/GST and FSSAI — then validated and written into the matching brand tables. Files are processed one at a time; a file that fails is reported and the rest carry on. Columns a sheet already fills are never overwritten.

{/* Enrichment toggles. Off by default, and the cost is stated rather than left for someone to discover on a 20-file run. */}
{files.length > 0 && ( )} {error && (
{error}
)}
{/* ---- 2. Per-file column mapping preview ---------------------- */} {preview && (

Column mapping — {preview.files_ok}/{preview.files_total} file(s) readable, {' '}{preview.rows_total} row(s) total

Nothing has been ingested yet. Check the mapping, then start the batch.

{preview.files.map((file) => (
{file.filename} {file.ok ? ( {file.rows_total} rows ) : ( will be skipped )}
{!file.ok && (

{file.error}

)} {file.ok && ( <>

{file.brand_column_present ? 'Brand column found.' : 'No brand column; the brand will be inferred from each product name.'}

{Object.entries(file.recognised_columns).map(([field, column]) => (
{column} {field}
))}
{file.unrecognised_columns?.length > 0 && (

Ignored: {file.unrecognised_columns.join(', ')}

)} )}
))}
)} {/* ---- 3. Live batch tracker ----------------------------------- */} {batch && (

{batch.status === 'done' && } {batch.status === 'partial' && } {batch.status === 'failed' && } {batch.status === 'interrupted' && } {running && } Batch — {batch.files_done}/{batch.files_total} file(s) done

{batch.status === 'interrupted' && ( )} {running && ( )} {batch.status}

{batch.batch_id}

{batch.status === 'partial' && (
{batch.files_done} file(s) landed and {batch.files_failed} failed. Check the failed rows below — the rest of the batch was written.
)}
{batch.detail &&

{batch.detail}

} {/* Per-file steps */}
    {batch.files.map((file) => { const Icon = FILE_ICON[file.status] || Clock; return (
  • {file.filename} {file.status}
    {file.stage_index > 0 && file.status === 'running' && (

    Stage {file.stage_index}/{file.total_stages} — {file.stage_name} {' · '}{file.rows_done}/{file.rows_total} rows

    )} {file.detail && (

    {file.detail}

    )}
  • ); })}
{/* Batch totals, once anything has landed */} {totals && (batch.files_done > 0 || batch.files_failed > 0) && ( <>
{batch.brands?.length > 0 && (

Brands touched:{' '} {batch.brands.join(', ')}

)} (f.result?.rejections || []).map( (r) => `${f.filename} · ${r.product_name} ${r.size || ''} — ${r.reason}` ) )} /> (f.result?.errors || []).map( (e) => `${f.filename} · row ${e.row}: ${e.product_name || '(unnamed)'} — ${e.error}` ) )} /> (f.result?.warnings || []).map((w) => `${f.filename} · ${w}`) )} /> )}
)}
); } const TONES = { emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700', blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700', slate: 'border-ink-900/10 bg-white text-slate-600', maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600', amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700', }; function Stat({ label, value, tone }) { return (

{label}

{value ?? 0}

); } function IssueList({ title, items }) { if (!items || items.length === 0) return null; return (

{title} ({items.length})

); }