From 39ae51e291f04da21c98d2613fdef1c973519c42 Mon Sep 17 00:00:00 2001 From: sriram Date: Tue, 18 Aug 2026 16:55:31 +0530 Subject: [PATCH] frontend enrichment pipeline --- src/api/client.js | 13 ++ src/pages/AdminPage.jsx | 47 ++++-- src/pages/StoreCatalogPanel.jsx | 265 ++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 src/pages/StoreCatalogPanel.jsx diff --git a/src/api/client.js b/src/api/client.js index 89d6a65..1944b99 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -284,6 +284,19 @@ export const api = { body: JSON.stringify({ store_id, rules }), }), + // --- Admin: store-catalog ingestion (Excel -> 11-stage pipeline -> brand tables) --- + // See app/api/routers/store_catalog.py. `preview` parses only, so the + // operator can check the column mapping before paying for a full scrape. + previewStoreCatalog: (file) => upload('/api/admin/store-catalog/preview', file), + + // The two flags are query params, not form fields: the endpoint takes them + // as plain query arguments alongside the multipart body. + ingestStoreCatalog: (file, { use_llm = true, fetch_images = true } = {}) => + upload(`/api/admin/store-catalog/ingest${qs({ use_llm, fetch_images })}`, file), + + getStoreCatalogJob: (jobId) => + request(`/api/admin/store-catalog/jobs/${encodeURIComponent(jobId)}`), + // --- User workspace: single + batch product entry --- // Guarded by `add_product` / `upload_batch_products` - see // app/api/routers/user_products.py. diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 8692a45..0a3d21a 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -1,22 +1,27 @@ import React, { useEffect, useState } from 'react'; -import { Wrench, Database } from 'lucide-react'; +import { Wrench, Database, UploadCloud } from 'lucide-react'; import { api } from '../api/client'; import { NavigationHeader } from '../components/NavigationHeader'; +import { StoreCatalogPanel } from './StoreCatalogPanel'; /* - * The admin panel is the project overview and nothing else. + * The admin panel carries two views: the read-only project overview, and the + * store-catalog ingestion workspace. * - * It used to carry three more sub-tabs behind a local `activeTab` switch - - * train/test dataset upload, a stock-remaining discount allocator, and a - * trigger for the vector ingestion pipeline. They were retired from the UI; - * with one view left there is nothing to switch between, so the tab bar and - * its state went too. - * - * Only the UI was removed. The endpoints behind those panels are still - * registered and still work - ingestion in particular is reachable from + * It briefly carried neither tab bar nor second view - three earlier sub-tabs + * (train/test dataset upload, stock-remaining discount allocator, and a + * trigger for the brand ingestion pipeline) were retired from the UI, leaving + * only the overview. Their endpoints are all still registered and still work; + * brand ingestion in particular is reachable from * `python cli/ingest_brand.py ""` and from POST /api/catalog/generate. */ +const TABS = [ + { id: 'project', label: 'Project Overview Details', icon: Database }, + { id: 'ingest', label: 'Store Catalog Ingestion', icon: UploadCloud }, +]; + export function AdminPage() { + const [activeTab, setActiveTab] = useState('project'); const [projectDetails, setProjectDetails] = useState(null); const [loadingProject, setLoadingProject] = useState(true); @@ -40,11 +45,30 @@ export function AdminPage() {
+
+ {TABS.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {activeTab === 'ingest' && } + + {activeTab === 'project' && (

@@ -99,6 +123,7 @@ export function AdminPage() {

)}
+ )}
); diff --git a/src/pages/StoreCatalogPanel.jsx b/src/pages/StoreCatalogPanel.jsx new file mode 100644 index 0000000..2ada849 --- /dev/null +++ b/src/pages/StoreCatalogPanel.jsx @@ -0,0 +1,265 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + UploadCloud, FileSpreadsheet, PlayCircle, Loader2, CheckCircle2, + XCircle, AlertTriangle, ArrowRight, Table2, +} from 'lucide-react'; +import { api } from '../api/client'; + +/* + * Store spreadsheet -> 11-stage scraping pipeline -> brand tables. + * + * Two deliberate steps. "Preview" only parses the file and shows how each + * column was understood; the mapping from a store's own headers onto catalog + * fields is a guess, and discovering that "Item" was read as the description + * after a 2000-row scrape is expensive. "Start Ingestion" is the committing + * action, and from then on this polls the job for stage + row progress. + */ + +const POLL_MS = 3000; +const TERMINAL = new Set(['done', 'failed']); + +export function StoreCatalogPanel() { + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [previewing, setPreviewing] = useState(false); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(''); + const [job, setJob] = useState(null); + + const pollRef = useRef(null); + const jobIdRef = useRef(null); + + // One interval for the component's lifetime. It reads the job id from a ref + // rather than closing over it, so starting a second ingestion does not leave + // an older interval polling the previous job. + useEffect(() => { + pollRef.current = setInterval(async () => { + const id = jobIdRef.current; + if (!id) return; + try { + const next = await api.getStoreCatalogJob(id); + setJob(next); + if (TERMINAL.has(next.status)) jobIdRef.current = null; + } catch { + /* a dropped poll is not worth surfacing; the next tick retries */ + } + }, POLL_MS); + return () => clearInterval(pollRef.current); + }, []); + + const onPick = (e) => { + setFile(e.target.files?.[0] || null); + setPreview(null); + setJob(null); + setError(''); + }; + + const handlePreview = async () => { + if (!file) return; + setPreviewing(true); + setError(''); + try { + setPreview(await api.previewStoreCatalog(file)); + } catch (err) { + setError(err?.message || 'Could not read that file.'); + } finally { + setPreviewing(false); + } + }; + + const handleIngest = async () => { + if (!file) return; + setStarting(true); + setError(''); + try { + const started = await api.ingestStoreCatalog(file); + jobIdRef.current = started.job_id; + setJob(started); + } catch (err) { + setError(err?.message || 'Could not start the ingestion job.'); + } finally { + setStarting(false); + } + }; + + const running = job && !TERMINAL.has(job.status); + const result = job?.result; + + return ( +
+ {/* ---- 1. File selection --------------------------------------- */} +
+

+ Store Catalog Ingestion +

+

+ Upload a store's product list (.xlsx, .xls, .csv or .tsv). Every row is run through + the 11-stage scraping pipeline to fill in whatever the sheet does not supply — + images, price bands, pack sizes, SKU, barcode, HSN/GST and FSSAI — then validated + and written into the matching brand table. Columns the sheet already fills are never + overwritten. +

+ +
+ + + + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* ---- 2. Column mapping preview ------------------------------- */} + {preview && ( +
+

+ Column mapping — {preview.rows_total} row(s) +

+

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

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

+ + Ignored (no matching catalog field): {preview.unrecognised_columns.join(', ')} +

+ )} +
+ )} + + {/* ---- 3. Live stage tracker ----------------------------------- */} + {job && ( +
+
+

+ {job.status === 'done' && } + {job.status === 'failed' && } + {running && } + Pipeline — {job.filename} +

+ {job.status} +
+ + {job.stage_index > 0 && ( + <> +
+ + Stage {job.stage_index}/{job.total_stages} — {job.stage_name} + + + {job.rows_done}/{job.rows_total} rows + +
+
+
+
+ + )} + + {job.detail &&

{job.detail}

} + + {result && ( + <> +
+ + + + + +
+ + {result.brands?.length > 0 && ( +

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

+ )} + + `${r.product_name} ${r.size || ''} — ${r.reason}`) + } /> + `row ${e.row}: ${e.product_name || '(unnamed)'} — ${e.error}`) + } /> + + + )} +
+ )} +
+ ); +} + +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})

+
    + {items.map((text, idx) => ( +
  • + {text} +
  • + ))} +
+
+ ); +}