import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@astryxdesign/core/Badge'; import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { ProgressBar } from '@astryxdesign/core/ProgressBar'; import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, CheckCircle2, Clock, Download, FileSpreadsheet } from 'lucide-react'; import type { SheetProductRow } from '@/api/products'; import { ACCEPTED_EXTENSIONS, isAwaitingReview, isDismissed, isIncomplete, isSettled, pollBatch, productsOf, progressOf, submitBatch, summarise, type IngestBatch, } from '@/api/ingest'; import { errorMessage } from '@/api/client'; import { buildSender, uploadsApi } from '@/api/uploads'; import { useAuth } from '@/auth/AuthContext'; import { useTenantLocations, useTenants } from '@/queries/hooks'; import { ImportScope, describeMissingTarget, isTargetComplete, type ImportTarget, } from './ImportScope'; import { type StockPlan } from './openingStock'; import { shelveBatch } from './shelve'; import { SectionHeader } from '@/components/SectionHeader'; import { SheetDropzone } from '@/components/SheetDropzone'; import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; interface PreviewRow extends Record { productname: string; productsku: string; category: string; retailprice: number; productcost: number; quantity: number; } /* No props. The ingest writes the global catalogue and takes no tenant and no outlet — see the note in `GlobalCataloguePage`. They return with the inventory step. */ /** * The spreadsheet import path. * * Staged rather than one-shot — upload, parse, show what is wrong, then commit — * because the underlying calls are N creates with no transaction and no batch, * so a row that fails on the server fails alone and has to be recoverable. * * It is also not idempotent, and cannot be made so from the client: nothing on * the server dedupes on SKU. Re-uploading the same file creates the products * twice, so that is said out loud before the button rather than discovered * afterwards. */ export interface SheetImportPanelProps { /** * Fixed by the session for a store login; absent for a Nearle Admin, who is * asked instead. * * The ingest itself takes no tenant — it writes the shared global catalogue — * but the step AFTER it does. Pricing each product and putting its opening * stock on a shelf is per tenant and per branch, and a sheet applied to the * wrong branch loads a shelf nobody is standing at. */ tenantid?: number; locationid?: number; } export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps = {}) { const isTenantFixed = Boolean(tenantid); const [target, setTarget] = useState>({ tenantid, locationid }); const { user } = useAuth(); /* Read only to NAME the merchant and branch on the receipt and in the label their reviewer sees. Neither drives any request — the ids in `target` do — so a lookup that has not resolved yet degrades to a shorter label rather than to a wrong upload. */ const tenants = useTenants({ pageno: 1, pagesize: 200 }); const branches = useTenantLocations(target.tenantid); /** The shelving step, after the run finishes. */ const [plan, setPlan] = useState(null); const [shelving, setShelving] = useState(null); const [shelved, setShelved] = useState<{ count: number; skipped: number } | null>(null); const [file, setFile] = useState(null); const [parsed, setParsed] = useState(null); const [parseError, setParseError] = useState(null); /** * Non-null while the ingest service is working. * * Not a percentage. The old importer ran N creates from the browser and could * count them; this is one request to a service that scrapes, calls an LLM and * fetches images before it answers, and it reports nothing along the way. A * bar that invented a position would be lying, so it says what is happening * and how many rows are in flight instead. */ /** The batch while it runs, and after it settles. */ const [batch, setBatch] = useState(null); /** * The DROP id, held separately from `batch`. * * `batch` follows the drop to the run once an admin releases it, so * `batch.batch_id` stops being the id we uploaded under — and the receipt is * keyed on the drop. Reading the shelving key off `batch` therefore updated * a row that does not exist, silently, and the Uploads page would have gone * on reporting products as never shelved after they had been. */ const [dropId, setDropId] = useState(null); const [isWorking, setIsWorking] = useState(false); /** * The live watch on the current batch. * * Held so it can be called off — when a new file replaces this one, when the * panel resets, and when the drawer closes. Without that, closing the drawer * mid-run left a poll reading a batch nothing was rendering. */ const watcher = useRef(null); useEffect(() => () => watcher.current?.abort(), []); /** * Follows a batch until it finishes, updating the steps as it goes. * * NOT awaited by the caller, and that is the point. The upload is over once * the service has the file; what follows is a wait — often on their admin to * release the drop — and holding the submit handler open for it would keep * the button spinning for hours. Every reading lands through `setBatch`, so * the panel re-renders on each one. */ function watch(batchId: string) { watcher.current?.abort(); const controller = new AbortController(); watcher.current = controller; void pollBatch(batchId, setBatch, controller.signal) .then((settled) => { if (!controller.signal.aborted) setBatch(settled); }) .catch(() => { /* Aborted, or the service stopped answering. The last reading stays on screen with its batch id, which is what the operator comes back with — inventing an error over a poll that was cancelled would report a failure that did not happen. */ }); } /** Set when the upload succeeded but its receipt could not be filed. */ const [receiptError, setReceiptError] = useState(null); /* Derived at render rather than seeded into state by an effect: both lists arrive asynchronously, and a state copy would hold whatever was known at the moment the effect happened to run. Empty is a fine answer — the label simply gets shorter. */ const tenantName = useMemo( () => (tenants.data ?? []).find((entry) => entry.tenantid === target.tenantid)?.tenantname ?? '', [tenants.data, target.tenantid], ); const branchName = useMemo( () => (branches.data ?? []).find((entry) => entry.locationid === target.locationid)?.locationname ?? '', [branches.data, target.locationid], ); async function handleFile(next: File | File[] | null) { const chosen = Array.isArray(next) ? (next[0] ?? null) : next; setFile(chosen); setParsed(null); setParseError(null); // A new file is a new upload, so stop watching the old one first. watcher.current?.abort(); setBatch(null); setDropId(null); setReceiptError(null); if (!chosen) return; try { setParsed(await parseProductSheet(chosen)); } catch (cause) { setParseError(errorMessage(cause)); } } /** * Submit, then poll until the batch settles. * * The FILE goes up, not the parsed rows — the service does its own parsing * and enrichment and can only do that from the original document. The local * parse still runs, but only to fill the table above. * * There is no dry run any more. The batch endpoint has no `/preview` — a POST * to it answers 405, because the path matches the GET that reads a batch by * id. Only the older `/api/admin/store-catalog` route offers one, and it is * gated on the `admin` role while the console's key is an `uploader`. So the * local parse above is the only look before sending, and it reads the sheet * with our rules rather than the service's. */ async function handleImport() { if (!file) return; setParseError(null); setIsWorking(true); try { // Sent as a one-file batch. The endpoint takes up to twenty, and the // client already supports that — the dropzone is what takes one at a // time, and widening it is a separate change. // // The sender names the shop rather than the console. It is the label the // catalogue service's admin reads when deciding whether to run the file, // and until now every upload from here arrived as the same constant — so // they could not tell one merchant's sheet from another's. const submitted = await submitBatch({ files: [file], sender: buildSender({ tenantname: tenantName, locationname: branchName, username: user?.name ?? '', }), }); setBatch(submitted); setDropId(submitted.batch_id); // The receipt, written BEFORE the first poll. // // This is the one instant the batch id is guaranteed to exist and // guaranteed not to have been lost. Everything after it — the review // wait, the run, the shelving — can be recovered from the id; the id // cannot be recovered from anything, and the service hands it out once, // to this tab. A drop nobody releases is deleted after seven days, so // without this row an upload can vanish with no trace at either end. // // Failure here is reported, not swallowed, and deliberately does not stop // the upload: the sheet is already with the service and the id is on // screen. But it has to be visible, because the quiet version of this // failure is an upload nobody can find a week later. if (isTargetComplete(target)) { try { await uploadsApi.record({ tenantid: target.tenantid, locationid: target.locationid, // Nothing picks a category any more, so the receipt's column is // written 0 and never read back: `shelveBatch` classifies each row. categoryid: 0, batchid: submitted.batch_id, filename: file.name, sender: submitted.submitted_by ?? '', uploadedby: user?.userid ?? 0, uploadedname: user?.name ?? '', rowcount: parsed?.rows.length ?? 0, // The sheet itself, so the shelving step can be run later from the // Uploads page. The prices and opening stock exist nowhere else — // the ingest service holds a catalogue every merchant shares, which // carries neither — and this browser is otherwise their only copy. sheetrows: JSON.stringify(parsed?.rows ?? []), laststatus: submitted.status, }); } catch (cause) { setReceiptError( `The upload reached the catalogue service, but this console could not file its receipt: ${errorMessage(cause)}. Keep the batch id below — it is the only way back to this upload.`, ); } } // The steps from here on arrive by themselves — see `watch`. watch(submitted.batch_id); } catch (cause) { setParseError(errorMessage(cause)); } finally { setIsWorking(false); } } /** * Puts the run's products on the shelf, with the sheet's own price and * opening stock. * * This is the half the ingest service cannot do. It writes the GLOBAL * catalogue, which is shared by every merchant and therefore holds no price * and no stock; the sheet carries both, and they have travelled no further * than this browser. Joining them is what turns "the products exist" into * "the shop can sell them". * * One `importcatalogueproduct` call per batch, not per product: it takes an * array, and each element writes the product row, the outlet row and the * stock ledger entry together. */ async function handleShelve() { if (!batch || !parsed || !isTargetComplete(target)) return; setShelving(null); setIsWorking(true); try { // One implementation, shared with the Uploads page — see `shelve.ts`. // This step used to live only here, in a closure over this page's state, // which meant it could not be run once the tab was gone. It normally is // gone: the drop waits for their admin, and the run then takes minutes. const result = await shelveBatch(batch, parsed.rows, target, file?.name); setPlan(result.plan); setShelved({ count: result.shelved, skipped: result.skipped }); // The other half of the confirmation, onto the receipt. // // The catalogue service confirms the GLOBAL catalogue, which every // merchant shares and which therefore holds no price and no stock — so // "added" and "this shop can sell them" are two different claims. Only // this call can make the second one, and without it the Uploads page // would show a successful import of products no customer can buy. // // Swallowed on failure: the shelving itself has already happened and // succeeded, and failing the whole step over a bookkeeping write would // invite someone to run it twice. // // Skipped entirely when there is no drop id, which happens only if the // receipt was never filed. Sending the run id instead would update // nothing and look identical to success. if (dropId) { void uploadsApi .markShelved({ // The DROP id, never `batch.batch_id` — see the note on `dropId`. batchid: dropId, shelved: result.shelved, skipped: result.skipped, }) .catch(() => {}); } } catch (cause) { setShelving(errorMessage(cause)); } finally { setIsWorking(false); } } const previewColumns: TableColumn[] = [ { key: 'productname', header: 'Product', width: { type: 'proportional', value: 3 }, renderCell: (row) => ( {row.productname} {row.productsku} ), }, /* The category, shown because nobody chooses it any more. It is worked out per row before the upload, so this column is the one place the operator can see what each product will be filed under while the file is still theirs to correct. */ { key: 'category', header: 'Category', width: { type: 'proportional', value: 2 }, renderCell: (row) => ( {row.category || 'General'} ), }, { key: 'retailprice', header: 'Retail', align: 'end', width: { type: 'pixel', value: 100 }, renderCell: (row) => ( ₹{row.retailprice} ), }, { key: 'productcost', header: 'Cost', align: 'end', width: { type: 'pixel', value: 100 }, renderCell: (row) => ( ₹{row.productcost} ), }, { key: 'quantity', header: 'Opening stock', align: 'end', width: { type: 'pixel', value: 130 }, renderCell: (row) => {row.quantity}, }, ]; /* The whole sheet, paged — it used to be cut at 25 with nothing saying so, unlike the issues list above, which says '…and N more'. A bad row on line 300 was unreachable in the one screen meant for checking the parse. */ const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({ productname: row.productname, productsku: row.productsku, category: row.category ?? '', retailprice: row.retailprice, productcost: row.productcost, quantity: row.quantity, })); // A new file is a new sheet, so the pager starts over. const previewPaged = usePaged(preview, { resetKey: preview.length }); /* ── Finished ─────────────────────────────────────────────────────────── */ if (batch && (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch))) { const { totals } = batch; const broken = isIncomplete(batch); /* Every file the service refused, with the reason it gave for each. A file that could not be read stays in the batch rather than being dropped, so this is where a sender learns what became of it. */ const refused = batch.files.filter((entry) => entry.status === 'failed'); /* What the run actually wrote, narrowed to our own file for the same reason the shelving step narrows it: a run can be assembled from several drops, and another sender's products have no business being reported here as ours. */ const confirmed = productsOf(batch, file ? [file.name] : undefined); return ( {isAwaitingReview(batch) ? ( ) : batch.status === 'failed' ? ( ) : broken ? ( ) : ( )} {summarise(batch)} Batch {batch.batch_id} · {batch.status} {/* The receipt failed to file. Said here rather than swallowed, because the quiet version of this is an upload nobody can find a week later — the id above is then the only copy in existence, and it is on a screen somebody is about to close. */} {receiptError ? ( {receiptError} ) : null} {/* Where this upload can be found again once this page is closed. Worth saying on every result, not only on the ones still waiting: a released run finishes long after whoever sent it has moved on. */} {!receiptError && isTargetComplete(target) ? ( This upload is saved under Uploads, so it can be checked again later without this page. ) : null} {isAwaitingReview(batch) ? ( /* No counts while it waits. Every total is zero because nothing has run yet, and showing them reads as an import that found nothing — the precise misreading this whole status exists to prevent. */ The file reached the service and is queued for review. Nothing is in the catalogue yet. Keep the batch id above — it is how this upload is found once an admin releases it. ) : totals ? ( {totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '} {totals.products_built} product{totals.products_built === 1 ? '' : 's'} {totals.products_built > totals.rows_total ? ' — a cell listing several pack sizes becomes one product each.' : '.'} ) : null} {/* The way back into the catalogue — where to go and look at what just arrived, as a group. The product-by-product answer is below; this is the shortcut when there are four hundred of them. */} {batch.brands.length > 0 ? ( Brands touched {batch.brands.map((brand) => ( ))} ) : null} {/* The confirmation itself: which products the run actually wrote. Narrowed to OUR file — a run an admin assembles from several drops carries other senders' products, and listing those here would claim we had added someone else's goods. `unchanged` is shown rather than filtered out. Re-sending a sheet is the normal case and writes nothing, so hiding those rows would turn a completely successful upload into an empty list. */} {confirmed.length > 0 ? ( {confirmed.length} product{confirmed.length === 1 ? '' : 's'} in the catalogue “Already there” is a success — the product exists and this sheet had nothing to add to it. {confirmed.slice(0, 50).map((product) => ( {product.product_name} {product.product_sku ?? product.image_id} ))} {confirmed.length > 50 ? ( Showing the first 50 of {confirmed.length}. The full list is on the Uploads page. ) : null} ) : null} {/* Named individually rather than counted. "1 of 2 files failed" does not tell you which one to resend. */} {refused.length > 0 ? ( {refused.length} file{refused.length === 1 ? '' : 's'} could not be read {refused.map((entry) => ( {entry.detail ?? 'No reason given.'} ))} ) : null} {/* Headers the service did not recognise, per file. High on the panel because they are dropped silently: a price column it never read looks exactly like a clean import until somebody opens the catalogue and finds everything unpriced. */} {batch.files.map((entry) => { const ignored = entry.result?.unrecognised_columns ?? []; if (ignored.length === 0) return null; return ( Ignored columns in {entry.filename} {ignored.join(', ')} — nothing in these columns was read. ); })} {/* Rows built but not stored. The counts populate either way, so reading them without checking this reports an import that never landed. */} {batch.files.map((entry) => entry.result?.storage_error ? ( {entry.filename}: rows were built but could not be stored — {entry.result.storage_error} ) : null, )} {(totals?.rejected ?? 0) > 0 ? ( {totals.rejected} product{totals.rejected === 1 ? '' : 's'} were built and then refused by the validation gate. ) : null} {batch.status === 'interrupted' ? ( This batch does not restart on its own. Ask an admin on the ingest service to resume it — re-uploading would run the files that already landed a second time. ) : null} {/* Putting them on the shelf. The run wrote the GLOBAL catalogue, which is shared by every merchant and holds no price and no stock. The sheet carries both and has gone no further than this browser, so this step is the only place the two can meet. Until it runs, the products exist and no shop can sell them. */} {batch.status !== 'failed' && !isAwaitingReview(batch) && !isDismissed(batch) ? ( {shelved ? ( {shelved.count} product{shelved.count === 1 ? '' : 's'} priced and stocked They are in this merchant’s catalogue, on the chosen branch’s shelf, with their opening stock recorded — so they are on sale in the app now. {shelved.skipped > 0 ? ` ${shelved.skipped} were left out; see below for which and why.` : ''} ) : ( <> These are in the global catalogue, which every merchant shares — so they carry no price and no stock yet. This step adds them to the chosen branch with the price and opening stock from your sheet.