diff --git a/src/App.tsx b/src/App.tsx
index d064b2d..7fe0214 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -39,6 +39,7 @@ const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-a
below therefore render the same component, not two copies of one board. */
const ConsolePage = named('ConsolePage', () => import('@/features/console/ConsolePage'));
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
+const DispatchPage = named('DispatchPage', () => import('@/features/store-admin/pages/DispatchPage'));
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
@@ -123,6 +124,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -157,6 +159,7 @@ export function App() {
} />
} />
} />
+ } />
} />
{/* Reached from the account menu. A shop does not commission outlets,
so there is deliberately no `branches/new` here. */}
diff --git a/src/api/types.ts b/src/api/types.ts
index c9c77a1..fdb557a 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -737,6 +737,14 @@ export interface DeliveryRow {
pickupaddress?: string;
pickuplocation?: string;
pickupsuburb?: string;
+ /**
+ * Present on the wire and ALWAYS 0. Nothing that writes a delivery sets it —
+ * not the app, not `createdeliveries` — so joining a delivery to a customer
+ * on this id matches nothing. `GetTenantLocationDeliveries` did exactly that
+ * with an INNER JOIN and returned zero rows for every branch as a result.
+ * Group on `deliverycontactno` instead; see `dispatchModel.groupByCustomer`.
+ */
+ deliverycustomerid?: number;
deliverycustomer?: string;
deliverycontactno?: string;
deliveryaddress?: string;
@@ -748,8 +756,25 @@ export interface DeliveryRow {
deliverytype?: string;
notes?: string;
ordernotes?: string;
+ /**
+ * The assigned rider's `app_users.userid`.
+ *
+ * The stable key for grouping a round — `ridername` comes from a join and is
+ * blank whenever that join misses.
+ */
+ userid?: number;
ridername?: string;
ridercontact?: string;
+ /**
+ * Where the rider was when they last moved this job along.
+ *
+ * Written by `updatedelivery`, so a position arrives per status change — a
+ * handful of points per delivery, never a trail. Nothing ingests positions on
+ * a timer and `getriders` returns none, so this is the only rider location
+ * the console can show. Sparse: 1 of 7 production deliveries carries one.
+ */
+ riderslat?: string;
+ riderslon?: string;
/** Planned distance. `actualkms`/`riderkms` is what was really ridden. */
kms?: string;
actualkms?: string;
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index dd82174..8b8e530 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -18,6 +18,7 @@ import { useLiveEvents } from '@/queries/useLiveEvents';
const NAV: readonly NavEntry[] = [
{ to: '/admin/console', label: 'Console' },
{ to: '/admin/sales', label: 'Sales' },
+ { to: '/admin/dispatch', label: 'Dispatch' },
{ to: '/admin/inventory', label: 'Inventory' },
{ to: '/admin/reports', label: 'Reports' },
];
diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts
new file mode 100644
index 0000000..c5765c8
--- /dev/null
+++ b/src/features/store-admin/dispatchModel.test.ts
@@ -0,0 +1,236 @@
+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 {
+ dayTotals,
+ groupByCustomer,
+ groupByRider,
+ groupByStore,
+ isToday,
+ lastSeen,
+ shiftDay,
+ UNASSIGNED,
+ ymd,
+} 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.
+*/
+
+const row = (over: Partial = {}): DeliveryRow =>
+ ({
+ deliveryid: 1,
+ orderheaderid: 100,
+ orderid: '1135-1',
+ locationid: 1166,
+ orderstatus: 'pending',
+ deliverycustomer: 'anbu',
+ deliverycontactno: '7397177923',
+ deliveryamt: 80,
+ assigntime: '2026-07-23 19:08:23',
+ userid: 1427,
+ ridername: 'aravind test',
+ ...over,
+ }) as DeliveryRow;
+
+const branches: TenantLocation[] = [
+ { locationid: 1166, tenantid: 1135, locationname: 'Suriya Store RS Puram', status: 'Active' },
+ { locationid: 1170, tenantid: 1135, locationname: 'Suriya Store Peelamedu', status: 'Active' },
+];
+
+/* ── 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.
+ */
+ const groups = groupByStore([row({ locationid: 1166 })], branches);
+ assert.equal(groups.length, 2);
+ assert.equal(groups.find((g) => g.id === '1170')?.deliveries.length, 0);
+});
+
+test('busiest shop first', () => {
+ const groups = groupByStore(
+ [row({ deliveryid: 1, locationid: 1170 }), row({ deliveryid: 2, locationid: 1170 }), row({ deliveryid: 3, locationid: 1166 })],
+ branches,
+ );
+ assert.equal(groups[0]?.id, '1170');
+});
+
+test('a delivery 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);
+ assert.ok(groups.some((g) => g.id === '9999'), 'live work is never hidden');
+});
+
+/* ── By customer ─────────────────────────────────────────────────────────── */
+
+test('customers are matched on PHONE, not on customer id', () => {
+ /*
+ `deliveries.deliverycustomerid` is 0 on every row this system has written —
+ 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');
+ 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 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);
+ 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"', () => {
+ /*
+ 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' }),
+ ],
+ [],
+ () => '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', () => {
+ // 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' }),
+ ],
+ [],
+ () => undefined,
+ );
+ assert.equal(groups.length, 2);
+});
+
+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);
+ assert.deepEqual(groups.map((g) => g.name), ['Anbu']);
+});
+
+/* ── By rider ────────────────────────────────────────────────────────────── */
+
+test('unassigned work 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: '' }),
+ ]);
+ assert.equal(groups[0]?.id, UNASSIGNED);
+ assert.equal(groups[0]?.deliveries.length, 1);
+});
+
+test('a round is ordered by assigntime', () => {
+ /*
+ Safe here and NOT safe in the console this is modelled on: theirs mapped
+ 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]);
+});
+
+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: '' })]);
+ 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' }),
+ ]);
+ assert.equal(seen?.deliveryid, 2);
+ assert.equal(seen?.lat, 11.005);
+});
+
+test('no reported position is null, not a zero coordinate', () => {
+ /*
+ Sparse by design: one production delivery in seven carries a position, because
+ 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([]), 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' })]);
+ 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' }),
+ ]);
+ 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);
+});
+
+/* ── The day ─────────────────────────────────────────────────────────────── */
+
+test('stepping a day stays in local time', () => {
+ // Parsing the string would shift the whole board by a day in some zones.
+ assert.equal(shiftDay('2026-09-05', -1), '2026-09-04');
+ assert.equal(shiftDay('2026-09-05', 1), '2026-09-06');
+ assert.equal(shiftDay('2026-01-01', -1), '2025-12-31', 'across a year boundary');
+ assert.equal(shiftDay('2026-03-01', -1), '2026-02-28', 'across a month boundary');
+});
+
+test('today is today', () => {
+ assert.equal(isToday(ymd(new Date())), true);
+ assert.equal(isToday('2020-01-01'), false);
+});
diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts
new file mode 100644
index 0000000..af704eb
--- /dev/null
+++ b/src/features/store-admin/dispatchModel.ts
@@ -0,0 +1,315 @@
+import type { CustomerInfo } from '@/api/customers';
+import type { DeliveryRow, TenantLocation } from '@/api/types';
+
+/**
+ * A day's deliveries, grouped three ways.
+ *
+ * By store, by customer, and by rider — the same rows 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 ─────────────────────────
+ *
+ * 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.
+ *
+ * Pure, so every rule below is testable without a map or a network.
+ */
+
+export type ViewMode = 'stores' | 'customers' | 'riders';
+
+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. */
+ statuses: Record;
+ delivered: number;
+ /** Distinct riders touching this group's work. */
+ riders: Set;
+ value: number;
+}
+
+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';
+}
+
+function blank(id: string, name: string, detail?: string): Group {
+ return {
+ id,
+ name,
+ ...(detail ? { detail } : {}),
+ deliveries: [],
+ statuses: {},
+ delivered: 0,
+ riders: new Set(),
+ value: 0,
+ };
+}
+
+function fold(group: Group, row: DeliveryRow): void {
+ group.deliveries.push(row);
+ const status = statusOf(row);
+ 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);
+}
+
+/** 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),
+ );
+}
+
+/**
+ * By store.
+ *
+ * Seeded from the branch list. A delivery 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[],
+ branches: readonly TenantLocation[],
+): Group[] {
+ const map = new Map();
+ for (const branch of branches) {
+ map.set(String(branch.locationid), blank(String(branch.locationid), branch.locationname || `Branch ${branch.locationid}`, branch.suburb || branch.city));
+ }
+
+ for (const row of deliveries) {
+ const id = String(row.locationid ?? 'unknown');
+ let group = map.get(id);
+ if (!group) {
+ group = blank(id, row.locationname || `Branch ${id}`);
+ map.set(id, group);
+ }
+ fold(group, row);
+ }
+
+ return order([...map.values()]);
+}
+
+/**
+ * By customer.
+ *
+ * Seeded from the customer book, and keyed on the CONTACT NUMBER rather than
+ * the customer id. `deliveries.deliverycustomerid` is 0 on every row this
+ * system has ever written — the app never sets it — so an id join finds
+ * nothing. The phone number is what both sides actually carry.
+ */
+export function groupByCustomer(
+ deliveries: readonly DeliveryRow[],
+ customers: readonly CustomerInfo[],
+ branchName: (locationid: number | undefined) => string | undefined,
+): Group[] {
+ const map = new Map();
+ const phone = (value: string | undefined) => (value ?? '').replace(/\D/g, '').slice(-10);
+
+ for (const customer of customers) {
+ const key = phone(customer.contactno);
+ if (!key) continue;
+ const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
+ map.set(key, blank(key, name || `Customer ${customer.customerid}`, customer.contactno));
+ }
+
+ for (const row of deliveries) {
+ const key = phone(row.deliverycontactno) || `row-${row.deliveryid}`;
+ let group = map.get(key);
+ if (!group) {
+ /*
+ A delivery to 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
+ of production rows carry neither a name nor a phone (three of the three
+ out on 10 June), and naming them all "Customer" produced a rail of
+ identical entries that could not be told apart or usefully clicked. The
+ address is the one thing every one of them has, and it is what an operator
+ would use to identify the stop anyway.
+ */
+ group = blank(
+ key,
+ row.deliverycustomer || row.deliveryaddress || 'Customer',
+ row.deliverycontactno,
+ );
+ map.set(key, group);
+ }
+ if (!group.detail) group.detail = branchName(row.locationid);
+ fold(group, row);
+ }
+
+ // 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));
+}
+
+/**
+ * By rider — the round.
+ *
+ * Ordered by `assigntime`, which is safe here and is NOT safe in the system
+ * this pattern came from: theirs mapped `assigntime` onto the row's
+ * last-modified column, so any status change moved an order between groups.
+ * Ours is a distinct column written once by `createdeliveries` and never
+ * 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.
+ */
+export const UNASSIGNED = 'unassigned';
+
+export function groupByRider(deliveries: readonly DeliveryRow[]): Group[] {
+ const map = new Map();
+
+ for (const row of deliveries) {
+ const id = String(row.userid ?? '') || UNASSIGNED;
+ let group = map.get(id);
+ if (!group) {
+ group = blank(id, id === UNASSIGNED ? 'Not assigned' : row.ridername || `Rider ${id}`, row.ridercontact);
+ map.set(id, group);
+ }
+ fold(group, row);
+ }
+
+ for (const group of map.values()) {
+ group.deliveries.sort((a, b) => (a.assigntime ?? '').localeCompare(b.assigntime ?? ''));
+ }
+
+ const groups = order([...map.values()]);
+ // Unassigned to the top regardless of size — it is the work queue.
+ return groups.sort((a, b) => Number(b.id === UNASSIGNED) - Number(a.id === UNASSIGNED));
+}
+
+/* ── Rider tracking ──────────────────────────────────────────────────────── */
+
+export interface LastSeen {
+ lat: number;
+ lon: number;
+ /** The delivery whose status update carried the position. */
+ deliveryid: number;
+ orderid?: string;
+ /** The status the rider was reporting when it was captured. */
+ status: string;
+ /** When, if the row carries a usable stamp. */
+ at: string | null;
+}
+
+/**
+ * Where a rider last reported themselves.
+ *
+ * NOT a live feed and must never be drawn as one. `riderslat`/`riderslon` are
+ * written by `updatedelivery`, so a position arrives only when the rider moves
+ * a job along — a handful of points per delivery, not a trail. Nothing in the
+ * backend ingests a position on a timer, and `getriders` returns none at all.
+ *
+ * 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.
+ */
+export function lastSeen(deliveries: readonly DeliveryRow[]): LastSeen | null {
+ const withPosition = deliveries
+ .filter((row) => {
+ const lat = Number(row.riderslat ?? '');
+ const lon = Number(row.riderslon ?? '');
+ return Number.isFinite(lat) && Number.isFinite(lon) && lat !== 0 && lon !== 0;
+ })
+ .sort((a, b) => stampOf(b).localeCompare(stampOf(a)));
+
+ const row = withPosition[0];
+ if (!row) return null;
+
+ return {
+ lat: Number(row.riderslat),
+ lon: Number(row.riderslon),
+ deliveryid: row.deliveryid,
+ ...(row.orderid ? { orderid: row.orderid } : {}),
+ status: statusOf(row),
+ at: stampOf(row) || null,
+ };
+}
+
+/**
+ * The most recent lifecycle stamp on a delivery.
+ *
+ * Walked newest-first through the ladder rather than trusting one field: a row
+ * that has been delivered carries every earlier stamp too, and the latest is
+ * the one that says when the position was captured.
+ */
+function stampOf(row: DeliveryRow): string {
+ return (
+ row.deliverytime ||
+ row.pickuptime ||
+ row.arrivaltime ||
+ row.starttime ||
+ row.assigntime ||
+ ''
+ );
+}
+
+/* ── The day's headline numbers ──────────────────────────────────────────── */
+
+export interface DayTotals {
+ stops: number;
+ delivered: number;
+ outstanding: number;
+ unassigned: number;
+ riders: number;
+ value: number;
+}
+
+export function dayTotals(deliveries: readonly DeliveryRow[]): DayTotals {
+ const riders = new Set();
+ let delivered = 0;
+ let unassigned = 0;
+ let value = 0;
+
+ for (const row of deliveries) {
+ const status = statusOf(row);
+ if (status === 'delivered') delivered += 1;
+ if (!row.userid) unassigned += 1;
+ if (row.userid) riders.add(String(row.userid));
+ value += row.deliveryamt ?? 0;
+ }
+
+ 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,
+ };
+}
+
+/** One day as Fiesta wants it, and the stepper's arithmetic. */
+export function ymd(date: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+export function shiftDay(day: string, by: number): string {
+ const [y, m, d] = day.split('-').map(Number);
+ // Local midnight, then step. Constructing from the parts rather than parsing
+ // the string avoids the UTC shift that would move the whole board a day.
+ const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
+ date.setDate(date.getDate() + by);
+ return ymd(date);
+}
+
+export function isToday(day: string): boolean {
+ return day === ymd(new Date());
+}
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
new file mode 100644
index 0000000..d58d055
--- /dev/null
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -0,0 +1,571 @@
+import { useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Card } from '@astryxdesign/core/Card';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import {
+ Bike,
+ ChevronLeft,
+ ChevronRight,
+ IndianRupee,
+ MapPin,
+ Package,
+ Store,
+ Truck,
+ UserX,
+ Users,
+} from 'lucide-react';
+import type { DeliveryRow } from '@/api/types';
+import { KpiCard } from '@/components/KpiCard';
+import { PageHeader } from '@/components/PageHeader';
+import { useCustomers, useDeliveries, useTenantLocations } from '@/queries/hooks';
+import { useBranchScope } from '../BranchScope';
+import { count, money, moneyExact } from '../format';
+import { DELIVERY_STATUS, statusColor } from '../orderStatus';
+import { shortAge } from '../posStatus';
+import { OrderDetailDrawer } from '../OrderDetailDrawer';
+import {
+ dayTotals,
+ groupByCustomer,
+ groupByRider,
+ groupByStore,
+ isToday,
+ lastSeen,
+ shiftDay,
+ statusOf,
+ UNASSIGNED,
+ ymd,
+ type Group,
+ type ViewMode,
+} from '../dispatchModel';
+import './dispatch.css';
+
+/**
+ * Dispatch — one day's deliveries, read three ways.
+ *
+ * A day at a time, because that is how a round is worked: everything here is
+ * scoped to a single date, not a range. No batch windows — order placement in
+ * this tenant clusters so hard into one hour that a morning/afternoon/evening
+ * split would put ninety per cent in one bucket and call it navigation.
+ *
+ * ── Why three groupings and not one list ────────────────────────────────────
+ *
+ * The same rows answer three different questions and no single ordering answers
+ * more than one of them: which shop is busy (stores), who is still waiting
+ * (customers), and what is each rider carrying (riders). The reference console
+ * this is modelled on offers the first two; the third is the one our own
+ * Deliveries tab could never answer, since it lists jobs flat with a rider
+ * column and no way to see a round.
+ *
+ * ── What this page deliberately is not ──────────────────────────────────────
+ *
+ * There is no map yet. Every drop coordinate in production today sits within
+ * about four metres of every other — one test customer ordering repeatedly —
+ * so a map would render one pin with six underneath and imply a precision the
+ * 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.
+ */
+export function DispatchPage() {
+ const { branches, selected, current, tenantid } = useBranchScope();
+ const [day, setDay] = useDayParam();
+ const [mode, setMode] = useState('riders');
+ const [focused, setFocused] = useState(null);
+ const [detail, setDetail] = useState(null);
+
+ const query = tenantid
+ ? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
+ : undefined;
+
+ const deliveries = useDeliveries(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.
+ const customers = useCustomers(
+ tenantid && mode === 'customers'
+ ? { tenantid, locationid: selected ?? undefined, pagesize: 500 }
+ : undefined,
+ );
+
+ const rows = useMemo(() => deliveries.data ?? [], [deliveries.data]);
+ const totals = useMemo(() => dayTotals(rows), [rows]);
+
+ const branchName = useMemo(
+ () => (locationid: number | undefined) =>
+ branches.find((branch) => branch.locationid === locationid)?.locationname,
+ [branches],
+ );
+
+ 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]);
+
+ const open = groups.find((group) => group.id === focused) ?? null;
+
+ const changeMode = (next: ViewMode) => {
+ setMode(next);
+ // A group id means nothing across groupings — a branch id is not a rider
+ // id — so the focus is dropped rather than carried into nonsense.
+ setFocused(null);
+ };
+
+ return (
+
+
+ }
+ tabs={
+
+ }
+ badge={totals.riders || undefined}
+ isActive={mode === 'riders'}
+ onClick={() => changeMode('riders')}
+ />
+ }
+ badge={branches.length || undefined}
+ isActive={mode === 'stores'}
+ onClick={() => changeMode('stores')}
+ />
+ }
+ isActive={mode === 'customers'}
+ onClick={() => changeMode('customers')}
+ />
+
+ }
+ />
+
+
+ {open ? (
+
+ ) : (
+
+
+
+
+
+
+ {groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
+
+
+ {groups.length > 0
+ ? 'Every stop, in the order it was assigned, with where the rider last reported in.'
+ : 'Deliveries appear here once orders are assigned to a rider.'}
+
+
+
+ )}
+
+
+
+ {detail ? (
+ setDetail(null)} />
+ ) : null}
+
+ );
+}
+
+/* ── The day ─────────────────────────────────────────────────────────────── */
+
+/**
+ * Which day is on screen, held in the URL.
+ *
+ * The same reasoning `BranchScope` gives for the branch: "Dispatch, last
+ * Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
+ * not silently throw the operator back to today while they are reading a round
+ * that belongs to another date.
+ *
+ * A malformed or future value falls back to today rather than 400ing the
+ * delivery read — the param is user-editable, so it is untrusted input.
+ */
+function useDayParam(): [string, (next: string) => void] {
+ const [params, setParams] = useSearchParams();
+ const today = ymd(new Date());
+ const raw = params.get('day') ?? '';
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
+
+ const set = (next: string) => {
+ const clamped = next > today ? today : next;
+ const merged = new URLSearchParams(params);
+ // Today is the default, so it is left OUT of the URL: the common case gets
+ // a clean address, and only a deliberate look back carries a param.
+ if (clamped === today) merged.delete('day');
+ else merged.set('day', clamped);
+ setParams(merged, { replace: true });
+ };
+
+ return [day, set];
+}
+
+/**
+ * A day at a time.
+ *
+ * Not a range picker: a round belongs to a date, and the arrows are how an
+ * operator checks yesterday without thinking about a calendar. Forward is
+ * disabled on today — dispatch has nothing to say about tomorrow.
+ */
+function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
+ const today = isToday(day);
+ const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ });
+
+ return (
+
+
+ {/*
+ The label doubles as the picker. Arrows answer "yesterday", which is
+ most of the traffic; a date a month back would otherwise be thirty
+ clicks. The input sits transparent over the label rather than replacing
+ it, so the day still reads as "Sat, Sep 5" instead of as a form field,
+ and clicking it opens the browser's own calendar — a real control,
+ keyboard-reachable, with no picker to write or to get wrong.
+ */}
+
+ {label}
+ {today ? today : null}
+ {
+ // Clearing the field yields '' — keep the day rather than blanking
+ // the board.
+ if (event.target.value) onChange(event.target.value);
+ }}
+ />
+
+
+ {!today ? (
+
+ ) : null}
+
+ );
+}
+
+/* ── The focused group ───────────────────────────────────────────────────── */
+
+function GroupDetail({
+ group,
+ mode,
+ onOpen,
+}: {
+ group: Group;
+ mode: ViewMode;
+ onOpen: (row: DeliveryRow) => void;
+}) {
+ const seen = lastSeen(group.deliveries);
+
+ return (
+
+
+
+
+
+
+ {group.name}
+
+ {group.detail ? (
+
+ {group.detail}
+
+ ) : null}
+
+
+ {group.deliveries.length} stop{group.deliveries.length === 1 ? '' : 's'} ·{' '}
+ {moneyExact(group.value)}
+
+
+
+ {/* Rider position, only in the rider view and only when reported.
+ 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' ? (
+ seen ? (
+