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 (
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.
Nothing has been ingested yet. Check the mapping, then start the batch.
{file.brand_column_present ? 'Brand column found.' : 'No brand column; the brand will be inferred from each product name.'}
{batch.batch_id}
{batch.status === 'partial' && ({batch.detail}
} {/* Per-file steps */}Stage {file.stage_index}/{file.total_stages} — {file.stage_name} {' · '}{file.rows_done}/{file.rows_total} rows
)} {file.detail && ({file.detail}
)}Brands touched:{' '} {batch.brands.join(', ')}
)}{label}
{value ?? 0}