agent conformation

This commit is contained in:
2026-08-31 12:32:09 +05:30
parent 7e3571f90e
commit bd133e7cc6
14 changed files with 1480 additions and 7 deletions

View File

@@ -11,6 +11,7 @@ const NAV: readonly NavEntry[] = [
{ to: '/nearle/stores', label: 'Stores' },
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' },
{ to: '/nearle/uploads', label: 'Uploads' },
];
export function NearleAdminShell() {

View File

@@ -23,6 +23,9 @@ import {
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 { catalogueApi } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import {
@@ -78,6 +81,13 @@ export interface SheetImportPanelProps {
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);
@@ -96,7 +106,35 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
*/
/** 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);
/** 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) {
@@ -105,6 +143,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
setParsed(null);
setParseError(null);
setBatch(null);
setDropId(null);
setReceiptError(null);
if (!chosen) return;
try {
@@ -136,8 +176,56 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
// 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.
const submitted = await submitBatch({ files: [file] });
//
// 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,
categoryid: target.categoryid,
batchid: submitted.batch_id,
filename: file.name,
sender: submitted.submitted_by ?? '',
uploadedby: user?.userid ?? 0,
uploadedname: user?.name ?? '',
rowcount: parsed?.rows.length ?? 0,
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.`,
);
}
}
setBatch(await pollBatch(submitted.batch_id, setBatch));
} catch (cause) {
setParseError(errorMessage(cause));
@@ -192,6 +280,32 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
if (requests.length > 0) await productsApi.importFromCatalogue(requests);
setShelved({ count: requests.length, skipped: unresolved.length + unpriced.length });
// 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: requests.length,
skipped: unresolved.length + unpriced.length,
})
.catch(() => {});
}
} catch (cause) {
setShelving(errorMessage(cause));
} finally {
@@ -267,6 +381,11 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
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">
@@ -290,6 +409,26 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
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
@@ -309,9 +448,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</Text>
) : null}
{/* The way back into the catalogue. `brands` is the only identity the
batch returns — it reports counts, not product ids — so it is what
tells an operator where to go and look for what just arrived. */}
{/* 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">
@@ -325,6 +464,66 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</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 ? (

View File

@@ -0,0 +1,23 @@
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
/**
* Every spreadsheet on the platform, whoever sent it.
*
* No tenant scope, which is the whole difference from the two store versions:
* the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
* and they cannot do that from one merchant at a time. The panel shows the
* merchant name on each row when it is not given one.
*/
export function UploadsPage() {
return (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
/>
<UploadsPanel />
</VStack>
);
}