pagenation
This commit is contained in:
49
src/components/TablePager.tsx
Normal file
49
src/components/TablePager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/tablePager.css
Normal file
24
src/components/tablePager.css
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/components/usePaged.test.ts
Normal file
104
src/components/usePaged.test.ts
Normal 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
112
src/components/usePaged.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user