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