diff --git a/src/api/client.js b/src/api/client.js index 9f7a61e..1899f0f 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -131,6 +131,52 @@ async function upload(path, file, { fieldName = 'file' } = {}) { return res.json(); } +/** + * Post several files under one field name. + * + * Separate from `upload` rather than a flag on it: `upload` is used by six + * call sites that send exactly one file, and widening its signature would put + * an array check in front of all of them for the benefit of one. + * + * FastAPI reads `files: List[UploadFile]` from repeated parts sharing a name, + * which is why every file is appended under the same key. As with `upload`, + * Content-Type is deliberately left unset so the browser writes the multipart + * boundary itself. + */ +async function uploadMany(path, files, { fieldName = 'files' } = {}) { + const formData = new FormData(); + Array.from(files || []).forEach((file) => formData.append(fieldName, file)); + + let res; + try { + res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: authHeaders(), + body: formData, + }); + } catch { + throw new ApiError( + `Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` + + `and reachable?`, + 0 + ); + } + + if (!res.ok) { + let detail = `Upload failed (${res.status})`; + try { + const body = await res.json(); + detail = body.detail || JSON.stringify(body); + } catch { + /* ignore parse errors, keep generic message */ + } + if (res.status === 401 && onUnauthorized) onUnauthorized(detail); + throw new ApiError(detail, res.status); + } + + return res.json(); +} + function qs(params = {}) { const usp = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { @@ -304,6 +350,36 @@ export const api = { getStoreCatalogJob: (jobId) => request(`/api/admin/store-catalog/jobs/${encodeURIComponent(jobId)}`), + // --- Admin: batch catalog ingestion (many spreadsheets -> one batch) --- + // The multi-file sibling of the block above. See + // app/api/routers/batch_catalog.py. + // + // NOTE the flag defaults are the OPPOSITE of ingestStoreCatalog's. One file + // with image search on is a considered trade; twenty files is thousands of + // outbound requests and a Playwright subprocess per row, on a single-vCPU + // container that is also serving the API. A batch opts in. + previewCatalogBatch: (files) => + uploadMany('/api/admin/catalog-batch/preview', files), + + ingestCatalogBatch: (files, { use_llm = false, fetch_images = false } = {}) => + uploadMany(`/api/admin/catalog-batch/ingest${qs({ use_llm, fetch_images })}`, files), + + getCatalogBatch: (batchId) => + request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}`), + + listCatalogBatches: (limit = 20) => + request(`/api/admin/catalog-batch/batches${qs({ limit })}`), + + resumeCatalogBatch: (batchId) => + request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/resume`, { + method: 'POST', + }), + + cancelCatalogBatch: (batchId) => + request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/cancel`, { + method: 'POST', + }), + // --- 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 0a3d21a..eeecff1 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; -import { Wrench, Database, UploadCloud } from 'lucide-react'; +import { Wrench, Database, UploadCloud, Layers } from 'lucide-react'; import { api } from '../api/client'; import { NavigationHeader } from '../components/NavigationHeader'; import { StoreCatalogPanel } from './StoreCatalogPanel'; +import { BatchCatalogPanel } from './BatchCatalogPanel'; /* * The admin panel carries two views: the read-only project overview, and the @@ -18,6 +19,7 @@ import { StoreCatalogPanel } from './StoreCatalogPanel'; const TABS = [ { id: 'project', label: 'Project Overview Details', icon: Database }, { id: 'ingest', label: 'Store Catalog Ingestion', icon: UploadCloud }, + { id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers }, ]; export function AdminPage() { @@ -45,7 +47,7 @@ export function AdminPage() {
+ 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}
+