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

@@ -0,0 +1,49 @@
import { Pagination } from '@astryxdesign/core/Pagination';
import type { Paged } from './usePaged';
import { PAGE_SIZES } from './usePaged';
import './tablePager.css';
/**
* The pager that sits under a table.
*
* One component so every table in the console counts, labels and behaves the
* same way, rather than each page inventing its own row of buttons.
*
* ── It hides itself ─────────────────────────────────────────────────────────
*
* Nothing renders while everything fits on one page. That is what makes it safe
* to put under EVERY table, including the ones that usually hold four rows: a
* pager reading "1–4 of 4" next to a dead prev/next pair is noise, and noise
* under every table is worse than no pager at all. It appears exactly when it
* has something to offer.
*
* `variant="count"` — "21–40 of 96" — rather than a strip of page numbers.
* With 25 rows a page a busy day is four pages, and the number an operator
* actually wants is how much is left, not which of four buttons is lit.
*/
export function TablePager({
paged,
label = 'rows',
}: {
paged: Paged<unknown>;
/** What is being counted, for the screen-reader label: "orders", "products". */
label?: string;
}) {
if (paged.totalPages <= 1) return null;
return (
<div className="table-pager">
<Pagination
page={paged.page}
onChange={paged.setPage}
totalItems={paged.total}
pageSize={paged.pageSize}
pageSizeOptions={PAGE_SIZES}
onPageSizeChange={paged.setPageSize}
variant="count"
size="sm"
label={`Page through ${label}`}
/>
</div>
);
}

View File

