dispatch fix

This commit is contained in:
2026-09-05 15:38:40 +05:30
parent 5006a4f0d4
commit d3785cc303
8 changed files with 1407 additions and 0 deletions

View File

@@ -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() {
<Route index element={<Navigate to="/admin/console" replace />} />
<Route path="console" element={<ConsolePage />} />
<Route path="sales" element={<SalesPage />} />
<Route path="dispatch" element={<DispatchPage />} />
<Route path="inventory" element={<InventoryPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="branches/new" element={<OnboardBranchPage />} />
@@ -157,6 +159,7 @@ export function App() {
<Route path="console" element={<ConsolePage />} />
<Route path="products" element={<StoreProductsPage />} />
<Route path="sales" element={<SalesPage />} />
<Route path="dispatch" element={<DispatchPage />} />
<Route path="reports" element={<ReportsPage />} />
{/* Reached from the account menu. A shop does not commission outlets,
so there is deliberately no `branches/new` here. */}

View File

@@ -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;

View File

@@ -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' },
];

View File

@@ -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> = {}): 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);
});

View File

@@ -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<string, number>;
delivered: number;
/** Distinct riders touching this group's work. */
riders: Set<string>;
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<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));
}
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<string, Group>();
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<string, Group>();
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<string>();
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());
}

View File

