Files
daily_console_web/src/features/uploads/UploadsPanel.tsx
2026-08-31 12:32:09 +05:30

645 lines
24 KiB
TypeScript

/**
* Every spreadsheet this shop has sent to the catalogue ingest service, and
* what became of it.
*
* One component for all three workspaces. The question — "did my upload land?"
* — is identical for a Nearle Admin, a Store Admin and a Store user; only the
* scope differs, and that arrives as props rather than being re-decided here.
*
* ── Why this screen exists ──────────────────────────────────────────────────
*
* Before it, a confirmation lived in React state in one browser tab. That was
* survivable only if uploads finished while you watched, and they do not:
* nothing runs on arrival at the ingest service. A drop waits in a review inbox
* until one of their admins presses Start — hours, sometimes days — by which
* time the tab is long closed and the batch id, which is the ONLY credential
* for reading the result, is gone with it.
*
* ── Two systems, read together ──────────────────────────────────────────────
*
* Fiesta holds the receipt: the batch id, which shop the sheet was for, who
* sent it, and whether the products reached that shop's shelf. The ingest
* service holds what became of the drop and is the only authority on it.
*
* So the list renders from Fiesta immediately — twenty receipts, no waiting on
* a host that spends minutes per batch — and each unfinished row is then
* refreshed against the ingest service in the background. What comes back is
* written home, so the next person to open this page sees it without the wait.
*/
import { useCallback, useEffect, useMemo, 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 { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, CheckCircle2, Clock, PackageCheck, RefreshCw, XCircle } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
currentStage,
fetchBatch,
isAwaitingReview,
isDismissed,
isSettled,
isStuckOnMissingRunner,
productsOf,
resolveBatch,
releasedRunId,
type IngestBatch,
type IngestProduct,
} from '@/api/ingest';
import { daysUntilExpiry, uploadsApi, type UploadReceipt } from '@/api/uploads';
import { SectionHeader } from '@/components/SectionHeader';
export interface UploadsPanelProps {
/**
* Whose uploads to show. Omit — or pass 0 — for every merchant, which is what
* a Nearle Admin sees and what nobody else may.
*/
tenantid?: number;
/** Narrow to one branch. A Store user is pinned to theirs. */
locationid?: number;
}
/** The live reading for one receipt, once we have been able to take one. */
interface LiveReading {
batch: IngestBatch;
/** Narrowed to the file this receipt is for — see the note on `productsOf`. */
products: IngestProduct[];
}
export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({});
const [expanded, setExpanded] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const load = useCallback(async () => {
setError(null);
try {
setReceipts(await uploadsApi.list({ tenantid, locationid, pagesize: 50 }));
} catch (cause) {
setError(errorMessage(cause));
}
}, [tenantid, locationid]);
useEffect(() => {
void load();
}, [load]);
/**
* Refreshes the receipts that are still moving, against the ingest service.
*
* Anonymous, and that is not laziness — it is what their documentation tells
* integrators to do. Their ownership filter matches `sender` EXACTLY, and a
* run an admin assembles from several drops carries a joined list
* ("alice, bob"), so a caller presenting a credential gets a 404 on a run
* containing their own file while the identical anonymous request returns
* 200. The batch id is the credential here; sending anything else makes the
* read fail.
*
* Settled receipts are skipped. A finished run does not change, and re-asking
* for forty of them would spend a shop's connection confirming what Fiesta
* already knows.
*/
const refreshLive = useCallback(
async (rows: UploadReceipt[]) => {
setIsRefreshing(true);
const readings: Record<string, LiveReading> = {};
for (const receipt of rows) {
// Nothing further will arrive for these; the receipt is the answer.
if (receipt.laststatus === 'done' && receipt.shelvedat) continue;
try {
// The DROP first, then the run it became. Both matter and they are
// not interchangeable: `released_to` lives on the drop's files and is
// gone from the run, so reading the run and asking it for a run id
// gets null — and the pointer from the id we hold to the id with the
// results would never be saved.
const drop = await fetchBatch(receipt.batchid);
const runid = releasedRunId(drop) ?? '';
const batch = await resolveBatch(drop);
readings[receipt.batchid] = {
batch,
products: productsOf(batch, receipt.filename ? [receipt.filename] : undefined),
};
// Write it home, so the next reader does not repeat the wait. Failing
// here is not worth surfacing: the live reading is already on screen
// and the cache is only an optimisation for somebody else later.
void uploadsApi
.updateStatus({
batchid: receipt.batchid,
laststatus: batch.status,
...(runid ? { runid } : {}),
inserted: batch.totals?.inserted ?? 0,
backfilled: batch.totals?.backfilled ?? 0,
skipped: batch.totals?.skipped_existing ?? 0,
rejected: batch.totals?.rejected ?? 0,
})
.catch(() => {});
} catch {
/* A drop the service has forgotten — past its seven-day retention, or
an id it never issued — reads as 404. The receipt survives and says
so; that is precisely why the receipt exists. */
}
}
setLive((prev) => ({ ...prev, ...readings }));
setIsRefreshing(false);
},
[],
);
useEffect(() => {
if (receipts && receipts.length > 0) void refreshLive(receipts);
}, [receipts, refreshLive]);
const rows = useMemo(() => receipts ?? [], [receipts]);
if (error) {
return (
<Card padding={4} variant="transparent">
<VStack gap={1.5}>
<Text type="body" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
<HStack>
<Button label="Try again" variant="secondary" onClick={() => void load()} />
</HStack>
</VStack>
</Card>
);
}
if (!receipts) {
return (
<Card padding={4} variant="transparent">
<Text type="body" color="secondary">
Loading uploads…
</Text>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={4} variant="transparent">
<VStack gap={0.5}>
<Text type="label" weight="semibold">
No spreadsheets have been uploaded yet
</Text>
<Text type="body" size="sm" color="secondary">
When a sheet is sent to the catalogue service, its receipt appears here — including
while it waits for their admin to release it.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={2}>
<HStack align="center" justify="between" wrap="wrap" gap={1}>
<SectionHeader
title="Uploads"
note={`${rows.length} upload${rows.length === 1 ? "" : "s"}`}
/>
<Button
label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary"
size="sm"
isLoading={isRefreshing}
isDisabled={isRefreshing}
icon={<RefreshCw size={13} />}
onClick={() => void refreshLive(rows)}
/>
</HStack>
<VStack gap={1.5}>
{rows.map((receipt) => (
<UploadRow
key={receipt.batchid}
receipt={receipt}
live={live[receipt.batchid]}
isExpanded={expanded === receipt.batchid}
onToggle={() =>
setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
}
showTenant={!tenantid}
/>
))}
</VStack>
</VStack>
);
}
/* ── One receipt ──────────────────────────────────────────────────────────── */
function UploadRow({
receipt,
live,
isExpanded,
onToggle,
showTenant,
}: {
receipt: UploadReceipt;
live: LiveReading | undefined;
isExpanded: boolean;
onToggle: () => void;
showTenant: boolean;
}) {
const batch = live?.batch;
const state = describeState(receipt, batch);
const expiresIn = daysUntilExpiry(receipt);
return (
<Card padding={3} variant="transparent">
<VStack gap={1.5}>
<HStack align="start" justify="between" gap={2} wrap="wrap">
<HStack align="start" gap={1.5}>
<state.Icon size={20} style={{ color: state.colour, flexShrink: 0, marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" weight="semibold">
{receipt.filename || 'Spreadsheet'}
</Text>
<Text type="body" size="sm" color="secondary">
{state.line}
</Text>
<Text type="body" size="xsm" color="secondary">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
formatWhen(receipt.created),
]
.filter(Boolean)
.join(' · ')}
</Text>
</VStack>
</HStack>
<HStack gap={1} align="center">
{/* The shelving half, stated separately and always. A product in the
global catalogue carries no price and no stock — "added" and
"on sale here" are two different claims, and running them
together would tell a shopkeeper they can sell something no
customer can buy. */}
{receipt.shelvedat ? (
<Badge
variant="success"
label={`${receipt.shelvedcount} on the shelf`}
/>
) : state.canShelve ? (
<Badge variant="warning" label="Not on a shelf yet" />
) : null}
<Button
label={isExpanded ? 'Hide' : 'Details'}
variant="ghost"
size="sm"
onClick={onToggle}
/>
</HStack>
</HStack>
{/* The seven-day clock. Their service deletes a drop nobody acts on,
and the remedy — asking an admin to release it — only works before
the deadline, so it cannot wait to be discovered. */}
{expiresIn !== null ? (
<Text
type="body"
size="sm"
style={{
color: expiresIn <= 2 ? 'var(--color-error, #d64545)' : 'var(--color-warning, #b7860b)',
}}
>
{expiresIn === 0
? 'This drop has passed the seven-day review window and may already have been deleted. Re-upload it.'
: `Still waiting for review. The catalogue service deletes an unreviewed drop after seven days — ${expiresIn} left.`}
</Text>
) : null}
{/* A batch handed to an orchestrator that is not deployed. Every other
signal — queued, stage 0, an empty bar — is identical to waiting
one's turn, so the difference has to be said out loud. */}
{batch && isStuckOnMissingRunner(batch) ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
This batch was staged for the Dagster orchestrator, which is not running in production —
it will wait indefinitely rather than fail. Ask the catalogue team to resume it onto the
in-process worker.
</Text>
) : null}
{isExpanded ? <UploadDetail receipt={receipt} live={live} /> : null}
</VStack>
</Card>
);
}
/* ── The detail: stages while it runs, products once it has ───────────────── */
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {
const batch = live?.batch;
const products = live?.products ?? [];
return (
<VStack gap={2} style={{ borderTop: '1px solid var(--color-border, #e5e5e5)', paddingTop: 12 }}>
<HStack gap={2} wrap="wrap">
<Meta label="Batch" value={receipt.batchid} mono />
{receipt.runid ? <Meta label="Run" value={receipt.runid} mono /> : null}
<Meta label="Sheet rows" value={String(receipt.rowcount || '—')} />
<Meta label="Sent as" value={receipt.sender || '—'} />
</HStack>
{/* The pipeline, drawn from the service's own stage list rather than a
hardcoded one — so this cannot drift when they add or rename a stage. */}
{batch && !isSettled(batch) && !isAwaitingReview(batch) ? (
<StageTimeline batch={batch} />
) : null}
{batch?.brands?.length ? (
<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: the products, one line each, with what the
run did to every one of them. */}
{products.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{products.length} product{products.length === 1 ? '' : 's'} in the catalogue
</Text>
<Text type="body" size="xsm" color="secondary">
“Already there” is a success, not a failure — re-sending a sheet writes nothing.
</Text>
<VStack gap={0.5}>
{products.slice(0, 100).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}
{product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
</Text>
</VStack>
<Badge {...dispositionBadge(product.disposition)} />
</HStack>
))}
</VStack>
{products.length > 100 ? (
<Text type="body" size="xsm" color="secondary">
Showing the first 100 of {products.length}.
</Text>
) : null}
</VStack>
) : batch && isSettled(batch) ? (
<Text type="body" size="sm" color="secondary">
The run reported no products for this file.
</Text>
) : null}
{/* Files the service refused, named rather than counted — "1 of 2 failed"
does not say which one to resend. */}
{(batch?.files ?? [])
.filter((file) => file.status === 'failed')
.map((file) => (
<Text
key={file.index}
type="body"
size="sm"
style={{ color: 'var(--color-error, #d64545)' }}
>
{file.filename} could not be read — {file.detail ?? 'no reason given'}
</Text>
))}
{receipt.shelvedat ? (
<HStack gap={1} align="center">
<PackageCheck size={16} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="body" size="sm" color="secondary">
{receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
{formatWhen(receipt.shelvedat)}
{receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
</Text>
</HStack>
) : null}
</VStack>
);
}
/**
* The eleven stages, with the one running now.
*
* `stage_names` is read from the response rather than hardcoded, deliberately:
* the service serves it so a client cannot drift out of step when a stage is
* added or renamed. When a build does not send it, the file's own timeline is
* the fallback and the list is simply shorter.
*/
function StageTimeline({ batch }: { batch: IngestBatch }) {
const file = batch.files?.[0];
if (!file) return null;
const stage = currentStage(file);
const names = batch.stage_names ?? [];
const total = file.total_stages ?? names.length ?? 11;
if (!stage) return null;
return (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Stage {stage.index} of {total} — {stage.name}
</Text>
{stage.rows_total ? (
<Text type="body" size="xsm" color="secondary">
{stage.rows_done ?? 0} of {stage.rows_total} rows. Image search and barcode enrichment
reach the network and are much the slowest — minutes here is normal.
</Text>
) : null}
{names.length > 0 ? (
/* Numbered pips rather than eleven spelled-out rows. The names are long
enough ("Brand Resolution & FSSAI Licence Mapping") that listing them
all would bury the one fact anybody wants, which is how far along it
is; the name is on the line above and on hover. `title` goes on the
wrapper because Badge takes no tooltip of its own. */
<HStack gap={0.5} wrap="wrap">
{names.map((name, index) => (
<span key={name} title={`${index + 1}. ${name}`}>
<Badge
variant={
index + 1 < stage.index
? 'success'
: index + 1 === stage.index
? 'warning'
: 'neutral'
}
label={String(index + 1)}
/>
</span>
))}
</HStack>
) : null}
</VStack>
);
}
/* ── Wording ──────────────────────────────────────────────────────────────── */
function dispositionBadge(disposition: IngestProduct['disposition']) {
if (disposition === 'inserted') return { variant: 'success' as const, label: 'Added' };
if (disposition === 'backfilled') return { variant: 'warning' as const, label: 'Filled in' };
return { variant: 'neutral' as const, label: 'Already there' };
}
/**
* One line saying where this upload stands.
*
* The live reading wins when we have one; the cached receipt answers when the
* service could not be reached, which is the case the whole receipt exists for.
* Every branch below is a state somebody can actually be in, and none of them
* is allowed to render as a bare status word — "retired" and "partial" mean
* nothing to a shopkeeper.
*/
function describeState(receipt: UploadReceipt, batch: IngestBatch | undefined) {
const warning = 'var(--color-warning, #b7860b)';
const error = 'var(--color-error, #d64545)';
const success = 'var(--color-success, #10b981)';
if (batch) {
if (isDismissed(batch)) {
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
return {
Icon: XCircle,
colour: error,
canShelve: false,
line: reason
? `Declined by the catalogue service: ${reason}`
: 'Declined by the catalogue service. Nothing was imported.',
};
}
if (isAwaitingReview(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (!isSettled(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Running in the catalogue pipeline now.',
};
}
if (batch.status === 'failed') {
return { Icon: XCircle, colour: error, canShelve: false, line: 'No file could be ingested.' };
}
if (batch.status === 'interrupted') {
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
line: 'A restart cut this run short. It does not resume on its own — ask the catalogue team.',
};
}
const totals = batch.totals;
return {
Icon: batch.status === 'partial' ? AlertTriangle : CheckCircle2,
colour: batch.status === 'partial' ? warning : success,
canShelve: true,
line: countLine(
totals?.inserted ?? 0,
totals?.backfilled ?? 0,
totals?.skipped_existing ?? 0,
totals?.rejected ?? 0,
),
};
}
// No live reading. Everything below is the cached receipt talking.
if (receipt.laststatus === 'pending') {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (receipt.laststatus === 'done' || receipt.laststatus === 'partial') {
return {
Icon: CheckCircle2,
colour: success,
canShelve: true,
line: countLine(receipt.inserted, receipt.backfilled, receipt.skipped, receipt.rejected),
};
}
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
// Named as unreachable rather than dressed up. A drop past its seven-day
// window is genuinely gone from their side, and the receipt is all that is
// left of it — saying "loading" forever would hide that.
line: `Last known: ${receipt.laststatus}. The catalogue service could not be reached for a fresh reading.`,
};
}
function countLine(inserted: number, backfilled: number, skipped: number, rejected: number): string {
const parts = [`${inserted} added to the catalogue`];
if (backfilled > 0) parts.push(`${backfilled} filled in`);
if (skipped > 0) parts.push(`${skipped} already there`);
if (rejected > 0) parts.push(`${rejected} rejected`);
return parts.join(' · ');
}
function formatWhen(value: string | null): string {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleString(undefined, {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<VStack gap={0}>
<Text type="body" size="xsm" color="secondary">
{label}
</Text>
<Text
type="body"
size="sm"
{...(mono ? { style: { fontFamily: 'var(--font-mono)' } } : {})}
>
{value}
</Text>
</VStack>
);
}