timing
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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';
|
||||
@@ -118,6 +118,41 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -143,6 +178,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
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);
|
||||
@@ -234,7 +271,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
}
|
||||
}
|
||||
|
||||
setBatch(await pollBatch(submitted.batch_id, setBatch));
|
||||
// The steps from here on arrive by themselves — see `watch`.
|
||||
watch(submitted.batch_id);
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
@@ -695,6 +733,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
label="Send another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
watcher.current?.abort();
|
||||
setBatch(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
|
||||
@@ -85,12 +85,28 @@ export function Drawer({
|
||||
<div className="drawer-panel dialog-panel" style={{ width }}>
|
||||
<header className="drawer-head">
|
||||
<div className="drawer-head-text">
|
||||
<h2 className="drawer-title" {...(isTitleMono ? { 'data-mono': 'true' } : {})}>
|
||||
{/* `title` on both, because the header is one row now and a long
|
||||
product name or a branch list is clipped to fit it. The full
|
||||
text stays reachable on hover; it is also, always, on the row
|
||||
the drawer was opened from. */}
|
||||
<h2
|
||||
className="drawer-title"
|
||||
title={title}
|
||||
{...(isTitleMono ? { 'data-mono': 'true' } : {})}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? <span className="drawer-sub">{subtitle}</span> : null}
|
||||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||||
{subtitle ? (
|
||||
<span className="drawer-sub" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Beside the title, not beneath it. Stacked under the subtitle it was
|
||||
a third line, and a third line is what made this header twice the
|
||||
height of the nav bar it now matches. */}
|
||||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||||
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useDeliveryMoves } from './DeliveryProgress';
|
||||
import { Drawer } from './Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
||||
import { branchLabel, moneyExact } from './format';
|
||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||
import {
|
||||
DELIVERY_STATUS,
|
||||
ORDER_STATUS,
|
||||
orderQuantity,
|
||||
orderValue,
|
||||
stampAfterMs,
|
||||
statusColor,
|
||||
} from './orderStatus';
|
||||
|
||||
type Row_ = OrderRow | DeliveryRow;
|
||||
|
||||
@@ -303,15 +310,21 @@ function MoveCaption({ moves }: { moves: Moves }) {
|
||||
* data-quality problem the operator opened this sheet to find.
|
||||
*/
|
||||
function Timeline({ row, isCancelled }: { row: Row_; isCancelled: boolean }) {
|
||||
const steps = STEPS.map((step) => ({
|
||||
label: step.label,
|
||||
const fields = row as unknown as Record<string, string | undefined>;
|
||||
/* Every later stamp is measured against when the order was placed, because no
|
||||
step of the journey can have happened before it — see `stampAfterMs`.
|
||||
`getorders` returns a `deliverydate` 5:30 BEHIND `orderdate` on every row,
|
||||
and drawn raw this timeline read backwards. */
|
||||
const placed = fields['orderdate'];
|
||||
|
||||
const steps = STEPS.map((step) => {
|
||||
// Read by name: the two row shapes do not carry the same stamps — a
|
||||
// delivery has no `packtime`, an order has no `arrivaltime` — so a step
|
||||
// whose field is absent simply reads undefined and renders as not-yet.
|
||||
at: step.fields
|
||||
.map((field) => (row as unknown as Record<string, string | undefined>)[field])
|
||||
.find(Boolean),
|
||||
}));
|
||||
const raw = step.fields.map((field) => fields[field]).find(Boolean);
|
||||
const at = stampAfterMs(raw, placed);
|
||||
return { label: step.label, at: at === null ? undefined : at };
|
||||
});
|
||||
|
||||
/* The step the job is sitting on: the first one without a stamp. Marked as
|
||||
"current" rather than merely "not done", so the eye lands on where the
|
||||
@@ -400,7 +413,7 @@ function Party({
|
||||
);
|
||||
}
|
||||
|
||||
const when = (at: string): string =>
|
||||
const when = (at: string | number): string =>
|
||||
new Date(at).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
|
||||
@@ -57,61 +57,111 @@
|
||||
.drawer-panel, .drawer-scrim { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
/* ── Header ─────────────────────────────────────────────────────────────────
|
||||
|
||||
ONE BAR HIGH, and the same bar the page wears.
|
||||
|
||||
`AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's
|
||||
header is 56 + 1 too: open a drawer and its header sits on exactly the line
|
||||
the nav bar sits on, edge to edge across the screen, instead of a 116px block
|
||||
starting level with it and finishing 60px lower.
|
||||
|
||||
Getting there meant the header becoming a ROW rather than a stack: title and
|
||||
subtitle share a line-and-a-half in the middle, the status badge sits beside
|
||||
them, and the close button ends the row. Nothing was dropped to make it fit —
|
||||
the same four things are in it.
|
||||
|
||||
It is painted in the brand purple. The one place a drawer needs to announce
|
||||
itself is the moment it slides in over the page, and the purple does that
|
||||
without the panel needing a border or a heavier shadow. */
|
||||
|
||||
.drawer-head {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
/* 57, not 56: box-sizing is border-box across the app, so the 1px rule
|
||||
below eats into the height. The nav bar is a 56px row PLUS its rule, and
|
||||
the two have to finish on the same pixel. */
|
||||
height: 57px;
|
||||
padding: 0 12px 0 20px;
|
||||
background: var(--color-brand);
|
||||
/* A darker purple, not the grey page rule: a grey seam across a saturated
|
||||
ground reads as a rendering fault rather than an edge. */
|
||||
border-bottom: 1px solid var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.drawer-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.drawer-head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* One line, with the full text on hover and in the accessible name.
|
||||
|
||||
A product name can run to sixty characters and the row is 56px tall, so it
|
||||
truncates — but the drawer is always opened FROM the thing it names, so the
|
||||
full text is on the page behind it as well as in the tooltip. */
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font: 600 21px/1.25 var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink-1);
|
||||
overflow-wrap: anywhere;
|
||||
font: 600 15px/1.3 var(--font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* An order id is a code, and a code reads better tabular. */
|
||||
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 20px; }
|
||||
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; }
|
||||
|
||||
.drawer-sub {
|
||||
font: 400 13.5px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
overflow-wrap: anywhere;
|
||||
font: 400 11.5px/1.35 var(--font-sans);
|
||||
/* Not a grey: a grey on purple goes muddy. White at 72% keeps the same
|
||||
"quieter than the title" relationship the page's ink scale gives. */
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The row under the title: badge, then any metadata. */
|
||||
/* Beside the title now, not under it — that stacked row is what made the
|
||||
header three lines tall. It never wraps: a second badge shortens the title
|
||||
rather than pushing the row taller. */
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
/* Capped, because the meta keeps its width and the title gives way. A branch
|
||||
named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
|
||||
.drawer-meta-text {
|
||||
font: 400 12px/1.4 var(--font-sans);
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
max-width: 30ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drawer-meta-text { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
.drawer-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
border: 0;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-3);
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, color 160ms ease;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
.drawer-close:hover { background: var(--color-slate-200); color: var(--color-ink-1); }
|
||||
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
.drawer-close:hover { background: rgb(255 255 255 / 0.26); }
|
||||
.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -273,6 +323,27 @@
|
||||
font: 600 12px/1.5 var(--font-sans);
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
color: var(--badge);
|
||||
background: color-mix(in oklab, var(--badge) 12%, transparent);
|
||||
}
|
||||
|
||||
/* In the purple header the status colour cannot be the text colour — a 12%
|
||||
tint of green on purple is unreadable, and several statuses are darker than
|
||||
the ground. The label goes white on a translucent chip and the colour moves
|
||||
to a dot, lightened so it still separates from the purple. The status is
|
||||
still distinguishable at a glance, which is the badge's whole job. */
|
||||
.drawer-head .drawer-badge {
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 0.16);
|
||||
padding-left: 8px;
|
||||
}
|
||||
.drawer-head .drawer-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
background: color-mix(in oklab, var(--badge) 55%, white);
|
||||
}
|
||||
|
||||
/* ── Timeline ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import './drawer.css';
|
||||
|
||||
@@ -132,7 +132,12 @@ export function Badge({ label, colour }: { label: string; colour: string }) {
|
||||
return (
|
||||
<span
|
||||
className="drawer-badge"
|
||||
style={{ color: colour, background: `color-mix(in oklab, ${colour} 12%, transparent)` }}
|
||||
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
|
||||
`background` directly, so the stylesheet can decide how to spend it.
|
||||
An inline colour would win over every rule, and the badge has to look
|
||||
different in the purple header than it does on a white card — see
|
||||
`.drawer-head .drawer-badge`. */
|
||||
style={{ '--badge': colour } as CSSProperties}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { billedAtMs, syncLagMs } from './orderStatus';
|
||||
import { billedAtMs, syncLagMs ,
|
||||
stampAfterMs,} from './orderStatus';
|
||||
|
||||
/* Set after the imports on purpose, and it still takes: `billedAtMs` reads the
|
||||
zone when it is CALLED, not when this module loads, and assigning TZ calls
|
||||
@@ -115,3 +116,45 @@ test('survives the fields being absent or unparseable', () => {
|
||||
assert.equal(syncLagMs({ billedat: '2026-08-27T11:15:00Z' }), null);
|
||||
assert.equal(syncLagMs({ receivedat: '2026-08-27T11:15:00Z' }), null);
|
||||
});
|
||||
|
||||
/* ── A delivery stamped before its own order ──────────────────────────────── */
|
||||
|
||||
/*
|
||||
Measured on live tenant 1147, 50 of 50 rows: `deliverydate` sits 5h29m48s behind
|
||||
`orderdate`. That is one IST offset less a few seconds of processing — a UTC
|
||||
instant written into a column whose other writer uses server local time, then
|
||||
served with a +05:30 tag. A delivery cannot precede its order.
|
||||
*/
|
||||
|
||||
test('a delivery stamped 5:30 before its order is read forward', () => {
|
||||
const at = stampAfterMs('2026-09-05T09:18:54+05:30', '2026-09-05T14:48:42+05:30');
|
||||
assert.equal(
|
||||
new Date(at as number).toISOString(),
|
||||
new Date('2026-09-05T14:48:54+05:30').toISOString(),
|
||||
'the same wall clock the order was placed at, twelve seconds later',
|
||||
);
|
||||
});
|
||||
|
||||
test('a stamp that is already after the order is left alone', () => {
|
||||
const raw = '2026-09-05T15:10:00+05:30';
|
||||
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime());
|
||||
});
|
||||
|
||||
// Correcting only when it resolves the impossibility is what makes this stop by
|
||||
// itself: the day the caller sends a real instant, nothing here fires.
|
||||
test('a stamp still before the order after correcting is left as sent', () => {
|
||||
const raw = '2026-09-01T10:00:00+05:30';
|
||||
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime());
|
||||
});
|
||||
|
||||
test('no reference means nothing to check against, so nothing is changed', () => {
|
||||
const raw = '2026-09-05T09:18:54+05:30';
|
||||
assert.equal(stampAfterMs(raw, undefined), new Date(raw).getTime());
|
||||
assert.equal(stampAfterMs(undefined, raw), null);
|
||||
});
|
||||
|
||||
// Clock drift of a few minutes is ordinary and must not trigger a 5.5h shift.
|
||||
test('a couple of minutes of drift is drift, not a timezone fault', () => {
|
||||
const raw = '2026-09-05T14:46:00+05:30';
|
||||
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime());
|
||||
});
|
||||
|
||||
@@ -193,3 +193,43 @@ export function syncLagMs(bill: { billedat?: string; receivedat?: string }): num
|
||||
const lag = received - billed;
|
||||
return lag >= 60_000 ? lag : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lifecycle stamp that cannot have happened before the order did.
|
||||
*
|
||||
* The same class of fault as `billedAtMs`, in a second place. `getorders`
|
||||
* returns `deliverydate` — which is `orders.deliverytime` under an alias — and
|
||||
* on live tenant 1147 it is 5h29m48s behind `orderdate` on ALL FIFTY rows read:
|
||||
*
|
||||
* orderdate 2026-09-05T14:48:42+05:30
|
||||
* deliverydate 2026-09-05T09:18:54+05:30
|
||||
*
|
||||
* A delivery cannot be stamped before the order it belongs to. The gap is one
|
||||
* IST offset less a few seconds of processing, which is what a UTC instant
|
||||
* looks like when it is written into a column whose other writer uses server
|
||||
* local time and then serialised with a +05:30 tag. Fiesta's own default for
|
||||
* `orderdate` is `time.Now()` in the server's zone, so `orderdate` is the sound
|
||||
* reference and the one to measure against.
|
||||
*
|
||||
* Corrected only when the correction actually resolves the impossibility — a
|
||||
* stamp still earlier than the order afterwards is something else, and guessing
|
||||
* further would be inventing data. So this stops correcting by itself the day
|
||||
* the caller sends a real instant.
|
||||
*/
|
||||
export function stampAfterMs(value: string | undefined, notBefore: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const at = new Date(value).getTime();
|
||||
if (Number.isNaN(at)) return null;
|
||||
|
||||
if (!notBefore) return at;
|
||||
const floor = new Date(notBefore).getTime();
|
||||
if (Number.isNaN(floor)) return at;
|
||||
if (at >= floor - CLOCK_TOLERANCE_MS) return at;
|
||||
|
||||
// `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. SUBTRACTING
|
||||
// it moves a UTC instant forward to the wall-clock reading it stood for,
|
||||
// which is the direction this fault runs — the opposite of `billedAtMs`,
|
||||
// where local digits were labelled `Z`.
|
||||
const corrected = at - new Date(at).getTimezoneOffset() * 60_000;
|
||||
return corrected >= floor - CLOCK_TOLERANCE_MS ? corrected : at;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
orderValue,
|
||||
statusColor,
|
||||
billedAtMs,
|
||||
stampAfterMs,
|
||||
syncLagMs,
|
||||
type StatusKey,
|
||||
} from '../orderStatus';
|
||||
@@ -770,7 +771,11 @@ function DeliveriesTable({
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.assigntime || row.deliverydate)}</Secondary>
|
||||
{/* Measured against the order own time: deliverydate
|
||||
arrives 5:30 early on every row - see stampAfterMs. */}
|
||||
<Secondary>
|
||||
{stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))}
|
||||
</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td isMuted>
|
||||
@@ -988,10 +993,10 @@ function BillsTable({
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** "21 Aug, 14:32". Falls back to the raw string rather than to "Invalid Date". */
|
||||
function stamp(value: string | undefined): string {
|
||||
if (!value) return '—';
|
||||
function stamp(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
if (Number.isNaN(parsed.getTime())) return String(value);
|
||||
return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ export function StoreProductsPage() {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Products"
|
||||
actions={
|
||||
tab === 'requests' ? null : (
|
||||
|
||||
@@ -33,7 +33,7 @@ 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 { FileSpreadsheet, RefreshCw, UploadCloud } from 'lucide-react';
|
||||
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
fetchBatch,
|
||||
@@ -119,7 +119,6 @@ export function UploadsPanel({
|
||||
const [search, setSearch] = useState('');
|
||||
const from = range?.fromdate ?? '';
|
||||
const to = range?.todate ?? '';
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -151,7 +150,6 @@ export function UploadsPanel({
|
||||
*/
|
||||
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.
|
||||
@@ -191,7 +189,6 @@ export function UploadsPanel({
|
||||
}
|
||||
}
|
||||
setLive((prev) => ({ ...prev, ...readings }));
|
||||
setIsRefreshing(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -200,6 +197,55 @@ export function UploadsPanel({
|
||||
if (receipts && receipts.length > 0) void refreshLive(receipts);
|
||||
}, [receipts, refreshLive]);
|
||||
|
||||
/** Receipts that could still change. A finished, shelved one never will. */
|
||||
const moving = useMemo(
|
||||
() => (receipts ?? []).filter((receipt) => !(receipt.laststatus === 'done' && receipt.shelvedat)),
|
||||
[receipts],
|
||||
);
|
||||
|
||||
/**
|
||||
* Keeps the moving rows current without anyone reloading the page.
|
||||
*
|
||||
* The list used to be read once, when the page loaded. Everything after that
|
||||
* — their admin releasing a drop, the run finishing, products reaching the
|
||||
* shelf — arrived only if the operator reloaded the page, which is exactly
|
||||
* what a status page exists to avoid. The "Check again" button that used to
|
||||
* paper over it is gone with the same change.
|
||||
*
|
||||
* Fifteen seconds, and only while something is unfinished: a page of forty
|
||||
* settled receipts makes no requests at all. It also pauses entirely while
|
||||
* the tab is in the background and takes a reading the moment it comes
|
||||
* forward, so a tab left open all afternoon costs nothing and is up to date
|
||||
* by the time it is looked at.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (moving.length === 0) return;
|
||||
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const read = () => {
|
||||
if (stopped || document.visibilityState !== 'visible') return;
|
||||
void refreshLive(moving);
|
||||
};
|
||||
const loop = () => {
|
||||
read();
|
||||
timer = setTimeout(loop, 15000);
|
||||
};
|
||||
timer = setTimeout(loop, 15000);
|
||||
|
||||
// Coming back to the tab is worth a reading straight away — that is the
|
||||
// moment somebody wants the answer.
|
||||
const onVisible = () => read();
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [moving, refreshLive]);
|
||||
|
||||
const rows = useMemo(() => receipts ?? [], [receipts]);
|
||||
const showTenant = !tenantid;
|
||||
|
||||
@@ -312,15 +358,12 @@ export function UploadsPanel({
|
||||
aria-label="Search by file name"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
label={isRefreshing ? 'Checking…' : 'Check again'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRefreshing}
|
||||
isDisabled={isRefreshing}
|
||||
icon={<RefreshCw size={13} />}
|
||||
onClick={() => void refreshLive(rows)}
|
||||
/>
|
||||
{/* "Check again" stood here. It is gone because the page reads the
|
||||
moving rows by itself now — every fifteen seconds while something is
|
||||
unfinished, and again the moment the tab comes forward. A refresh
|
||||
button beside a list that refreshes itself is a button that never
|
||||
changes anything, and pressing it to find out whether it did is
|
||||
worse than not having it. */}
|
||||
{onUpload ? (
|
||||
<Button
|
||||
label="Upload spreadsheet"
|
||||
|
||||
Reference in New Issue
Block a user