domain change

This commit is contained in:
2026-09-28 18:08:55 +05:30
parent c9616edad9
commit eeef8851e4
34 changed files with 99 additions and 4211 deletions

View File

@@ -0,0 +1,989 @@
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<string, unknown> {
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<Partial<ImportTarget>>({ 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<StockPlan | null>(null);
const [shelving, setShelving] = useState<string | null>(null);
const [shelved, setShelved] = useState<{ count: number; skipped: number } | null>(null);
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [parseError, setParseError] = useState<string | null>(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<IngestBatch | null>(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<string | null>(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<AbortController | null>(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<string | null>(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<PreviewRow>[] = [
{
key: 'productname',
header: 'Product',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.productname}
</Text>
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
{row.productsku}
</Text>
</VStack>
),
},
/* 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) => (
<Text type="body" size="sm" color={row.category ? undefined : 'secondary'}>
{row.category || 'General'}
</Text>
),
},
{
key: 'retailprice',
header: 'Retail',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
₹{row.retailprice}
</Text>
),
},
{
key: 'productcost',
header: 'Cost',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
₹{row.productcost}
</Text>
),
},
{
key: 'quantity',
header: 'Opening stock',
align: 'end',
width: { type: 'pixel', value: 130 },
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
},
];
/* 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 (
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{isAwaitingReview(batch) ? (
<Clock size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
) : batch.status === 'failed' ? (
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
) : broken ? (
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
) : (
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
)}
<Text type="large" weight="semibold">
{summarise(batch)}
</Text>
</HStack>
<Text type="body" size="sm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
Batch {batch.batch_id} · {batch.status}
</Text>
{/* 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 ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{receiptError}
</Text>
) : 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) ? (
<Text type="body" size="sm" color="secondary">
This upload is saved under Uploads, so it can be checked again later without this
page.
</Text>
) : 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. */
<Text type="body" color="secondary">
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.
</Text>
) : totals ? (
<Text type="body" color="secondary">
{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.'
: '.'}
</Text>
) : 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 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Brands touched
</Text>
<HStack gap={1} wrap="wrap">
{batch.brands.map((brand) => (
<Badge key={brand} variant="neutral" label={brand} />
))}
</HStack>
</VStack>
) : 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 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{confirmed.length} product{confirmed.length === 1 ? '' : 's'} in the catalogue
</Text>
<Text type="body" size="xsm" color="secondary">
&ldquo;Already there&rdquo; is a success — the product exists and this sheet had
nothing to add to it.
</Text>
<VStack gap={0.5}>
{confirmed.slice(0, 50).map((product) => (
<HStack key={product.image_id} gap={1} align="center" justify="between" wrap="wrap">
<VStack gap={0}>
<Text type="body" size="sm">
{product.product_name}
</Text>
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontFamily: 'var(--font-mono)' }}
>
{product.product_sku ?? product.image_id}
</Text>
</VStack>
<Badge
variant={
product.disposition === 'inserted'
? 'success'
: product.disposition === 'backfilled'
? 'warning'
: 'neutral'
}
label={
product.disposition === 'inserted'
? 'Added'
: product.disposition === 'backfilled'
? 'Filled in'
: 'Already there'
}
/>
</HStack>
))}
</VStack>
{confirmed.length > 50 ? (
<Text type="body" size="xsm" color="secondary">
Showing the first 50 of {confirmed.length}. The full list is on the Uploads page.
</Text>
) : null}
</VStack>
) : null}
{/* Named individually rather than counted. "1 of 2 files failed" does
not tell you which one to resend. */}
{refused.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{refused.length} file{refused.length === 1 ? '' : 's'} could not be read
</Text>
{refused.map((entry) => (
<HStack key={entry.index} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={entry.filename} />
<Text type="body" size="sm" color="secondary">
{entry.detail ?? 'No reason given.'}
</Text>
</HStack>
))}
</VStack>
) : 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 (
<VStack key={`u${entry.index}`} gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Ignored columns in {entry.filename}
</Text>
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{ignored.join(', ')} — nothing in these columns was read.
</Text>
</VStack>
);
})}
{/* 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 ? (
<Text key={`s${entry.index}`} type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{entry.filename}: rows were built but could not be stored — {entry.result.storage_error}
</Text>
) : null,
)}
{(totals?.rejected ?? 0) > 0 ? (
<Text type="body" size="sm" color="secondary">
{totals.rejected} product{totals.rejected === 1 ? '' : 's'} were built and then
refused by the validation gate.
</Text>
) : null}
{batch.status === 'interrupted' ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
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.
</Text>
) : 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) ? (
<VStack gap={1.5}>
{shelved ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{shelved.count} product{shelved.count === 1 ? '' : 's'} priced and stocked
</Text>
<Text type="body" size="sm" color="secondary">
They are in this merchant&rsquo;s catalogue, on the chosen branch&rsquo;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.`
: ''}
</Text>
</VStack>
) : (
<>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
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.
</Text>
<HStack>
<Button
label={isWorking ? 'Working…' : 'Add to this branch with opening stock'}
variant="primary"
isLoading={isWorking}
isDisabled={isWorking || !isTargetComplete(target) || !parsed}
onClick={handleShelve}
/>
</HStack>
{describeMissingTarget(target) ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{describeMissingTarget(target)}
</Text>
) : null}
</>
)}
{shelving ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{shelving}
</Text>
) : null}
{/* What was left out, and why. Each of these is a product the
merchant expects to have and will not, so none of them is
allowed to be silent. */}
{plan && plan.unmatched.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{plan.unmatched.length} product{plan.unmatched.length === 1 ? '' : 's'} the
sheet does not price
</Text>
<Text type="body" size="xsm" color="secondary">
A cell listing several pack sizes becomes one product each, so the run can
produce more products than the sheet has rows. These reached the global
catalogue with no price and no stock:{' '}
{plan.unmatched.slice(0, 8).map((entry) => entry.product_name).join(', ')}
{plan.unmatched.length > 8 ? ` and ${plan.unmatched.length - 8} more` : ''}.
</Text>
</VStack>
) : null}
{plan && plan.missing.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{plan.missing.length} sheet row{plan.missing.length === 1 ? '' : 's'} produced
no product
</Text>
<Text type="body" size="xsm" color="secondary">
{plan.missing.slice(0, 8).map((row) => row.productname).join(', ')}
{plan.missing.length > 8 ? ` and ${plan.missing.length - 8} more` : ''} — the
run rejected or never saw these.
</Text>
</VStack>
) : null}
{plan && plan.matched.some((entry) => entry.basis === 'name') ? (
<Text type="body" size="xsm" color="secondary">
{plan.matched.filter((entry) => entry.basis === 'name').length} product
{plan.matched.filter((entry) => entry.basis === 'name').length === 1 ? '' : 's'}{' '}
were matched on their name rather than a SKU, because those sheet rows left the
SKU blank. Add a SKU column to make the match exact.
</Text>
) : null}
</VStack>
) : null}
<RawResponse payload={batch} />
<HStack>
<Button
label="Send another file"
variant="secondary"
onClick={() => {
watcher.current?.abort();
setBatch(null);
setParsed(null);
setFile(null);
}}
/>
</HStack>
</VStack>
</Card>
);
}
return (
<VStack gap={2}>
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="Upload the tenant's product list"
action={
<Button
label="Download template"
variant="secondary"
size="sm"
icon={<Download size={14} />}
onClick={() => void downloadTemplate()}
/>
}
/>
{/* Who this sheet is for.
A Nearle Admin is asked; a store login already answered by signing
in, so it renders nothing for them. */}
<ImportScope value={target} onChange={setTarget} isTenantFixed={isTenantFixed} />
{/* The service reads .tsv as well, and the dropzone's own default
list did not offer it — a file the picker refuses never reaches
the code that would have accepted it. One list, exported by the
client, so the two cannot drift. */}
<SheetDropzone
file={file}
onFile={handleFile}
accept={ACCEPTED_EXTENSIONS.join(',')}
/>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
Only a product-name column is required. Everything else is optional: a price, a cost
and an opening stock are used when present, and a SKU makes the match back to your rows
exact rather than by name.
<br />
You are not asked for a category, and a categoryid column is ignored if you include
one. Each product is filed by the platform's own classification — the sheet's category
column if it has one, then the product registry, then its name — into one of the 31
categories the customer app browses, so a mixed sheet no longer lands in a single aisle.
</Text>
{parseError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{parseError}
</Text>
) : null}
</VStack>
</Card>
{parsed ? (
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="What is in the file"
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
/>
{parsed.unmappedColumns.length > 0 ? (
<HStack gap={1} wrap="wrap" align="center">
<Text type="body" size="sm" color="secondary">
Ignored columns:
</Text>
{parsed.unmappedColumns.map((column) => (
<Badge key={column} variant="neutral" label={column} />
))}
</HStack>
) : null}
{parsed.issues.length > 0 ? (
<VStack
gap={0.5}
padding={2}
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
>
<HStack align="center" gap={1}>
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
</Text>
</HStack>
{parsed.issues.slice(0, 8).map((issue, index) => (
<Text key={index} type="body" size="xsm" color="secondary">
Row {issue.line} · {issue.field}: {issue.message}
</Text>
))}
{parsed.issues.length > 8 ? (
<Text type="body" size="xsm" color="secondary">
…and {parsed.issues.length - 8} more.
</Text>
) : null}
</VStack>
) : null}
{preview.length > 0 ? (
<>
<Table<PreviewRow>
data={previewPaged.rows}
columns={previewColumns}
idKey="productsku"
density="compact"
dividers="rows"
/>
<TablePager paged={previewPaged} label="sheet rows" />
</>
) : null}
{/* Real progress, from the batch.
Two positions come off each poll and they answer different
questions: files done of files total is how far the batch has
got, and the running file's stage of eleven is what it is doing
now. Shown together because a single number cannot say both. It
falls back to indeterminate for the moment between submitting
and the first reading. */}
{batch && !isSettled(batch) ? (
<VStack gap={0.5}>
{(() => {
const { done, total } = progressOf(batch);
const running = batch.files.find((entry) => entry.status === 'running');
return total > 0 ? (
<ProgressBar
label={running?.stage_name || batch.current_file || 'Working'}
value={done}
max={total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max} files`}
/>
) : (
<ProgressBar label="Starting" isIndeterminate />
);
})()}
<Text type="body" size="xsm" color="secondary">
{(() => {
const running = batch.files.find((entry) => entry.status === 'running');
if (!running) {
return batch.status === 'queued'
? 'Queued — the service runs one batch at a time. Keep this tab open.'
: 'Working — keep this tab open.';
}
const stage =
running.stage_index !== undefined && running.total_stages
? `Stage ${running.stage_index + 1} of ${running.total_stages}`
: 'Running';
const rows =
running.rows_total && running.rows_done !== undefined
? ` · ${running.rows_done} of ${running.rows_total} rows`
: '';
return `${running.filename} — ${stage}${rows}. Keep this tab open.`;
})()}
</Text>
</VStack>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack align="center" gap={1}>
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
<Text type="body" size="xsm" color="secondary">
Sent whole for parsing and enrichment. The table above is only what we could read
locally — the service applies its own column rules.
</Text>
</HStack>
<HStack gap={1} align="center">
<Button
label={isWorking ? 'Working…' : 'Send to catalogue'}
variant="primary"
size="lg"
isLoading={isWorking}
isDisabled={isWorking || !file}
onClick={handleImport}
/>
</HStack>
</HStack>
</VStack>
</Card>
) : null}
</VStack>
);
}
/**
* What the ingest service actually replied.
*
* Collapsed, so it is not in the way, and copyable in one click — the point is
* to get the real payload out of a browser and in front of someone who can turn
* it into types. Scaffolding: it goes when `api/ingest.ts` stops guessing.
*/
function RawResponse({ payload }: { payload: unknown }) {
const [isOpen, setIsOpen] = useState(false);
const text = useMemo(() => {
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}, [payload]);
return (
<VStack gap={1}>
<HStack gap={1} align="center" wrap="wrap">
<Button
label={isOpen ? 'Hide the raw response' : 'Show the raw response'}
variant="ghost"
size="sm"
onClick={() => setIsOpen((open) => !open)}
/>
{isOpen ? (
<Button
label="Copy"
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard?.writeText(text)}
/>
) : null}
</HStack>
{isOpen ? (
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
fontFamily: 'var(--font-mono)',
fontSize: 12,
lineHeight: 1.6,
maxHeight: 320,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--color-ink-2)',
}}
>
{text}
</pre>
) : null}
</VStack>
);
}