@@ -0,0 +1,24 @@
/* ══ The pager under a table ═══════════════════════════════════════════════
Sits inside the table's own card, below the last row, so it reads as part of
the table rather than as a separate control floating beneath it. */
.table-pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 8px 12px;
/* The rule is the seam between the last row and the controls. Rows already
draw their own bottom border, so this only shows where the table ends. */
border-top: 1px solid var(--color-line);
}
/* On a narrow window the count and the buttons stop fitting side by side.
Centred rather than left-aligned once wrapped, so the two lines read as one
block instead of a ragged edge. */
@media (max-width: 560px) {
.table-pager {
justify-content: center;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,104 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from './usePaged';
/**
* `usePaged` is a hook, and there is no React renderer in this suite — the repo
* runs `tsx --test`, not jsdom. So the arithmetic it depends on is written here
* as the pure function the hook applies, and asserted directly.
*
* That is worth doing rather than skipping: every bug this hook exists to
* prevent is an arithmetic one at a boundary — an empty list, a list that
* shrinks under a page, a page size that divides exactly.
*/
interface Slice {
page: number;
totalPages: number;
from: number;
to: number;
rows: number[];
}
/** Mirrors the derivation in `usePaged`, including the clamp. */
function slice(rows: readonly number[], requestedPage: number, pageSize: number): Slice {
const total = rows.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(requestedPage, totalPages);
return {
page,
totalPages,
from: total === 0 ? 0 : (page - 1) * pageSize + 1,
to: Math.min(page * pageSize, total),
rows: rows.slice((page - 1) * pageSize, page * pageSize),
};
}
const upTo = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
test('a full first page', () => {
const out = slice(upTo(96), 1, 25);
assert.equal(out.totalPages, 4);
assert.deepEqual([out.from, out.to], [1, 25]);
assert.equal(out.rows[0], 1);
assert.equal(out.rows.at(-1), 25);
});
test('a middle page counts from the right place', () => {
// The off-by-one everybody writes at least once.
const out = slice(upTo(96), 3, 25);
assert.deepEqual([out.from, out.to], [51, 75]);
assert.equal(out.rows[0], 51);
});
test('the last page is short, and says so', () => {
const out = slice(upTo(96), 4, 25);
assert.deepEqual([out.from, out.to], [76, 96]);
assert.equal(out.rows.length, 21);
});
test('an exact multiple does not produce a trailing empty page', () => {
// 100 rows at 25 is four pages, not five.
assert.equal(slice(upTo(100), 1, 25).totalPages, 4);
});
test('no rows is one page reading 0–0, not zero pages', () => {
/*
`Math.ceil(0 / 25)` is 0, and a totalPages of 0 makes the pager render "page 1
of 0" and every control dead. One empty page is the honest shape.
*/
const out = slice([], 1, 25);
assert.equal(out.totalPages, 1);
assert.deepEqual([out.from, out.to], [0, 0]);
assert.deepEqual(out.rows, []);
});
test('a list that shrinks under you clamps instead of going blank', () => {
/*
The bug this hook exists for. Sitting on page 4, somebody narrows the filter
to nine results. Slicing at the requested page would read rows 76–100 of a
nine-row list and render an empty table with the controls saying page 4 —
which looks exactly like the data vanished.
*/
const out = slice(upTo(9), 4, 25);
assert.equal(out.page, 1, 'clamped to the last page that exists');
assert.equal(out.rows.length, 9);
assert.deepEqual([out.from, out.to], [1, 9]);
});
test('clamping lands on the LAST page, not always the first', () => {
// 60 rows at 25 is three pages; from page 9 you belong on 3, not on 1.
const out = slice(upTo(60), 9, 25);
assert.equal(out.page, 3);
assert.deepEqual([out.from, out.to], [51, 60]);
});
test('every offered page size divides the work sensibly', () => {
// Guards the constants themselves: a 0 or a negative here would make
// totalPages Infinity and hang the pager.
for (const size of PAGE_SIZES) {
assert.ok(size > 0 && Number.isInteger(size), `${size} is a usable page size`);
assert.equal(slice(upTo(100), 1, size).rows.length, Math.min(size, 100));
}
assert.ok(PAGE_SIZES.includes(DEFAULT_PAGE_SIZE), 'the default is one of the choices');
});

112
src/components/usePaged.ts Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useMemo, useState } from 'react';
/**
* Paging for a table, over rows already in hand.
*
* ── Why client-side ─────────────────────────────────────────────────────────
*
* Fiesta pages properly — `pageno` genuinely shifts the window, verified
* against `getorders`. What it does NOT return is a total: the envelope carries
* `code`, `details`, `message`, `status` and nothing else. So a server-paged
* table could offer next/prev and never honestly say "of 12 pages", and could
* not tell a last page from an empty one until it fetched it.
*
* Every list here already fetches a bounded window (200 rows, 500 for the
* customer book) and renders all of it. Paging that window client-side gives a
* real total, real page numbers, instant page turns, and works identically for
* the tables that have no server paging at all — grouped dispatch stops,
* reports, anything derived. When a table outgrows its fetch window the answer
* is to raise the window or move that ONE table to cursor paging with
* `hasMore`, not to make every table pretend.
*
* ── What this hook is actually for ──────────────────────────────────────────
*
* The slicing is the trivial part. The part worth having in one place, tested,
* is what happens when the rows underneath change — which is where hand-rolled
* paging goes wrong: you filter down to three results while on page 5 and the
* table renders empty with no way back.
*/
export interface Paged<T> {
/** 1-based, matching the design system's Pagination. */
page: number;
setPage: (page: number) => void;
pageSize: number;
setPageSize: (size: number) => void;
/** Just this page's rows. */
rows: T[];
/** Every row, before slicing. */
total: number;
totalPages: number;
/** 1-based inclusive range on screen, for "showing 21–40 of 96". Zero when empty. */
from: number;
to: number;
}
export const DEFAULT_PAGE_SIZE = 25;
/** The choices offered in the page-size selector. */
export const PAGE_SIZES = [10, 25, 50, 100];
export function usePaged<T>(
rows: readonly T[],
options: {
pageSize?: number;
/**
* Changing this sends the table back to page 1.
*
* Clamping alone is not enough. Switching branch, day or status tab can
* hand back a DIFFERENT set of rows that happens to be at least as long as
* the old one — nothing to clamp — and the operator is left reading page 4
* of something they just started looking at. Pass whatever identifies the
* query: a day, a branch id, a status, or a template string of several.
*/
resetKey?: string | number;
} = {},
): Paged<T> {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
const total = rows.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { resetKey } = options;
useEffect(() => {
setPage(1);
}, [resetKey]);
/*
Clamped on the way out as well as reset above.
A row set can shrink under a page without the query changing at all — a
delivery gets marked delivered and leaves the tab, someone types another
letter into the search. Reading `page` directly would then slice past the end
and render an empty table on page 5 of 2, which looks like the data
disappeared. Deriving the safe page rather than setting state in an effect
also means the correct rows render on the FIRST pass, with no empty frame in
between.
*/
const safePage = Math.min(page, totalPages);
const pageRows = useMemo(
() => rows.slice((safePage - 1) * pageSize, safePage * pageSize),
[rows, safePage, pageSize],
);
return {
page: safePage,
setPage,
pageSize,
setPageSize: (size: number) => {
// Back to the first page: keeping the number would land you somewhere
// unrelated, since page 4 of 10-per-page is page 1 of 50-per-page.
setPageSize(size);
setPage(1);
},
rows: pageRows,
total,
totalPages,
from: total === 0 ? 0 : (safePage - 1) * pageSize + 1,
to: Math.min(safePage * pageSize, total),
};
}

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>
)}