645 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|