pagenation

This commit is contained in:
2026-09-07 11:00:44 +05:30
parent d3785cc303
commit 8defbd418f
22 changed files with 1094 additions and 226 deletions

View File

@@ -37,6 +37,8 @@ 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;
@@ -347,7 +349,10 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
},
];
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
/* 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,
categoryid: row.categoryid,
@@ -356,6 +361,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
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))) {
@@ -781,13 +789,16 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
) : null}
{preview.length > 0 ? (
<Table<PreviewRow>
data={preview}
columns={previewColumns}
idKey="productsku"
density="compact"
dividers="rows"
/>
<>
<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.

View File

@@ -15,6 +15,8 @@ import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
interface BranchRow extends Record<string, unknown> {
locationid: number;
@@ -78,6 +80,8 @@ export function StoreDetailPage() {
});
}, [locations, summary.data]);
const paged = usePaged(rows);
const totals = useMemo(() => {
const branches = rows.length;
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
@@ -256,7 +260,7 @@ export function StoreDetailPage() {
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<BranchRow>
data={rows}
data={paged.rows}
columns={columns}
idKey="locationid"
density="balanced"
@@ -264,6 +268,7 @@ export function StoreDetailPage() {
dividers="rows"
/>
</div>
<TablePager paged={paged} label="branches" />
</DataState>
</Card>
</VStack>

View File

@@ -14,6 +14,7 @@ import { useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css';
/**
* Assigning riders, from the orders table itself.

View File

@@ -14,6 +14,8 @@ import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
import { SheetUploadDrawer } from './SheetUploadDrawer';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
effectivePrice,
imagesOf,
@@ -122,6 +124,7 @@ export function ProductsPanel({
<ProductTable
rows={rows}
queryKey={category}
isLoading={products.isLoading}
onOpen={setOpen}
onPrice={(product) => setPublishing([product])}
@@ -159,12 +162,18 @@ function ProductTable({
isLoading,
onOpen,
onPrice,
queryKey,
}: {
rows: Product[];
isLoading: boolean;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
/** The category filter; changing it returns the pager to page 1. */
queryKey: string;
}) {
// Above the early returns — a hook cannot be called conditionally.
const paged = usePaged(rows, { resetKey: queryKey });
if (isLoading) {
return (
<Card padding={0} elevation="low">
@@ -238,7 +247,7 @@ function ProductTable({
</tr>
</thead>
<tbody>
{rows.map((product) => (
{paged.rows.map((product) => (
<Row
key={product.productid}
product={product}
@@ -249,6 +258,7 @@ function ProductTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="products" />
</Card>
);
}

View File

@@ -26,6 +26,8 @@ import { importSheetProducts, type SheetImportResult, type SheetProductRow } fro
import { errorMessage } from '@/api/client';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
downloadTemplate,
parseProductSheet,
@@ -154,7 +156,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
},
];
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
/* 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,
categoryid: row.categoryid,
@@ -163,6 +168,9 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
quantity: row.quantity,
}));
// A new file is a new sheet, so the pager starts over.
const previewPaged = usePaged(preview, { resetKey: preview.length });
if (result) {
const isClean = result.failures.length === 0;
return (
@@ -295,13 +303,16 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
) : null}
{preview.length > 0 ? (
<Table<PreviewRow>
data={preview}
columns={previewColumns}
idKey="productsku"
density="compact"
dividers="rows"
/>
<>
<Table<PreviewRow>
data={previewPaged.rows}
columns={previewColumns}
idKey="productsku"
density="compact"
dividers="rows"
/>
<TablePager paged={previewPaged} label="sheet rows" />
</>
) : null}
{progress ? (

View File

@@ -1,7 +1,7 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, TenantLocation } from '@/api/types';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import {
dayTotals,
groupByCustomer,
@@ -10,17 +10,23 @@ import {
isToday,
lastSeen,
shiftDay,
stopKey,
stopValue,
toStops,
UNASSIGNED,
WAITING,
ymd,
type Stop,
} from './dispatchModel';
/*
Fixtures are Suriya Store's real shape, read from production: deliverycustomerid
is 0 on every row (the app never sets it), riderslat is populated on about one
row in seven, and orderstatus is lowercase.
Fixtures are production's real shape, read from the live API:
deliverycustomerid is 0 on every delivery row (the app never sets it), riderslat
is populated on about one row in seven, orderstatus is lowercase on deliveries
and 'created' on a fresh order, and an unassigned order carries deliveryid 0.
*/
const row = (over: Partial<DeliveryRow> = {}): DeliveryRow =>
const delivery = (over: Partial<DeliveryRow> = {}): DeliveryRow =>
({
deliveryid: 1,
orderheaderid: 100,
@@ -36,35 +42,129 @@ const row = (over: Partial<DeliveryRow> = {}): DeliveryRow =>
...over,
}) as DeliveryRow;
/** An order nobody has been assigned to — R mart's 5 September shape. */
const order = (over: Partial<OrderRow> = {}): OrderRow =>
({
orderheaderid: 500,
orderid: '1147-164',
locationid: 1166,
orderstatus: 'created',
orderdate: '2026-09-05 14:48:00',
deliveryid: 0,
deliverycustomer: 'anbu',
deliverycontactno: '7397177923',
deliveryaddress: 'Harmony Arcade, R.S. Puram, Coimbatore',
ordervalue: 120,
...over,
}) as OrderRow;
const d = (row: DeliveryRow): Stop => ({ kind: 'delivery', row });
const branches: TenantLocation[] = [
{ locationid: 1166, tenantid: 1135, locationname: 'Suriya Store RS Puram', status: 'Active' },
{ locationid: 1170, tenantid: 1135, locationname: 'Suriya Store Peelamedu', status: 'Active' },
];
/* ── Waiting orders reach the board at all ───────────────────────────────── */
test('an order nobody is carrying becomes a stop', () => {
/*
The bug this whole shape exists for. A `deliveries` row is written at ASSIGN
time, so an unassigned order has none — and a board reading only
`getdeliveries` showed "Nothing out on this day" while R mart had 29 orders
waiting on 5 September.
*/
const stops = toStops([], [order()]);
assert.equal(stops.length, 1);
assert.equal(stops[0]?.kind, 'order');
});
test('an order that already has a delivery is not counted twice', () => {
// Once assigned it exists on both reads; the delivery is the truthful one.
const stops = toStops([delivery({ orderheaderid: 500 })], [order({ orderheaderid: 500 })]);
assert.equal(stops.length, 1);
assert.equal(stops[0]?.kind, 'delivery');
});
test('a counter sale is not dragged onto the dispatch board', () => {
// No drop address and no customer: nothing to deliver anywhere.
const stops = toStops([], [order({ deliveryaddress: '', deliverycustomer: '' })]);
assert.deepEqual(stops, []);
});
test('a finished or cancelled order wants no rider', () => {
const stops = toStops([], [
order({ orderheaderid: 1, orderstatus: 'delivered' }),
order({ orderheaderid: 2, orderstatus: 'cancelled' }),
order({ orderheaderid: 3, orderstatus: 'created' }),
]);
assert.equal(stops.length, 1);
assert.equal(stops[0]?.row.orderheaderid, 3);
});
test('a waiting order reads as "waiting", never as "pending"', () => {
/*
`pending` on the delivery ladder means "a rider HAS it and has not collected
it yet". Reusing that word for work nobody has touched would make the two
indistinguishable on a board whose entire job is telling them apart.
*/
const stops = toStops([delivery({ orderstatus: 'pending' })], [order()]);
const statuses = stops.map((s) => (s.kind === 'order' ? WAITING : s.row.orderstatus));
assert.deepEqual(statuses, ['pending', WAITING]);
assert.notEqual(WAITING, 'pending');
});
test('stop keys do not collide across the two kinds', () => {
// Delivery 100 and order header 100 are different things; a bare number as a
// React key would silently drop one of them.
const collide = toStops(
[delivery({ deliveryid: 100, orderheaderid: 999 })],
[order({ orderheaderid: 100 })],
);
assert.equal(collide.length, 2, 'two unrelated pieces of work');
assert.equal(new Set(collide.map(stopKey)).size, 2);
});
test('a waiting order is worth its order value, not zero', () => {
// `deliveryamt` is written at assign time, so it is 0 on everything waiting —
// reading it alone made the day's value understate itself by exactly the work
// that had not gone out.
assert.equal(stopValue({ kind: 'order', row: order({ ordervalue: 120 }) }), 120);
assert.equal(
stopValue({ kind: 'order', row: order({ ordervalue: 0, orderamount: 95 }) }),
95,
'falls through the chain OrderRow documents',
);
assert.equal(stopValue(d(delivery({ deliveryamt: 80 }))), 80);
});
/* ── By store ────────────────────────────────────────────────────────────── */
test('a shop with nothing out today still appears', () => {
/*
Seeded from the branch list, not from the deliveries. Grouping only what came
back would make a quiet branch vanish, which reads as a data fault rather than
a quiet morning.
Seeded from the branch list, not from the stops. Grouping only what came back
would make a quiet branch vanish, which reads as a data fault rather than a
quiet morning.
*/
const groups = groupByStore([row({ locationid: 1166 })], branches);
const groups = groupByStore(toStops([delivery({ locationid: 1166 })], []), branches);
assert.equal(groups.length, 2);
assert.equal(groups.find((g) => g.id === '1170')?.deliveries.length, 0);
assert.equal(groups.find((g) => g.id === '1170')?.stops.length, 0);
});
test('busiest shop first', () => {
test('busiest shop first, counting waiting orders as work', () => {
const groups = groupByStore(
[row({ deliveryid: 1, locationid: 1170 }), row({ deliveryid: 2, locationid: 1170 }), row({ deliveryid: 3, locationid: 1166 })],
toStops(
[delivery({ deliveryid: 1, locationid: 1166 })],
[order({ orderheaderid: 1, locationid: 1170 }), order({ orderheaderid: 2, locationid: 1170 })],
),
branches,
);
assert.equal(groups[0]?.id, '1170');
assert.equal(groups[0]?.id, '1170', 'two waiting beats one out');
});
test('a delivery from a branch not on the list is kept, not dropped', () => {
test('a stop from a branch not on the list is kept, not dropped', () => {
// A branch can be deactivated while its deliveries are still out.
const groups = groupByStore([row({ locationid: 9999, locationname: 'Closed shop' })], branches);
const groups = groupByStore(toStops([delivery({ locationid: 9999 })], []), branches);
assert.ok(groups.some((g) => g.id === '9999'), 'live work is never hidden');
});
@@ -76,53 +176,60 @@ test('customers are matched on PHONE, not on customer id', () => {
the app never sets it — so an id join finds nothing at all. The phone number
is what both sides actually carry.
*/
const customers: CustomerInfo[] = [
{ customerid: 6060, firstname: 'Anbu', contactno: '7397177923' },
];
const groups = groupByCustomer([row({ deliverycustomerid: 0 })], customers, () => 'RS Puram');
const customers: CustomerInfo[] = [{ customerid: 6060, firstname: 'Anbu', contactno: '7397177923' }];
const groups = groupByCustomer(toStops([delivery({ deliverycustomerid: 0 })], []), customers, () => 'RS Puram');
assert.equal(groups.length, 1);
assert.equal(groups[0]?.name, 'Anbu', 'the book name wins over the delivery row');
});
test('phone matching survives formatting differences', () => {
const customers: CustomerInfo[] = [{ customerid: 1, firstname: 'Anbu', contactno: '+91 73971 77923' }];
const groups = groupByCustomer([row({ deliverycontactno: '7397177923' })], customers, () => undefined);
assert.equal(groups.length, 1, 'one customer, not two');
assert.equal(groups[0]?.name, 'Anbu');
test('a waiting order and a delivery to the same phone are one customer', () => {
// The whole point of the customer view: everything that person is owed today.
const groups = groupByCustomer(
toStops([delivery({ deliverycontactno: '7397177923' })], [order({ deliverycontactno: '+91 73971 77923' })]),
[],
() => undefined,
);
assert.equal(groups.length, 1);
assert.equal(groups[0]?.stops.length, 2);
});
test('a delivery to somebody not in the book is still shown', () => {
// Guest checkout, or a customer registered against another branch.
const groups = groupByCustomer([row({ deliverycustomer: 'Walk-in' })], [], () => undefined);
const groups = groupByCustomer(toStops([delivery({ deliverycustomer: 'Walk-in' })], []), [], () => undefined);
assert.equal(groups.length, 1);
assert.equal(groups[0]?.name, 'Walk-in');
});
test('a nameless delivery is named by its address, not by the word "Customer"', () => {
test('a nameless stop is named by its address, not by the word "Customer"', () => {
/*
All three deliveries out on 10 June carry neither a name nor a phone, which
produced three identical rail entries reading "Customer" — unreadable and
unclickable. The address is the one field they all have.
*/
const groups = groupByCustomer(
[
row({ deliveryid: 1, deliverycustomer: '', deliverycontactno: '', deliveryaddress: '120/423, R.S. Puram' }),
row({ deliveryid: 2, deliverycustomer: '', deliverycontactno: '', deliveryaddress: '412, R.S. Puram' }),
],
toStops(
[
delivery({ deliveryid: 1, deliverycustomer: '', deliverycontactno: '', deliveryaddress: '120/423, R.S. Puram' }),
delivery({ deliveryid: 2, deliverycustomer: '', deliverycontactno: '', deliveryaddress: '412, R.S. Puram' }),
],
[],
),
[],
() => 'RS Puram',
);
assert.deepEqual(groups.map((g) => g.name).sort(), ['120/423, R.S. Puram', '412, R.S. Puram']);
});
test('two nameless deliveries stay two groups, not one merged unknown', () => {
test('two nameless stops stay two groups, not one merged unknown', () => {
// With no phone there is no evidence they are the same person, and merging
// strangers on the strength of a blank field would be a lie the board tells.
const groups = groupByCustomer(
[
row({ deliveryid: 1, deliverycustomer: '', deliverycontactno: '', deliveryaddress: 'A' }),
row({ deliveryid: 2, deliverycustomer: '', deliverycontactno: '', deliveryaddress: 'A' }),
],
toStops(
[
delivery({ deliveryid: 1, deliverycustomer: '', deliverycontactno: '', deliveryaddress: 'A' }),
delivery({ deliveryid: 2, deliverycustomer: '', deliverycontactno: '', deliveryaddress: 'A' }),
],
[],
),
[],
() => undefined,
);
@@ -130,27 +237,33 @@ test('two nameless deliveries stay two groups, not one merged unknown', () => {
});
test('customers with nothing today are dropped, unlike shops', () => {
// Thousands of empty rows would bury a handful of real deliveries.
const customers: CustomerInfo[] = [
{ customerid: 1, firstname: 'Anbu', contactno: '7397177923' },
{ customerid: 2, firstname: 'Nobody', contactno: '9000000000' },
];
const groups = groupByCustomer([row()], customers, () => undefined);
const groups = groupByCustomer(toStops([delivery()], []), customers, () => undefined);
assert.deepEqual(groups.map((g) => g.name), ['Anbu']);
});
/* ── By rider ────────────────────────────────────────────────────────────── */
test('unassigned work sorts first, however small', () => {
test('waiting orders collect in the unassigned group', () => {
const groups = groupByRider(toStops([delivery()], [order({ orderheaderid: 1 }), order({ orderheaderid: 2 })]));
const queue = groups.find((g) => g.id === UNASSIGNED);
assert.equal(queue?.stops.length, 2);
assert.ok(queue?.stops.every((s) => s.kind === 'order'));
});
test('unassigned sorts first, however small', () => {
// It is the only group anybody has to act on.
const groups = groupByRider([
row({ deliveryid: 1, userid: 1427 }),
row({ deliveryid: 2, userid: 1427 }),
row({ deliveryid: 3, userid: 1427 }),
row({ deliveryid: 4, userid: undefined, ridername: '' }),
]);
const groups = groupByRider(
toStops(
[delivery({ deliveryid: 1 }), delivery({ deliveryid: 2 }), delivery({ deliveryid: 3 })],
[order()],
),
);
assert.equal(groups[0]?.id, UNASSIGNED);
assert.equal(groups[0]?.deliveries.length, 1);
assert.equal(groups[0]?.stops.length, 1);
});
test('a round is ordered by assigntime', () => {
@@ -159,27 +272,50 @@ test('a round is ordered by assigntime', () => {
assigntime onto the row's last-modified column, so any status change moved an
order between groups. Ours is written once by createdeliveries.
*/
const groups = groupByRider([
row({ deliveryid: 2, assigntime: '2026-07-23 19:30:00' }),
row({ deliveryid: 1, assigntime: '2026-07-23 19:08:23' }),
row({ deliveryid: 3, assigntime: '2026-07-23 20:00:00' }),
]);
assert.deepEqual(groups[0]?.deliveries.map((d) => d.deliveryid), [1, 2, 3]);
const groups = groupByRider(
toStops(
[
delivery({ deliveryid: 2, assigntime: '2026-07-23 19:30:00' }),
delivery({ deliveryid: 1, assigntime: '2026-07-23 19:08:23' }),
delivery({ deliveryid: 3, assigntime: '2026-07-23 20:00:00' }),
],
[],
),
);
assert.deepEqual(
groups[0]?.stops.map((s) => (s.kind === 'delivery' ? s.row.deliveryid : 0)),
[1, 2, 3],
);
});
test('the waiting queue is oldest first — it is a work queue, not a log', () => {
const groups = groupByRider(
toStops([], [
order({ orderheaderid: 2, orderdate: '2026-09-05 15:30:00' }),
order({ orderheaderid: 1, orderdate: '2026-09-05 14:48:00' }),
]),
);
assert.deepEqual(groups[0]?.stops.map((s) => s.row.orderheaderid), [1, 2]);
});
test('riders are keyed on userid, which survives a missing name', () => {
// `ridername` comes from a join and is blank whenever that join misses.
const groups = groupByRider([row({ userid: 1427, ridername: '' })]);
const groups = groupByRider(toStops([delivery({ userid: 1427, ridername: '' })], []));
assert.equal(groups[0]?.id, '1427');
});
/* ── Rider position ──────────────────────────────────────────────────────── */
test('the most recent reported position wins', () => {
const seen = lastSeen([
row({ deliveryid: 1, riderslat: '11.001', riderslon: '76.951', assigntime: '2026-07-23 10:00:00' }),
row({ deliveryid: 2, riderslat: '11.005', riderslon: '76.950', assigntime: '2026-07-23 10:00:00', pickuptime: '2026-07-23 18:00:00' }),
]);
const seen = lastSeen(
toStops(
[
delivery({ deliveryid: 1, riderslat: '11.001', riderslon: '76.951', assigntime: '2026-07-23 10:00:00' }),
delivery({ deliveryid: 2, riderslat: '11.005', riderslon: '76.950', assigntime: '2026-07-23 10:00:00', pickuptime: '2026-07-23 18:00:00' }),
],
[],
),
);
assert.equal(seen?.deliveryid, 2);
assert.equal(seen?.lat, 11.005);
});
@@ -190,34 +326,53 @@ test('no reported position is null, not a zero coordinate', () => {
it is only written when a rider moves a job along. (0,0) is the Atlantic and
is what an empty column becomes.
*/
assert.equal(lastSeen([row({ riderslat: '', riderslon: '' })]), null);
assert.equal(lastSeen([row({ riderslat: '0', riderslon: '0' })]), null);
assert.equal(lastSeen(toStops([delivery({ riderslat: '', riderslon: '' })], [])), null);
assert.equal(lastSeen(toStops([delivery({ riderslat: '0', riderslon: '0' })], [])), null);
assert.equal(lastSeen([]), null);
});
test('a waiting order never contributes a position', () => {
// Nobody is carrying it, so there is no rider to attribute one to.
assert.equal(lastSeen(toStops([], [order()])), null);
});
test('the position carries the status that produced it', () => {
// It is a last-known point, not a live feed, so it must say when and why.
const seen = lastSeen([row({ riderslat: '11.005', riderslon: '76.950', orderstatus: 'picked' })]);
const seen = lastSeen(toStops([delivery({ riderslat: '11.005', riderslon: '76.950', orderstatus: 'picked' })], []));
assert.equal(seen?.status, 'picked');
});
/* ── Totals ──────────────────────────────────────────────────────────────── */
test('cancelled work is finished, not outstanding', () => {
const totals = dayTotals([
row({ deliveryid: 1, orderstatus: 'delivered' }),
row({ deliveryid: 2, orderstatus: 'cancelled' }),
row({ deliveryid: 3, orderstatus: 'pending' }),
]);
test('"Not assigned" counts waiting orders — the number that was always zero', () => {
/*
The KPI was structurally incapable of firing: it counted delivery rows with no
userid, and every delivery row has one, because the row is created at assign
time. It only means anything once orders are on the board.
*/
const totals = dayTotals(toStops([delivery()], [order({ orderheaderid: 1 }), order({ orderheaderid: 2 })]));
assert.equal(totals.unassigned, 2);
assert.equal(totals.riders, 1);
assert.equal(totals.stops, 3);
assert.equal(totals.delivered, 1);
assert.equal(totals.outstanding, 1, 'only the pending one is still to do');
});
test('unassigned stops are counted separately from riders', () => {
const totals = dayTotals([row({ userid: 1427 }), row({ deliveryid: 2, userid: undefined })]);
assert.equal(totals.unassigned, 1);
assert.equal(totals.riders, 1);
test('cancelled work is finished; waiting work is not', () => {
const totals = dayTotals(
toStops(
[
delivery({ deliveryid: 1, orderstatus: 'delivered' }),
delivery({ deliveryid: 2, orderstatus: 'cancelled' }),
],
[order()],
),
);
assert.equal(totals.delivered, 1);
assert.equal(totals.outstanding, 1, 'the waiting order, and only it');
});
test('the day is worth the work not yet sent out, too', () => {
const totals = dayTotals(toStops([delivery({ deliveryamt: 80 })], [order({ ordervalue: 120 })]));
assert.equal(totals.value, 200);
});
/* ── The day ─────────────────────────────────────────────────────────────── */

View File

@@ -1,33 +1,63 @@
import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, TenantLocation } from '@/api/types';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { awaitingRider, assignedFrom } from './assignDelivery';
/**
* A day's deliveries, grouped three ways.
* A day's dispatch, grouped three ways.
*
* By store, by customer, and by rider — the same rows read three ways, because
* By store, by customer, and by rider — the same stops read three ways, because
* an operator asks three different questions of them: which shop is busy, who
* is waiting, and what is each rider carrying.
*
* ── Seeded from the roster, not from the deliveries ─────────────────────────
* ── Why a stop is not just a delivery ───────────────────────────────────────
*
* A `deliveries` row only exists once somebody has been assigned. An order that
* nobody has picked up yet has no delivery row at all, so a board built on
* `getdeliveries` alone shows an empty day while orders pile up in Orders —
* R mart had 29 waiting on 5 September and this page said "Nothing out on this
* day". Worse, its "Not assigned" count was structurally incapable of ever
* being anything but zero.
*
* So a stop is one of two things, and the union is deliberate rather than a
* synthetic delivery row: the detail drawer needs the REAL order to render an
* order, and faking a `DeliveryRow` for it would hand the drawer a job that has
* no delivery id, no rider and no lifecycle stamps.
*
* ── Seeded from the roster, not from the stops ──────────────────────────────
*
* Both the store and customer views start from the full list — every branch,
* every customer — and fold the day's deliveries into it. A shop with nothing
* out today still appears, showing zero. Grouping only what came back would
* make a quiet branch vanish from the board entirely, which reads as a data
* problem rather than a quiet morning.
* every customer — and fold the day's stops into it. A shop with nothing out
* today still appears, showing zero. Grouping only what came back would make a
* quiet branch vanish from the board entirely, which reads as a data problem
* rather than a quiet morning.
*
* Pure, so every rule below is testable without a map or a network.
*/
export type ViewMode = 'stores' | 'customers' | 'riders';
/** A job somebody is carrying, or an order still waiting for a rider. */
export type Stop =
| { kind: 'delivery'; row: DeliveryRow }
| { kind: 'order'; row: OrderRow };
/**
* The status a waiting order shows.
*
* Its own word, not one borrowed from the delivery ladder. `pending` there
* means "assigned, not yet collected" — a rider already has it — so reusing it
* for work nobody has touched would make the two indistinguishable on a board
* whose whole job is telling them apart.
*/
export const WAITING = 'waiting';
export interface Group {
id: string;
name: string;
/** A second line — the branch a customer belongs to, or a suburb. */
detail?: string;
deliveries: DeliveryRow[];
/** Counts by lowercase delivery status. */
stops: Stop[];
/** Counts by lowercase status, `waiting` included. */
statuses: Record<string, number>;
delivered: number;
/** Distinct riders touching this group's work. */
@@ -37,9 +67,94 @@ export interface Group {
const lower = (value: string | undefined) => (value ?? '').trim().toLowerCase();
/** The status ladder, lowercased. `pending` here means "assigned, not collected". */
export function statusOf(row: DeliveryRow): string {
return lower(row.orderstatus) || 'unknown';
/* ── Reading a stop, whichever kind it is ────────────────────────────────── */
export function stopStatus(stop: Stop): string {
return stop.kind === 'order' ? WAITING : lower(stop.row.orderstatus) || 'unknown';
}
/**
* A key that is stable and unique across both kinds.
*
* Prefixed by kind because the two id spaces overlap — delivery 128302 and
* order header 128302 are different things, and a bare number would collide as
* a React key and silently drop a row.
*/
export function stopKey(stop: Stop): string {
return stop.kind === 'delivery'
? `d${stop.row.deliveryid}`
: `o${stop.row.orderheaderid ?? stop.row.orderid ?? ''}`;
}
export function stopOrderId(stop: Stop): string {
return stop.row.orderid ?? '';
}
export function stopLocation(stop: Stop): number | undefined {
return stop.row.locationid;
}
export function stopCustomer(stop: Stop): string {
return stop.row.deliverycustomer ?? '';
}
export function stopContact(stop: Stop): string {
return stop.row.deliverycontactno ?? '';
}
export function stopAddress(stop: Stop): string {
return stop.row.deliveryaddress ?? '';
}
/**
* What the stop is worth.
*
* A delivery carries `deliveryamt`. An order has not had one written yet, so it
* uses the chain `OrderRow` documents — `ordervalue || orderamount ||
* deliveryamt` — because which field is populated depends on the endpoint the
* row came through. Without it every waiting order would read ₹0 and the day's
* value would understate itself by exactly the work that has not gone out.
*/
export function stopValue(stop: Stop): number {
if (stop.kind === 'delivery') return stop.row.deliveryamt ?? 0;
return stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0;
}
/** The rider carrying it, or undefined while nobody is. */
export function stopRider(stop: Stop): number | undefined {
return stop.kind === 'delivery' ? stop.row.userid : undefined;
}
/**
* When the stop entered the day — assigned, for a delivery; placed, for an
* order. Used only for ordering within a group, so the two being different
* clocks does not matter; both answer "which came first".
*/
export function stopTime(stop: Stop): string {
return stop.kind === 'delivery'
? (stop.row.assigntime ?? '')
: (stop.row.orderdate ?? '');
}
/* ── Building the day ────────────────────────────────────────────────────── */
/**
* Every stop for the day: what is out, plus what is still waiting to go.
*
* The waiting half comes from `awaitingRider`, the same rule the Orders page
* uses for its assign bar, so the two screens cannot disagree about what counts
* as unassigned. Reusing it is the point — a second definition here would drift
* the first time either was touched.
*/
export function toStops(
deliveries: readonly DeliveryRow[],
orders: readonly OrderRow[],
): Stop[] {
const assigned = assignedFrom(deliveries);
return [
...deliveries.map((row): Stop => ({ kind: 'delivery', row })),
...awaitingRider(orders, assigned).map((row): Stop => ({ kind: 'order', row })),
];
}
function blank(id: string, name: string, detail?: string): Group {
@@ -47,7 +162,7 @@ function blank(id: string, name: string, detail?: string): Group {
id,
name,
...(detail ? { detail } : {}),
deliveries: [],
stops: [],
statuses: {},
delivered: 0,
riders: new Set(),
@@ -55,47 +170,52 @@ function blank(id: string, name: string, detail?: string): Group {
};
}
function fold(group: Group, row: DeliveryRow): void {
group.deliveries.push(row);
const status = statusOf(row);
function fold(group: Group, stop: Stop): void {
group.stops.push(stop);
const status = stopStatus(stop);
group.statuses[status] = (group.statuses[status] ?? 0) + 1;
if (status === 'delivered') group.delivered += 1;
group.value += row.deliveryamt ?? 0;
const rider = String(row.userid ?? '') || (row.ridername ?? '');
if (rider) group.riders.add(rider);
group.value += stopValue(stop);
const rider = stopRider(stop);
if (rider) group.riders.add(String(rider));
}
/** Busiest first, then alphabetical, so the board does not reshuffle randomly. */
function order(groups: Group[]): Group[] {
return groups.sort(
(a, b) => b.deliveries.length - a.deliveries.length || a.name.localeCompare(b.name),
);
return groups.sort((a, b) => b.stops.length - a.stops.length || a.name.localeCompare(b.name));
}
/**
* By store.
*
* Seeded from the branch list. A delivery whose branch is not in the list still
* Seeded from the branch list. A stop whose branch is not in the list still
* gets a group — a branch can be deactivated while its deliveries are out, and
* dropping them would hide live work.
*/
export function groupByStore(
deliveries: readonly DeliveryRow[],
stops: readonly Stop[],
branches: readonly TenantLocation[],
): Group[] {
const map = new Map<string, Group>();
for (const branch of branches) {
map.set(String(branch.locationid), blank(String(branch.locationid), branch.locationname || `Branch ${branch.locationid}`, branch.suburb || branch.city));
map.set(
String(branch.locationid),
blank(
String(branch.locationid),
branch.locationname || `Branch ${branch.locationid}`,
branch.suburb || branch.city,
),
);
}
for (const row of deliveries) {
const id = String(row.locationid ?? 'unknown');
for (const stop of stops) {
const id = String(stopLocation(stop) ?? 'unknown');
let group = map.get(id);
if (!group) {
group = blank(id, row.locationname || `Branch ${id}`);
group = blank(id, stop.row.locationname || `Branch ${id}`);
map.set(id, group);
}
fold(group, row);
fold(group, stop);
}
return order([...map.values()]);
@@ -110,7 +230,7 @@ export function groupByStore(
* nothing. The phone number is what both sides actually carry.
*/
export function groupByCustomer(
deliveries: readonly DeliveryRow[],
stops: readonly Stop[],
customers: readonly CustomerInfo[],
branchName: (locationid: number | undefined) => string | undefined,
): Group[] {
@@ -124,12 +244,12 @@ export function groupByCustomer(
map.set(key, blank(key, name || `Customer ${customer.customerid}`, customer.contactno));
}
for (const row of deliveries) {
const key = phone(row.deliverycontactno) || `row-${row.deliveryid}`;
for (const stop of stops) {
const key = phone(stopContact(stop)) || `row-${stopKey(stop)}`;
let group = map.get(key);
if (!group) {
/*
A delivery to somebody not in the book — a guest checkout, or a customer
A stop for somebody not in the book — a guest checkout, or a customer
registered against another branch. Kept: it is real work.
Falls back to the drop ADDRESS before the word "Customer". A good number
@@ -141,18 +261,18 @@ export function groupByCustomer(
*/
group = blank(
key,
row.deliverycustomer || row.deliveryaddress || 'Customer',
row.deliverycontactno,
stopCustomer(stop) || stopAddress(stop) || 'Customer',
stopContact(stop),
);
map.set(key, group);
}
if (!group.detail) group.detail = branchName(row.locationid);
fold(group, row);
if (!group.detail) group.detail = branchName(stopLocation(stop));
fold(group, stop);
}
// Customers with nothing today would flood the board — thousands of rows for
// a handful of deliveries — so unlike stores, the empty ones are dropped.
return order([...map.values()].filter((group) => group.deliveries.length > 0));
// a handful of stops — so unlike stores, the empty ones are dropped.
return order([...map.values()].filter((group) => group.stops.length > 0));
}
/**
@@ -165,25 +285,32 @@ export function groupByCustomer(
* re-stamped. Do not "simplify" it to `updated`.
*
* Unassigned work is its own group and sorts first, because it is the only
* group anybody has to act on.
* group anybody has to act on. It is also the only group that can hold order
* stops: everything else here is, by definition, carried by somebody.
*/
export const UNASSIGNED = 'unassigned';
export function groupByRider(deliveries: readonly DeliveryRow[]): Group[] {
export function groupByRider(stops: readonly Stop[]): Group[] {
const map = new Map<string, Group>();
for (const row of deliveries) {
const id = String(row.userid ?? '') || UNASSIGNED;
for (const stop of stops) {
const rider = stopRider(stop);
const id = rider ? String(rider) : UNASSIGNED;
let group = map.get(id);
if (!group) {
group = blank(id, id === UNASSIGNED ? 'Not assigned' : row.ridername || `Rider ${id}`, row.ridercontact);
const name =
id === UNASSIGNED
? 'Waiting for a rider'
: (stop.kind === 'delivery' ? stop.row.ridername : '') || `Rider ${id}`;
const detail = stop.kind === 'delivery' ? stop.row.ridercontact : undefined;
group = blank(id, name, detail);
map.set(id, group);
}
fold(group, row);
fold(group, stop);
}
for (const group of map.values()) {
group.deliveries.sort((a, b) => (a.assigntime ?? '').localeCompare(b.assigntime ?? ''));
group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b)));
}
const groups = order([...map.values()]);
@@ -215,9 +342,14 @@ export interface LastSeen {
*
* Sparse in practice: 1 of 7 production deliveries carries one. The empty case
* is the common case and has to read as "not reported" rather than as a blank.
*
* Order stops are skipped outright — nobody is carrying them, so there is no
* position to report and no rider to attribute one to.
*/
export function lastSeen(deliveries: readonly DeliveryRow[]): LastSeen | null {
const withPosition = deliveries
export function lastSeen(stops: readonly Stop[]): LastSeen | null {
const positioned = stops
.filter((stop): stop is { kind: 'delivery'; row: DeliveryRow } => stop.kind === 'delivery')
.map((stop) => stop.row)
.filter((row) => {
const lat = Number(row.riderslat ?? '');
const lon = Number(row.riderslon ?? '');
@@ -225,7 +357,7 @@ export function lastSeen(deliveries: readonly DeliveryRow[]): LastSeen | null {
})
.sort((a, b) => stampOf(b).localeCompare(stampOf(a)));
const row = withPosition[0];
const row = positioned[0];
if (!row) return null;
return {
@@ -233,7 +365,7 @@ export function lastSeen(deliveries: readonly DeliveryRow[]): LastSeen | null {
lon: Number(row.riderslon),
deliveryid: row.deliveryid,
...(row.orderid ? { orderid: row.orderid } : {}),
status: statusOf(row),
status: lower(row.orderstatus) || 'unknown',
at: stampOf(row) || null,
};
}
@@ -267,32 +399,26 @@ export interface DayTotals {
value: number;
}
export function dayTotals(deliveries: readonly DeliveryRow[]): DayTotals {
export function dayTotals(stops: readonly Stop[]): DayTotals {
const riders = new Set<string>();
let delivered = 0;
let unassigned = 0;
let outstanding = 0;
let value = 0;
for (const row of deliveries) {
const status = statusOf(row);
for (const stop of stops) {
const status = stopStatus(stop);
if (status === 'delivered') delivered += 1;
if (!row.userid) unassigned += 1;
if (row.userid) riders.add(String(row.userid));
value += row.deliveryamt ?? 0;
// Everything still to do — cancelled work is finished, not outstanding, and
// a waiting order is the most outstanding thing on the board.
if (status !== 'delivered' && status !== 'cancelled') outstanding += 1;
const rider = stopRider(stop);
if (rider) riders.add(String(rider));
else unassigned += 1;
value += stopValue(stop);
}
return {
stops: deliveries.length,
delivered,
// Everything still to do — cancelled work is finished, not outstanding.
outstanding: deliveries.filter((row) => {
const status = statusOf(row);
return status !== 'delivered' && status !== 'cancelled';
}).length,
unassigned,
riders: riders.size,
value,
};
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
}
/** One day as Fiesta wants it, and the stepper's arithmetic. */

View File

@@ -13,6 +13,8 @@ import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineS
import { useAuth } from '@/auth/AuthContext';
import { count, money } from '../format';
import { Drawer } from '../Drawer';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
buildTemplateWorkbook,
parseSalesWorkbook,
@@ -65,6 +67,9 @@ export function CounterSalesDrawer({
});
const rows = parsed?.rows ?? [];
// A new file is a new sheet, so the pager starts over.
const rowsPaged = usePaged(rows, { resetKey: rows.length });
const resultsPaged = usePaged(result?.results ?? [], { resetKey: result?.results?.length ?? 0 });
const totals = useMemo(() => summarise(rows), [rows]);
const isBlocked = (parsed?.fatal.length ?? 0) > 0 || totals.errors > 0;
@@ -145,7 +150,7 @@ export function CounterSalesDrawer({
</tr>
</thead>
<tbody>
{result.results.map((entry, index) => (
{resultsPaged.rows.map((entry, index) => (
<tr key={`${entry.locationid}-${entry.billno}-${index}`}>
<Td>{entry.locationname || `Branch ${entry.locationid}`}</Td>
<Td>{entry.billno || '—'}</Td>
@@ -159,6 +164,7 @@ export function CounterSalesDrawer({
</tbody>
</table>
</div>
<TablePager paged={resultsPaged} label="results" />
</Card>
<HStack gap={1} justify="end">
@@ -274,7 +280,7 @@ export function CounterSalesDrawer({
</tr>
</thead>
<tbody>
{rows.map((row) => (
{rowsPaged.rows.map((row) => (
<tr key={row.excelRow}>
<Td isMuted>{row.excelRow}</Td>
{locationid ? null : (
@@ -292,6 +298,7 @@ export function CounterSalesDrawer({
</tbody>
</table>
</div>
<TablePager paged={rowsPaged} label="sheet rows" />
</Card>
{upload.isError ? <Problem message={errorMessage(upload.error)} /> : null}

View File

@@ -29,6 +29,8 @@ import {
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
SYNC_COLOR,
SYNC_LABEL,
@@ -512,6 +514,8 @@ function TillTable({ rows }: { rows: Row[] }) {
row.health.terminals.map((terminal) => ({ branch: row.branch.locationname, terminal })),
);
const paged = usePaged(terminals);
if (terminals.length === 0) {
return (
<VStack gap={0.5} padding={3}>
@@ -527,6 +531,7 @@ function TillTable({ rows }: { rows: Row[] }) {
}
return (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
@@ -540,7 +545,7 @@ function TillTable({ rows }: { rows: Row[] }) {
</tr>
</thead>
<tbody>
{terminals.map(({ branch, terminal }) => (
{paged.rows.map(({ branch, terminal }) => (
<tr key={`${branch}:${terminal.terminalId}`}>
<Td isStrong>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
@@ -569,6 +574,8 @@ function TillTable({ rows }: { rows: Row[] }) {
))}
</tbody>
</table>
<TablePager paged={paged} label="tills" />
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
@@ -16,10 +16,15 @@ import {
UserX,
Users,
} from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { useCustomers, useDeliveries, useTenantLocations } from '@/queries/hooks';
import { useSelection, type Selection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -33,10 +38,19 @@ import {
isToday,
lastSeen,
shiftDay,
statusOf,
stopAddress,
stopCustomer,
stopKey,
stopOrderId,
stopStatus,
stopTime,
stopValue,
toStops,
UNASSIGNED,
WAITING,
ymd,
type Group,
type Stop,
type ViewMode,
} from '../dispatchModel';
import './dispatch.css';
@@ -66,18 +80,38 @@ import './dispatch.css';
* data does not have. The grouping, the rounds and the rider positions all work
* without one, and the map is worth adding the day real drops spread out.
*/
/**
* The delivery ladder plus the one status that is not on it.
*
* `waiting` is red because it is the only status on this board that means
* nobody has done anything — every other colour describes work in motion. It is
* added here rather than in `DELIVERY_STATUS` because it is not a delivery
* status: no `deliveries` row ever holds it, and putting it there would offer
* it to the delivery filters, where it can never match.
*/
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
export function DispatchPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const [day, setDay] = useDayParam();
const [mode, setMode] = useState<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<DeliveryRow | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
/*
Orders too, over the same window.
Not redundant with the deliveries read: a `deliveries` row only exists once
somebody has been assigned, so an order nobody has picked up is invisible to
it. Without this the board showed "Nothing out on this day" while R mart had
29 orders waiting, and "Not assigned" could never be anything but zero.
*/
const orders = useOrders(query);
const locations = useTenantLocations(tenantid || undefined);
// Only fetched for the customer view — the book runs to thousands of rows and
// no other grouping needs it.
@@ -87,8 +121,11 @@ export function DispatchPage() {
: undefined,
);
const rows = useMemo(() => deliveries.data ?? [], [deliveries.data]);
const totals = useMemo(() => dayTotals(rows), [rows]);
const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data],
);
const totals = useMemo(() => dayTotals(stops), [stops]);
const branchName = useMemo(
() => (locationid: number | undefined) =>
@@ -97,13 +134,55 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
if (mode === 'stores') return groupByStore(rows, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(rows, customers.data ?? [], branchName);
return groupByRider(rows);
}, [mode, rows, locations.data, branches, customers.data, branchName]);
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
}, [mode, stops, locations.data, branches, customers.data, branchName]);
const open = groups.find((group) => group.id === focused) ?? null;
/* ── Assigning, from the queue itself ────────────────────────────────────
The same AssignBar the Orders page uses, on the same `assignability` rule.
Reused rather than rebuilt: a second assign path would be a second place
for the delivery-status vocabulary to drift, and writing the wrong status
means the rider's app never sees the job. */
const assigned = useMemo(() => assignedFrom(deliveries.data ?? []), [deliveries.data]);
const branchOf = useMemo(
() => (row: OrderRow): TenantLocation | undefined =>
branches.find((branch) => branch.locationid === row.locationid),
[branches],
);
/** The waiting orders on screen — only the unassigned group holds any. */
const waiting = useMemo(
() =>
open?.id === UNASSIGNED
? open.stops.flatMap((stop) => (stop.kind === 'order' ? [stop.row] : []))
: [],
[open],
);
/*
Ticks are built from what can ACTUALLY be sent, not from every waiting row.
Handing the selection every row ticked the disabled boxes too — they went
grey-and-checked and the bar counted more than it would assign, which is how
a person stops trusting the ticks. The Orders page learnt this the hard way.
*/
const selectable = useMemo(
() =>
waiting
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
.map((row) => row.orderheaderid),
[waiting, branchOf, assigned],
);
const picked = useSelection(selectable);
const pickedOrders = useMemo(
() => waiting.filter((row) => picked.has(row.orderheaderid)),
[waiting, picked],
);
const changeMode = (next: ViewMode) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
@@ -194,7 +273,27 @@ export function DispatchPage() {
<div className="dispatch-main">
{open ? (
<GroupDetail group={open} mode={mode} onOpen={setDetail} />
<GroupDetail
group={open}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
@@ -221,7 +320,7 @@ export function DispatchPage() {
</div>
{detail ? (
<OrderDetailDrawer row={detail} kind="delivery" onClose={() => setDetail(null)} />
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
</VStack>
);
@@ -353,7 +452,7 @@ function GroupList({
<div className="group-list">
{groups.map((group) => {
const done = group.delivered;
const total = group.deliveries.length;
const total = group.stops.length;
return (
<button
key={group.id}
@@ -386,7 +485,7 @@ function GroupList({
key={status}
style={{
width: `${(n / total) * 100}%`,
background: statusColor(DELIVERY_STATUS, status),
background: statusColor(DISPATCH_STATUS, status),
}}
title={`${n} ${status}`}
/>
@@ -406,12 +505,31 @@ function GroupDetail({
group,
mode,
onOpen,
selection,
verdictOf,
assignBar,
}: {
group: Group;
mode: ViewMode;
onOpen: (row: DeliveryRow) => void;
onOpen: (stop: Stop) => void;
/**
* Ticks, and the bar they feed. Present only on the waiting queue — every
* other group is work already handed over, and offering to assign it again
* would be offering to double-book a rider.
*/
selection?: Selection;
/**
* The whole verdict, not just a boolean — a row that cannot be assigned has
* to be able to say WHY on hover, and re-deriving the reason here with
* whatever arguments were to hand is how a tooltip ends up confidently wrong.
*/
verdictOf?: (row: OrderRow) => Assignability;
assignBar?: ReactNode;
}) {
const seen = lastSeen(group.deliveries);
const seen = lastSeen(group.stops);
// Reset to page 1 whenever a different group is opened — page 3 of the last
// rider's round means nothing on this one.
const paged = usePaged(group.stops, { resetKey: group.id });
return (
<VStack gap={1.5}>
@@ -429,7 +547,7 @@ function GroupDetail({
) : null}
</VStack>
<Text type="body" size="sm" color="secondary">
{group.deliveries.length} stop{group.deliveries.length === 1 ? '' : 's'} ·{' '}
{group.stops.length} stop{group.stops.length === 1 ? '' : 's'} ·{' '}
{moneyExact(group.value)}
</Text>
</HStack>
@@ -438,7 +556,9 @@ function GroupDetail({
Labelled with its age and the status that produced it, because it
is a last-known point and not a live feed — drawing it as a dot on
a map would claim otherwise. */}
{mode === 'riders' ? (
{/* Never on the waiting queue: nobody is carrying that work, so
"No position reported" states the obvious and reads as a fault. */}
{mode === 'riders' && group.id !== UNASSIGNED ? (
seen ? (
<div className="last-seen">
<MapPin size={14} />
@@ -476,11 +596,31 @@ function GroupDetail({
</VStack>
</Card>
{/* Above the table, the way the Orders page does it: assigning is done
WHILE reading the stops, glancing between the drop addresses and who
is free, not on a screen you have to leave the list for. */}
{assignBar}
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
@@ -491,38 +631,67 @@ function GroupDetail({
</tr>
</thead>
<tbody>
{group.deliveries.map((row, index) => (
<tr key={row.deliveryid} onClick={() => onOpen(row)}>
<td className="num">{index + 1}</td>
<td>
<strong>{row.orderid || `#${row.orderheaderid}`}</strong>
<span>{row.assigntime ? shortTime(row.assigntime) : ''}</span>
</td>
<td>
<strong>{row.deliverycustomer || '—'}</strong>
<span>{row.deliveryaddress || row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>{row.ridername || <em className="muted">not assigned</em>}</td>
) : null}
<td className="num">{km(row)}</td>
<td className="num">{moneyExact(row.deliveryamt ?? 0)}</td>
<td>
<span
className="stop-status"
style={{
color: statusColor(DELIVERY_STATUS, statusOf(row)),
background: `color-mix(in oklab, ${statusColor(DELIVERY_STATUS, statusOf(row))} 12%, transparent)`,
}}
>
{statusOf(row)}
</span>
</td>
</tr>
))}
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
</VStack>
);

View File

@@ -37,6 +37,8 @@ import { ProductsPanel } from '../ProductsPanel';
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
import type { StepId } from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { branchLabel, count, money } from '../format';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
@@ -272,6 +274,9 @@ function RequestsPanel({
onSettled: refreshAfterDecision,
});
const settled = requests.filter((entry) => entry.status !== 'Pending');
/* This list used to be sliced to the first 25 with nothing to say so — the
26th decision was simply unreachable. Paged instead. */
const settledPaged = usePaged(settled);
return (
<VStack gap={3}>
@@ -578,7 +583,7 @@ function RequestsPanel({
</tr>
</thead>
<tbody>
{settled.slice(0, 25).map((request) => (
{settledPaged.rows.map((request) => (
<tr key={request.requestid}>
<Td isStrong>
{request.productname ?? `Product ${request.productid}`}
@@ -598,6 +603,7 @@ function RequestsPanel({
</tbody>
</table>
</div>
<TablePager paged={settledPaged} label="decisions" />
</Card>
</VStack>
) : null}
@@ -628,6 +634,8 @@ function StockPanel() {
});
const rows = statement.data ?? [];
// Keyword is a filter, so a new search starts at page 1.
const stockPaged = usePaged(rows, { resetKey: keyword });
const value = rows.reduce(
(sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0),
0,
@@ -721,7 +729,7 @@ function StockPanel() {
</tr>
</thead>
<tbody>
{rows.map((row) => {
{stockPaged.rows.map((row) => {
const closing = row.closing ?? 0;
return (
<tr key={row.productid}>
@@ -751,6 +759,7 @@ function StockPanel() {
</tbody>
</table>
</div>
<TablePager paged={stockPaged} label="stock rows" />
</>
)}
</Card>

View File

@@ -28,6 +28,8 @@ import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
type Tab = 'revenue' | 'sales' | 'inventory';
@@ -70,6 +72,8 @@ export function ReportsPage() {
[scoped, orders.data, posSales],
);
const paged = usePaged(rows);
const totals = rows.reduce(
(acc, row) => ({
appRevenue: acc.appRevenue + row.appRevenue,
@@ -185,7 +189,7 @@ export function ReportsPage() {
</tr>
</thead>
<tbody>
{rows.map((row) => (
{paged.rows.map((row) => (
<tr key={row.branch.locationid}>
<Td isStrong>
{branchLabel(row.branch.locationname)}
@@ -208,6 +212,7 @@ export function ReportsPage() {
</tbody>
</table>
</div>
<TablePager paged={paged} label="branches" />
</Card>
</VStack>
) : null}

View File

@@ -37,6 +37,8 @@ import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter';
@@ -65,6 +67,11 @@ export function SalesPage() {
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const [keyword, setKeyword] = useState('');
/* What the tables are currently showing. Any change to it sends every pager
back to page 1 — landing on page 4 of a search you just typed reads as an
empty result. */
const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`;
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
null,
);
@@ -334,6 +341,7 @@ export function SalesPage() {
) : null}
<OrdersTable
rows={orderRows}
queryKey={queryKey}
isLoading={orders.isLoading}
showBranch={selected === null}
selection={picked}
@@ -402,6 +410,7 @@ export function SalesPage() {
/>
<DeliveriesTable
rows={deliveryRows}
queryKey={queryKey}
isLoading={deliveries.isLoading}
showBranch={selected === null}
onOpen={(row) => setDetail({ row, kind: 'delivery' })}
@@ -465,6 +474,7 @@ export function SalesPage() {
</HStack>
<BillsTable
rows={billRows}
queryKey={queryKey}
isLoading={billPages.some((page) => page.isLoading)}
showBranch={selected === null}
/>
@@ -571,15 +581,21 @@ function OrdersTable({
branchOf,
assigned,
onOpen,
queryKey,
}: {
rows: OrderRow[];
isLoading: boolean;
/** Identifies the current filter set; changing it returns the pager to page 1. */
queryKey: string;
showBranch: boolean;
selection: ReturnType<typeof useSelection>;
branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>;
onOpen: (row: OrderRow) => void;
}) {
// Above the early returns — a hook cannot be called conditionally.
const paged = usePaged(rows, { resetKey: queryKey });
if (isLoading) return <Loading what="orders" />;
if (rows.length === 0) {
return (
@@ -623,7 +639,7 @@ function OrdersTable({
<tbody>
{/* Keyed on orderheaderid, never orderid — `orderid` is not unique
in this tenant's data, a defect the old console hit first. */}
{rows.map((row, index) => {
{paged.rows.map((row, index) => {
const cod = row.collectionamt ?? 0;
const amount = orderValue(row);
const verdict = assignability(row, branchOf(row), assigned);
@@ -657,7 +673,7 @@ function OrdersTable({
/>
</td>
<Td isMuted>
<Mono>{index + 1}</Mono>
<Mono>{paged.from + index}</Mono>
</Td>
<Td>
<Primary>
@@ -716,6 +732,7 @@ function OrdersTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="orders" />
</Card>
);
}
@@ -727,12 +744,16 @@ function DeliveriesTable({
isLoading,
showBranch,
onOpen,
queryKey,
}: {
rows: DeliveryRow[];
isLoading: boolean;
showBranch: boolean;
onOpen: (row: DeliveryRow) => void;
queryKey: string;
}) {
const paged = usePaged(rows, { resetKey: queryKey });
if (isLoading) return <Loading what="deliveries" />;
if (rows.length === 0) {
return (
@@ -762,7 +783,7 @@ function DeliveriesTable({
</tr>
</thead>
<tbody>
{rows.map((row) => {
{paged.rows.map((row) => {
// The backend leaves customer and contact blank on app-created
// jobs but fills the address, so both sides fall back rather than
// rendering an empty cell.
@@ -882,6 +903,7 @@ function DeliveriesTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
);
}
@@ -892,11 +914,15 @@ function BillsTable({
rows,
isLoading,
showBranch,
queryKey,
}: {
rows: { bill: PosSale; branch: string }[];
isLoading: boolean;
showBranch: boolean;
queryKey: string;
}) {
const paged = usePaged(rows, { resetKey: queryKey });
if (isLoading) return <Loading what="counter bills" />;
if (rows.length === 0) {
return (
@@ -930,7 +956,7 @@ function BillsTable({
{/* Keyed on terminalorderid — the UUID minted at the till. The
invoice number is unique only per terminal, so a replaced till
restarting its series would collide. */}
{rows.map(({ bill, branch }) => {
{paged.rows.map(({ bill, branch }) => {
const lag = syncLagMs(bill);
// The recovered instant, not the stamp — see `billedAtMs`.
const billedAt = billedAtMs(bill);
@@ -991,6 +1017,7 @@ function BillsTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="bills" />
</Card>
);
}

View File

@@ -27,6 +27,8 @@ import { useCounterLabels, type CounterLabels } from '../counterLabels';
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
/**
* Counters — the till board, written for the person who actually opens it.
@@ -573,6 +575,8 @@ function FineTable({
terminalId: counter.terminalId,
}));
const paged = usePaged(rows);
const columns: TableColumn<FineRowData>[] = [
{
key: 'name',
@@ -675,7 +679,7 @@ function FineTable({
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, overflow: 'hidden' }}>
<Table<FineRowData>
data={rows}
data={paged.rows}
columns={columns}
idKey="key"
density="balanced"
@@ -683,6 +687,7 @@ function FineTable({
dividers="rows"
/>
</div>
<TablePager paged={paged} label="terminals" />
</Card>
</VStack>
);

View File

@@ -23,6 +23,8 @@ import { branchLabel } from '../format';
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
import { RiderDrawer } from '../RiderDrawer';
import { isUnplaced } from '../staffPlacement';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
type Group = 'store' | 'till' | 'rider';
@@ -319,6 +321,9 @@ function RiderTable({
isLoading: boolean;
onEdit: (row: RiderRosterRow) => void;
}) {
// Above the early returns — a hook cannot be called conditionally.
const paged = usePaged(rows);
if (isLoading) {
return (
<Card padding={0} elevation="low">
@@ -365,7 +370,7 @@ function RiderTable({
</tr>
</thead>
<tbody>
{rows.map((rider) => (
{paged.rows.map((rider) => (
<tr key={rider.userid}>
<Td>
<strong style={{ display: 'block' }}>{rider.fullname || `Rider ${rider.userid}`}</strong>
@@ -407,6 +412,7 @@ function RiderTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="riders" />
</Card>
);
}
@@ -426,6 +432,8 @@ function StaffTable({
branches: { locationid: number; locationname: string }[];
onEdit: (row: StaffInfo) => void;
}) {
const paged = usePaged(rows);
if (isLoading) return <Loading what="the directory" />;
if (rows.length === 0) {
return (
@@ -453,7 +461,7 @@ function StaffTable({
</tr>
</thead>
<tbody>
{rows.map((person) => {
{paged.rows.map((person) => {
const branch = branches.find((entry) => entry.locationid === person.locationid);
return (
<tr key={person.userid}>
@@ -497,6 +505,7 @@ function StaffTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="people" />
</Card>
);
}
@@ -512,6 +521,8 @@ function TillTable({
showBranch: boolean;
onEdit: (row: PosUser, locationid: number) => void;
}) {
const paged = usePaged(rows);
if (isLoading) return <Loading what="terminal accounts" />;
if (rows.length === 0) {
return (
@@ -539,7 +550,7 @@ function TillTable({
</tr>
</thead>
<tbody>
{rows.map(({ person, branch, locationid }) => (
{paged.rows.map(({ person, branch, locationid }) => (
<tr key={person.user_id}>
<Td isStrong>
{person.full_name || '—'}
@@ -588,6 +599,7 @@ function TillTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="terminal accounts" />
</Card>
);
}

View File

@@ -12,6 +12,8 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
import { Drawer } from '@/features/store-admin/Drawer';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
/**
* Customers, for one shop.
@@ -44,6 +46,7 @@ export function StoreCustomersPage() {
.includes(term),
);
}, [customers.data, keyword]);
const paged = usePaged(rows, { resetKey: keyword });
const withPhone = rows.filter((customer) => Boolean(customer.contactno)).length;
@@ -110,12 +113,13 @@ export function StoreCustomersPage() {
</tr>
</thead>
<tbody>
{rows.map((customer) => (
{paged.rows.map((customer) => (
<Row key={customer.customerid} customer={customer} onOpen={() => setOpen(customer)} />
))}
</tbody>
</table>
</div>
<TablePager paged={paged} label="customers" />
</Card>
</VStack>
)}

View File

@@ -34,6 +34,8 @@ import {
stockHealth,
} from '../requestState';
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
type Tab = 'catalogue' | 'stock' | 'requests';
@@ -251,6 +253,9 @@ function CataloguePanel({
onAsk: (product: Product, qty: number) => void;
onAskMany: (products: Product[]) => void;
}) {
// Category is a filter, so changing it belongs back on page 1.
const paged = usePaged(rows, { resetKey: category });
/**
* Rows with no OPEN request can be ticked — including ones asked for before.
*
@@ -367,7 +372,7 @@ function CataloguePanel({
</tr>
</thead>
<tbody>
{rows.map((product) => (
{paged.rows.map((product) => (
<CatalogueRow
key={product.productid}
product={product}
@@ -385,6 +390,7 @@ function CataloguePanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="products" />
</Card>
</VStack>
)}
@@ -513,6 +519,8 @@ function CatalogueRow({
/* ── My stock ────────────────────────────────────────────────────────────── */
function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading: boolean }) {
const paged = usePaged(rows);
if (isLoading) return <Note title="Reading the stock ledger…" />;
if (rows.length === 0) {
return (
@@ -558,7 +566,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
</tr>
</thead>
<tbody>
{rows.map((row) => {
{paged.rows.map((row) => {
const closing = row.closing ?? 0;
const health = stockHealth(closing);
return (
@@ -606,6 +614,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
</tbody>
</table>
</div>
<TablePager paged={paged} label="stock rows" />
</Card>
</VStack>
);
@@ -614,6 +623,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
/* ── My requests ─────────────────────────────────────────────────────────── */
function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: boolean }) {
const paged = usePaged(rows);
const client = useQueryClient();
const [done, setDone] = useState<Set<number>>(new Set());
@@ -680,7 +690,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
</tr>
</thead>
<tbody>
{rows.map((row) => {
{paged.rows.map((row) => {
const state = requestState(row) ?? 'pending';
const isSettled = done.has(row.requestid) || state === 'received';
return (
@@ -725,6 +735,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
</tbody>
</table>
</div>
<TablePager paged={paged} label="requests" />
</Card>
</VStack>
);

View File

@@ -11,6 +11,8 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
/**
* Counter staff — the people who can open this shop's till.
@@ -49,6 +51,7 @@ export function StoreStaffPage() {
.filter((person) => (person.role ?? '').toLowerCase() !== 'supervisor')
.sort((a, b) => (a.full_name ?? '').localeCompare(b.full_name ?? ''));
}, [page?.data]);
const paged = usePaged(rows);
const active = rows.filter((person) => (person.status ?? '').toLowerCase() !== 'inactive').length;
@@ -111,7 +114,7 @@ export function StoreStaffPage() {
</tr>
</thead>
<tbody>
{rows.map((person) => (
{paged.rows.map((person) => (
<tr key={person.user_id}>
<Td isStrong>{person.full_name || '—'}</Td>
<Td>
@@ -152,6 +155,7 @@ export function StoreStaffPage() {
</tbody>
</table>
</div>
<TablePager paged={paged} label="people" />
</Card>
</VStack>
)}