@@ -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<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<DeliveryRow | null>(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 (
<VStack gap={3}>
<PageHeader
title="Dispatch"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="One day of deliveries — by rider, by shop, or by customer."
isLive
actions={
<DayStepper day={day} onChange={setDay} />
}
tabs={
<HStack gap={0.5} wrap="wrap">
<ModeTab
label="By rider"
icon={<Bike size={14} />}
badge={totals.riders || undefined}
isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/>
<ModeTab
label="By store"
icon={<Store size={14} />}
badge={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
<ModeTab
label="By customer"
icon={<Users size={14} />}
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
</HStack>
}
/>
<div className="kpi-grid">
<KpiCard
label="Stops"
value={count(totals.stops)}
note={isToday(day) ? 'today' : 'on this day'}
tone="accent"
icon={<Package size={15} />}
/>
<KpiCard
label="Still to do"
value={count(totals.outstanding)}
note={`${count(totals.delivered)} delivered`}
tone={totals.outstanding > 0 ? 'warning' : 'success'}
icon={<Truck size={15} />}
/>
<KpiCard
label="Not assigned"
value={count(totals.unassigned)}
note="nobody is carrying these"
tone={totals.unassigned > 0 ? 'error' : 'success'}
icon={<UserX size={15} />}
/>
<KpiCard
label="On the road"
value={count(totals.riders)}
note={money(totals.value) + ' in play'}
tone="neutral"
icon={<IndianRupee size={15} />}
/>
</div>
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail group={open} mode={mode} onOpen={setDetail} />
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{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.'}
</Text>
</VStack>
</Card>
)}
</div>
</div>
{detail ? (
<OrderDetailDrawer row={detail} kind="delivery" onClose={() => setDetail(null)} />
) : null}
</VStack>
);
}
/* ── 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 (
<div className="day-stepper">
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
<ChevronLeft size={15} />
</button>
{/*
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.
*/}
<span className="day-label">
<span aria-hidden>{label}</span>
{today ? <em aria-hidden>today</em> : null}
<input
type="date"
aria-label="Pick a day"
value={day}
max={ymd(new Date())}
onChange={(event) => {
// Clearing the field yields '' — keep the day rather than blanking
// the board.
if (event.target.value) onChange(event.target.value);
}}
/>
</span>
<button
type="button"
aria-label="Next day"
disabled={today}
onClick={() => onChange(shiftDay(day, 1))}
>
<ChevronRight size={15} />
</button>
{!today ? (
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
Today
</button>
) : null}
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */
function GroupList({
groups,
mode,
isLoading,
focused,
onFocus,
}: {
groups: Group[];
mode: ViewMode;
isLoading: boolean;
focused: string | null;
onFocus: (id: string) => void;
}) {
if (isLoading) {
return (
<Text type="body" size="sm" color="secondary">
Reading the day…
</Text>
);
}
if (groups.length === 0) {
return (
<Text type="body" size="sm" color="secondary">
{mode === 'stores' ? 'No shops.' : mode === 'customers' ? 'Nobody ordered.' : 'No rounds.'}
</Text>
);
}
return (
<div className="group-list">
{groups.map((group) => {
const done = group.delivered;
const total = group.deliveries.length;
return (
<button
key={group.id}
type="button"
className="group-card"
data-active={group.id === focused}
data-urgent={group.id === UNASSIGNED && total > 0}
aria-pressed={group.id === focused}
onClick={() => onFocus(group.id)}
>
<span className="group-name">
{group.name}
{group.detail ? <em>{group.detail}</em> : null}
</span>
<span className="group-count">
{total > 0 ? (
<>
<strong>{done}</strong>/{total}
</>
) : (
<span className="group-quiet">quiet</span>
)}
</span>
{/* One bar per status, widths proportional. It answers "how far
through is this round" without a number to read. */}
{total > 0 ? (
<span className="group-bar">
{Object.entries(group.statuses).map(([status, n]) => (
<i
key={status}
style={{
width: `${(n / total) * 100}%`,
background: statusColor(DELIVERY_STATUS, status),
}}
title={`${n} ${status}`}
/>
))}
</span>
) : null}
</button>
);
})}
</div>
);
}
/* ── The focused group ───────────────────────────────────────────────────── */
function GroupDetail({
group,
mode,
onOpen,
}: {
group: Group;
mode: ViewMode;
onOpen: (row: DeliveryRow) => void;
}) {
const seen = lastSeen(group.deliveries);
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<VStack gap={0}>
<Text type="label" size="lg" weight="semibold">
{group.name}
</Text>
{group.detail ? (
<Text type="body" size="xsm" color="secondary">
{group.detail}
</Text>
) : null}
</VStack>
<Text type="body" size="sm" color="secondary">
{group.deliveries.length} stop{group.deliveries.length === 1 ? '' : 's'} ·{' '}
{moneyExact(group.value)}
</Text>
</HStack>
{/* 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 ? (
<div className="last-seen">
<MapPin size={14} />
<div>
<strong>
{seen.lat.toFixed(5)}, {seen.lon.toFixed(5)}
</strong>
<span>
reported at “{seen.status}”
{seen.at ? ` · ${ageOf(seen.at)}` : ''}
{seen.orderid ? ` · ${seen.orderid}` : ''}
</span>
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${seen.lat}&mlon=${seen.lon}#map=16/${seen.lat}/${seen.lon}`}
target="_blank"
rel="noreferrer"
>
Open map
</a>
</div>
) : (
<div className="last-seen" data-empty="true">
<MapPin size={14} />
<div>
<strong>No position reported</strong>
<span>
A rider's location arrives only when they move a job along, so a round with no
updates yet has none.
</span>
</div>
</div>
)
) : null}
</VStack>
</Card>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</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>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
/** Planned distance, or the actual once it exists. Both arrive as strings. */
function km(row: DeliveryRow): string {
const value = row.actualkms || row.kms || '';
const n = Number(value);
return Number.isFinite(n) && n > 0 ? `${n}` : '—';
}
function shortTime(stamp: string): string {
const match = stamp.match(/(\d{1,2}):(\d{2})/);
return match ? `${match[1]}:${match[2]}` : '';
}
function ageOf(stamp: string): string {
const at = new Date(stamp.replace(' ', 'T')).getTime();
if (Number.isNaN(at)) return '';
return `${shortAge(Math.max(0, Date.now() - at))} ago`;
}
/* ── Chrome ──────────────────────────────────────────────────────────────── */
function ModeTab({
label,
icon,
badge,
isActive,
onClick,
}: {
label: string;
icon: React.ReactNode;
badge?: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="mode-tab" data-active={isActive} onClick={onClick}>
{icon}
{label}
{badge ? <span>{badge}</span> : null}
</button>
);
}

View File

@@ -0,0 +1,255 @@
/* ══ Dispatch ══════════════════════════════════════════════════════════════
A rail of groups beside the stops of whichever one is open. Built on the
console's own tokens — no inherited stylesheet — so the page reads as part of
this product rather than a port of another one. */
/* ── Layout ─────────────────────────────────────────────────────────────── */
/* The rail is fixed-width and the stops take the rest: the rail holds short
names and counts, and giving it a share of the width would leave it padded
while the stop addresses truncate. Stacks below 1000px, rail first — on a
phone the question is still "which round", and the answer has to come before
the detail. */
.dispatch-body {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: start;
}
@media (min-width: 1000px) {
.dispatch-body { grid-template-columns: 288px minmax(0, 1fr); }
}
.dispatch-rail { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.dispatch-main { min-width: 0; }
/* ── The day stepper ────────────────────────────────────────────────────── */
/* Arrows rather than a calendar: checking yesterday is one click, and a round
belongs to a date rather than to a range. */
.day-stepper {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
}
.day-stepper button {
display: grid;
place-items: center;
height: 28px;
min-width: 28px;
padding: 0 6px;
border: 0;
border-radius: 9px;
background: none;
color: var(--color-ink-3);
cursor: pointer;
}
.day-stepper button:hover:not(:disabled) { background: var(--color-surface-sunken); color: var(--color-brand); }
.day-stepper button:disabled { opacity: .3; cursor: not-allowed; }
.day-stepper button:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; }
/* The label IS the picker: a transparent date input laid over it, so the day
reads as "Sat, Sep 5" and still opens the browser's own calendar on click.
`position: relative` here is what the input is positioned against — remove it
and the input escapes to the page corner. */
.day-label {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 0 8px;
font: 600 13px/1 var(--font-sans);
color: var(--color-ink-1);
white-space: nowrap;
cursor: pointer;
}
.day-label input {
position: absolute;
inset: -4px 0;
width: 100%;
padding: 0;
border: 0;
/* Not `display: none` or `visibility: hidden` — either would take the input
out of the accessibility tree and off the tab order along with it. */
opacity: 0;
cursor: pointer;
}
/* Focus is drawn on the label, since the input itself is invisible. */
.day-label:focus-within {
border-radius: 8px;
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
.day-label em {
font: 500 10px/1 var(--font-sans);
font-style: normal;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--color-brand);
}
.day-today {
font: 500 12px/1 var(--font-sans) !important;
color: var(--color-brand) !important;
}
/* ── Mode tabs ──────────────────────────────────────────────────────────── */
.mode-tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--color-ink-3);
font: 500 13px/1 var(--font-sans);
cursor: pointer;
transition: background .2s, color .2s;
}
.mode-tab:hover { background: var(--color-surface-sunken); }
.mode-tab[data-active='true'] { background: var(--color-brand-tint); color: var(--color-brand); font-weight: 600; }
.mode-tab span {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-surface-sunken);
font: 700 11px/18px var(--font-sans);
font-variant-numeric: tabular-nums;
}
.mode-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
.mode-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* ── Group cards ────────────────────────────────────────────────────────── */
.group-list { display: flex; flex-direction: column; gap: 6px; }
.group-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
gap: 4px 10px;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
text-align: left;
cursor: pointer;
}
.group-card:hover { border-color: var(--color-ink-4); }
.group-card[data-active='true'] { border-color: var(--color-brand); background: var(--color-brand-tint); }
/* Unassigned work is the one group somebody must act on, so it is marked even
when it is not the biggest. */
.group-card[data-urgent='true'] { border-color: #f0cfcd; }
.group-card[data-urgent='true'][data-active='true'] { border-color: var(--color-brand); }
.group-card:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.group-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.group-name {
font: 600 13px/1.3 var(--font-sans);
color: var(--color-ink-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-name em {
font: 400 11px/1.3 var(--font-sans);
font-style: normal;
color: var(--color-ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-count {
font: 400 12px/1.3 var(--font-sans);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.group-count strong { color: var(--color-ink-1); font-weight: 600; }
.group-quiet { font-size: 11.5px; color: var(--color-ink-4); }
/* Progress as proportion, not as a number to read: one segment per status,
width by share. "How far through is this round" at a glance. */
.group-bar {
grid-column: 1 / -1;
display: flex;
gap: 1px;
height: 4px;
border-radius: 999px;
overflow: hidden;
background: var(--color-surface-sunken);
}
.group-bar i { display: block; height: 100%; }
/* ── Last-known position ────────────────────────────────────────────────── */
/* Deliberately NOT a map pin. A rider's location arrives only when they move a
job along — a handful of points per delivery — so it is shown as a
coordinate with its age and the status that produced it. A dot on a map would
claim a live feed that does not exist. */
.last-seen {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface-subtle);
}
.last-seen svg { color: var(--color-brand); }
.last-seen[data-empty='true'] svg { color: var(--color-ink-4); }
.last-seen div { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.last-seen strong {
font: 600 12.5px/1.3 var(--font-sans);
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.last-seen span { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-3); }
.last-seen a {
font: 500 12px/1 var(--font-sans);
color: var(--color-brand);
text-decoration: none;
white-space: nowrap;
}
.last-seen a:hover { text-decoration: underline; }
/* ── The stops ──────────────────────────────────────────────────────────── */
.stops-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.stops-table th {
text-align: left;
padding: 9px 12px;
border-bottom: 1px solid var(--color-line);
font: 600 11.5px/1 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
}
.stops-table td {
padding: 9px 12px;
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
vertical-align: top;
}
.stops-table tbody tr { cursor: pointer; }
.stops-table tbody tr:hover { background: var(--color-surface-subtle); }
.stops-table tbody tr:last-child td { border-bottom: none; }
.stops-table td strong { display: block; font: 600 12.5px/1.3 var(--font-sans); color: var(--color-ink-1); }
.stops-table td span {
display: block;
font: 400 11px/1.3 var(--font-sans);
color: var(--color-ink-4);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stops-table td.num { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--color-ink-2); }
.stops-table .muted { color: var(--color-ink-4); font-style: normal; }
.stop-status {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font: 600 11px/1.5 var(--font-sans);
text-transform: capitalize;
white-space: nowrap;
}

View File

@@ -26,6 +26,7 @@ const NAV: readonly NavEntry[] = [
{ to: '/store/console', label: 'Console' },
{ to: '/store/products', label: 'Products' },
{ to: '/store/sales', label: 'Sales' },
{ to: '/store/dispatch', label: 'Dispatch' },
{ to: '/store/reports', label: 'Reports' },
];