dispatch fix
This commit is contained in:
@@ -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. */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
236
src/features/store-admin/dispatchModel.test.ts
Normal file
236
src/features/store-admin/dispatchModel.test.ts
Normal 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);
|
||||
});
|
||||
315
src/features/store-admin/dispatchModel.ts
Normal file
315
src/features/store-admin/dispatchModel.ts
Normal 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());
|
||||
}
|
||||
571
src/features/store-admin/pages/DispatchPage.tsx
Normal file
571
src/features/store-admin/pages/DispatchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
src/features/store-admin/pages/dispatch.css
Normal file
255
src/features/store-admin/pages/dispatch.css
Normal 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;
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user