diff --git a/src/components/TablePager.tsx b/src/components/TablePager.tsx new file mode 100644 index 0000000..7d723ce --- /dev/null +++ b/src/components/TablePager.tsx @@ -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; + /** What is being counted, for the screen-reader label: "orders", "products". */ + label?: string; +}) { + if (paged.totalPages <= 1) return null; + + return ( +
+ +
+ ); +} diff --git a/src/components/tablePager.css b/src/components/tablePager.css new file mode 100644 index 0000000..c578720 --- /dev/null +++ b/src/components/tablePager.css @@ -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; + } +} diff --git a/src/components/usePaged.test.ts b/src/components/usePaged.test.ts new file mode 100644 index 0000000..3f68190 --- /dev/null +++ b/src/components/usePaged.test.ts @@ -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'); +}); diff --git a/src/components/usePaged.ts b/src/components/usePaged.ts new file mode 100644 index 0000000..330aafb --- /dev/null +++ b/src/components/usePaged.ts @@ -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 { + /** 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( + 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 { + 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), + }; +} diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx index 0f7ea62..05097e5 100644 --- a/src/features/nearle-admin/import/SheetImportPanel.tsx +++ b/src/features/nearle-admin/import/SheetImportPanel.tsx @@ -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 { 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 ? ( - - data={preview} - columns={previewColumns} - idKey="productsku" - density="compact" - dividers="rows" - /> + <> + + data={previewPaged.rows} + columns={previewColumns} + idKey="productsku" + density="compact" + dividers="rows" + /> + + ) : null} {/* Real progress, from the batch. diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx index 5ff2b4e..d36cdd4 100644 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx @@ -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 { 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. */}
- data={rows} + data={paged.rows} columns={columns} idKey="locationid" density="balanced" @@ -264,6 +268,7 @@ export function StoreDetailPage() { dividers="rows" />
+ diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx index 37fe16e..c0bd6a6 100644 --- a/src/features/store-admin/AssignBar.tsx +++ b/src/features/store-admin/AssignBar.tsx @@ -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. diff --git a/src/features/store-admin/ProductsPanel.tsx b/src/features/store-admin/ProductsPanel.tsx index 5b0b9fa..3dc09e1 100644 --- a/src/features/store-admin/ProductsPanel.tsx +++ b/src/features/store-admin/ProductsPanel.tsx @@ -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({ 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 ( @@ -238,7 +247,7 @@ function ProductTable({ - {rows.map((product) => ( + {paged.rows.map((product) => ( + ); } diff --git a/src/features/store-admin/TenantSheetImportPanel.tsx b/src/features/store-admin/TenantSheetImportPanel.tsx index b7440a1..a882c23 100644 --- a/src/features/store-admin/TenantSheetImportPanel.tsx +++ b/src/features/store-admin/TenantSheetImportPanel.tsx @@ -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 ? ( - - data={preview} - columns={previewColumns} - idKey="productsku" - density="compact" - dividers="rows" - /> + <> + + data={previewPaged.rows} + columns={previewColumns} + idKey="productsku" + density="compact" + dividers="rows" + /> + + ) : null} {progress ? ( diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts index c5765c8..6af3590 100644 --- a/src/features/store-admin/dispatchModel.test.ts +++ b/src/features/store-admin/dispatchModel.test.ts @@ -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 => +const delivery = (over: Partial = {}): DeliveryRow => ({ deliveryid: 1, orderheaderid: 100, @@ -36,35 +42,129 @@ const row = (over: Partial = {}): DeliveryRow => ...over, }) as DeliveryRow; +/** An order nobody has been assigned to — R mart's 5 September shape. */ +const order = (over: Partial = {}): 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 ─────────────────────────────────────────────────────────────── */ diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts index af704eb..cee69ff 100644 --- a/src/features/store-admin/dispatchModel.ts +++ b/src/features/store-admin/dispatchModel.ts @@ -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; 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(); 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(); - 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(); 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. */ diff --git a/src/features/store-admin/offlineSales/CounterSalesDrawer.tsx b/src/features/store-admin/offlineSales/CounterSalesDrawer.tsx index 91ad2e7..a26c8cf 100644 --- a/src/features/store-admin/offlineSales/CounterSalesDrawer.tsx +++ b/src/features/store-admin/offlineSales/CounterSalesDrawer.tsx @@ -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({ - {result.results.map((entry, index) => ( + {resultsPaged.rows.map((entry, index) => ( {entry.locationname || `Branch ${entry.locationid}`} {entry.billno || '—'} @@ -159,6 +164,7 @@ export function CounterSalesDrawer({ + @@ -274,7 +280,7 @@ export function CounterSalesDrawer({ - {rows.map((row) => ( + {rowsPaged.rows.map((row) => ( {row.excelRow} {locationid ? null : ( @@ -292,6 +298,7 @@ export function CounterSalesDrawer({ + {upload.isError ? : null} diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx index 97a0559..163ae9f 100644 --- a/src/features/store-admin/pages/ConsolePage.tsx +++ b/src/features/store-admin/pages/ConsolePage.tsx @@ -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 ( @@ -527,6 +531,7 @@ function TillTable({ rows }: { rows: Row[] }) { } return ( + <> @@ -540,7 +545,7 @@ function TillTable({ rows }: { rows: Row[] }) { - {terminals.map(({ branch, terminal }) => ( + {paged.rows.map(({ branch, terminal }) => (
@@ -569,6 +574,8 @@ function TillTable({ rows }: { rows: Row[] }) { ))}
+ + ); } diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index d58d055..beae3b6 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -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 = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' }; + export function DispatchPage() { const { branches, selected, current, tenantid } = useBranchScope(); const [day, setDay] = useDayParam(); const [mode, setMode] = useState('riders'); const [focused, setFocused] = useState(null); - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState(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() {
{open ? ( - + assignability(row, branchOf(row), assigned), + assignBar: + picked.count > 0 ? ( + + ) : null, + } + : {})} + /> ) : ( @@ -221,7 +320,7 @@ export function DispatchPage() {
{detail ? ( - setDetail(null)} /> + setDetail(null)} /> ) : null}
); @@ -353,7 +452,7 @@ function GroupList({
{groups.map((group) => { const done = group.delivered; - const total = group.deliveries.length; + const total = group.stops.length; return (
+ )} diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx index cc351f7..942b458 100644 --- a/src/features/store-admin/pages/ReportsPage.tsx +++ b/src/features/store-admin/pages/ReportsPage.tsx @@ -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() { - {rows.map((row) => ( + {paged.rows.map((row) => ( {branchLabel(row.branch.locationname)} @@ -208,6 +212,7 @@ export function ReportsPage() { + ) : null} diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index a9a07b8..8729c8b 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -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('month'); const [range, setRange] = useState(() => 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} setDetail({ row, kind: 'delivery' })} @@ -465,6 +474,7 @@ export function SalesPage() {
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; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; onOpen: (row: OrderRow) => void; }) { + // Above the early returns — a hook cannot be called conditionally. + const paged = usePaged(rows, { resetKey: queryKey }); + if (isLoading) return ; if (rows.length === 0) { return ( @@ -623,7 +639,7 @@ function OrdersTable({ {/* 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({ /> - {index + 1} + {paged.from + index} @@ -716,6 +732,7 @@ function OrdersTable({ + ); } @@ -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 ; if (rows.length === 0) { return ( @@ -762,7 +783,7 @@ function DeliveriesTable({ - {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({ + ); } @@ -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 ; 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({ + ); } diff --git a/src/features/store-admin/pages/TerminalsPage.tsx b/src/features/store-admin/pages/TerminalsPage.tsx index 6fc0693..b55a23d 100644 --- a/src/features/store-admin/pages/TerminalsPage.tsx +++ b/src/features/store-admin/pages/TerminalsPage.tsx @@ -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[] = [ { key: 'name', @@ -675,7 +679,7 @@ function FineTable({
- data={rows} + data={paged.rows} columns={columns} idKey="key" density="balanced" @@ -683,6 +687,7 @@ function FineTable({ dividers="rows" />
+
); diff --git a/src/features/store-admin/pages/UsersPage.tsx b/src/features/store-admin/pages/UsersPage.tsx index 5cf3799..87ae44f 100644 --- a/src/features/store-admin/pages/UsersPage.tsx +++ b/src/features/store-admin/pages/UsersPage.tsx @@ -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 ( @@ -365,7 +370,7 @@ function RiderTable({ - {rows.map((rider) => ( + {paged.rows.map((rider) => ( {rider.fullname || `Rider ${rider.userid}`} @@ -407,6 +412,7 @@ function RiderTable({ + ); } @@ -426,6 +432,8 @@ function StaffTable({ branches: { locationid: number; locationname: string }[]; onEdit: (row: StaffInfo) => void; }) { + const paged = usePaged(rows); + if (isLoading) return ; if (rows.length === 0) { return ( @@ -453,7 +461,7 @@ function StaffTable({ - {rows.map((person) => { + {paged.rows.map((person) => { const branch = branches.find((entry) => entry.locationid === person.locationid); return ( @@ -497,6 +505,7 @@ function StaffTable({ + ); } @@ -512,6 +521,8 @@ function TillTable({ showBranch: boolean; onEdit: (row: PosUser, locationid: number) => void; }) { + const paged = usePaged(rows); + if (isLoading) return ; if (rows.length === 0) { return ( @@ -539,7 +550,7 @@ function TillTable({ - {rows.map(({ person, branch, locationid }) => ( + {paged.rows.map(({ person, branch, locationid }) => ( {person.full_name || '—'} @@ -588,6 +599,7 @@ function TillTable({ + ); } diff --git a/src/features/store-user/pages/StoreCustomersPage.tsx b/src/features/store-user/pages/StoreCustomersPage.tsx index eef5401..979474a 100644 --- a/src/features/store-user/pages/StoreCustomersPage.tsx +++ b/src/features/store-user/pages/StoreCustomersPage.tsx @@ -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() { - {rows.map((customer) => ( + {paged.rows.map((customer) => ( setOpen(customer)} /> ))} + )} diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index d7f5751..27dee37 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -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({ - {rows.map((product) => ( + {paged.rows.map((product) => ( + )} @@ -513,6 +519,8 @@ function CatalogueRow({ /* ── My stock ────────────────────────────────────────────────────────────── */ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading: boolean }) { + const paged = usePaged(rows); + if (isLoading) return ; if (rows.length === 0) { return ( @@ -558,7 +566,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading: - {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: + ); @@ -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>(new Set()); @@ -680,7 +690,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b - {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 + ); diff --git a/src/features/store-user/pages/StoreStaffPage.tsx b/src/features/store-user/pages/StoreStaffPage.tsx index c5b7e6e..85c2e0b 100644 --- a/src/features/store-user/pages/StoreStaffPage.tsx +++ b/src/features/store-user/pages/StoreStaffPage.tsx @@ -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() { - {rows.map((person) => ( + {paged.rows.map((person) => ( {person.full_name || '—'} @@ -152,6 +155,7 @@ export function StoreStaffPage() { + )}