pagenation
This commit is contained in:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user