pagenation

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

View File

@@ -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. */