delivery slot updated in orders and deliveries fix
This commit is contained in:
@@ -5,6 +5,7 @@ import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||
import {
|
||||
dayTotals,
|
||||
groupByCustomer,
|
||||
groupByWindow,
|
||||
groupByRider,
|
||||
groupByStore,
|
||||
isToday,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
stopKey,
|
||||
stopValue,
|
||||
toStops,
|
||||
NO_WINDOW,
|
||||
UNASSIGNED,
|
||||
WAITING,
|
||||
ymd,
|
||||
@@ -407,3 +409,93 @@ test('today is today', () => {
|
||||
assert.equal(isToday(ymd(new Date())), true);
|
||||
assert.equal(isToday('2020-01-01'), false);
|
||||
});
|
||||
|
||||
/* ── Grouping by delivery window ──────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
The board that answers "what has to leave next".
|
||||
|
||||
Two rules carry the weight, and both are about what happens to work nobody
|
||||
wants to see: a window that has already closed, and the large pile of stops
|
||||
that chose no window at all. Getting either wrong hides work rather than
|
||||
ordering it.
|
||||
*/
|
||||
|
||||
const windowed = (slotid: number, date: string, key: string, start: string, end: string) =>
|
||||
order({
|
||||
orderheaderid: slotid * 1000 + Number(date.slice(-2)),
|
||||
deliveryslotid: slotid,
|
||||
deliveryslotdate: date,
|
||||
slotkey: key,
|
||||
deliveryslotname: key[0]!.toUpperCase() + key.slice(1),
|
||||
deliveryslotstart: start,
|
||||
deliveryslotend: end,
|
||||
} as Partial<OrderRow>);
|
||||
|
||||
test('groupByWindow sorts by date then start time', () => {
|
||||
const stops = toStops(
|
||||
[],
|
||||
[
|
||||
windowed(3, '2026-10-08', 'evening', '17:00', '20:00'),
|
||||
windowed(1, '2026-10-09', 'morning', '08:00', '10:00'),
|
||||
windowed(1, '2026-10-08', 'morning', '08:00', '10:00'),
|
||||
],
|
||||
);
|
||||
|
||||
const names = groupByWindow(stops).map((g) => g.name);
|
||||
assert.ok(names[0]?.startsWith('Morning'), `first was ${names[0]}`);
|
||||
assert.ok(names[1]?.startsWith('Evening'), `second was ${names[1]}`);
|
||||
// Tomorrow's morning sorts after tonight's evening, not beside this
|
||||
// morning's — the date leads, so the board reads as a timeline.
|
||||
assert.ok(names[2]?.startsWith('Morning'), `third was ${names[2]}`);
|
||||
assert.notEqual(names[0], names[2], 'two different days collapsed into one group');
|
||||
});
|
||||
|
||||
test('groupByWindow keeps the same window on different days apart', () => {
|
||||
const stops = toStops(
|
||||
[],
|
||||
[
|
||||
windowed(1, '2026-10-08', 'morning', '08:00', '10:00'),
|
||||
windowed(1, '2026-10-09', 'morning', '08:00', '10:00'),
|
||||
],
|
||||
);
|
||||
|
||||
// Same slot id, different days. Grouping on the id alone would put two days'
|
||||
// work under one heading with one deadline.
|
||||
assert.equal(groupByWindow(stops).length, 2);
|
||||
});
|
||||
|
||||
test('groupByWindow puts stops with no window last', () => {
|
||||
const stops = toStops(
|
||||
[],
|
||||
[order({ orderheaderid: 1 }), windowed(3, '2026-10-08', 'evening', '17:00', '20:00')],
|
||||
);
|
||||
|
||||
const groups = groupByWindow(stops);
|
||||
// It is the biggest group by far today. First, it would push every group
|
||||
// with an actual deadline off the screen.
|
||||
assert.equal(groups.at(-1)?.id, NO_WINDOW);
|
||||
assert.equal(groups.at(-1)?.name, 'No delivery window');
|
||||
});
|
||||
|
||||
test('groupByWindow keeps a window that has already closed', () => {
|
||||
const stops = toStops([], [windowed(1, '2020-01-01', 'morning', '08:00', '10:00')]);
|
||||
|
||||
// A morning group still holding undelivered stops in the afternoon is the
|
||||
// most important thing on this screen. Dropping it to tidy the board would
|
||||
// hide the one group somebody needs to act on.
|
||||
const groups = groupByWindow(stops);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(groups[0]?.stops.length, 1);
|
||||
});
|
||||
|
||||
test('groupByWindow treats a window with no date as no window', () => {
|
||||
const stops = toStops(
|
||||
[],
|
||||
[order({ orderheaderid: 7, deliveryslotid: 2, deliveryslotdate: '' } as Partial<OrderRow>)],
|
||||
);
|
||||
|
||||
// Without a date it cannot be placed on the timeline, and inventing one
|
||||
// would file somebody's order under the wrong deadline.
|
||||
assert.equal(groupByWindow(stops)[0]?.id, NO_WINDOW);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ import { isRealName } from './orderStatus';
|
||||
* Pure, so every rule below is testable without a map or a network.
|
||||
*/
|
||||
|
||||
export type ViewMode = 'stores' | 'customers' | 'riders';
|
||||
export type ViewMode = 'stores' | 'customers' | 'riders' | 'windows';
|
||||
|
||||
/** A job somebody is carrying, or an order still waiting for a rider. */
|
||||
export type Stop =
|
||||
@@ -481,3 +481,94 @@ export function isToday(day: string): boolean {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/** Stops with no delivery window, which is most of them while this rolls out. */
|
||||
export const NO_WINDOW = 'no-window';
|
||||
|
||||
/**
|
||||
* Grouped by the delivery window the customer asked for.
|
||||
*
|
||||
* ── Why this board exists ───────────────────────────────────────────────────
|
||||
*
|
||||
* The other three answer "who is carrying this" and "where is it going". This
|
||||
* one answers "what has to leave next", which is the question a window creates
|
||||
* and the only one the rider, store and customer boards cannot be asked.
|
||||
*
|
||||
* ── Order ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* By date then start time, so the soonest window is at the top and tomorrow's
|
||||
* sits below today's. "No window" goes LAST rather than first: it is the
|
||||
* largest group by far today and would otherwise push every dated group off
|
||||
* the screen, burying the work that actually has a deadline.
|
||||
*
|
||||
* ── Windows that have closed are kept ───────────────────────────────────────
|
||||
*
|
||||
* A morning group still holding undelivered stops at 2pm is the most important
|
||||
* thing on this screen. It stays, in place, in time order — dropping or
|
||||
* collapsing it would hide exactly the group somebody needs to act on. The
|
||||
* caller decides how to mark it; this function only refuses to lose it.
|
||||
*/
|
||||
export function groupByWindow(stops: readonly Stop[]): Group[] {
|
||||
const map = new Map<string, Group>();
|
||||
// The sort key per group, kept aside so the Group shape stays unchanged.
|
||||
const sortKeys = new Map<string, string>();
|
||||
|
||||
for (const stop of stops) {
|
||||
const row = stop.row as {
|
||||
deliveryslotid?: number;
|
||||
deliveryslotdate?: string;
|
||||
slotkey?: string;
|
||||
deliveryslotname?: string;
|
||||
deliveryslotstart?: string;
|
||||
deliveryslotend?: string;
|
||||
};
|
||||
|
||||
const slotId = Number(row.deliveryslotid ?? 0);
|
||||
const date = (row.deliveryslotdate ?? '').trim().slice(0, 10);
|
||||
|
||||
// Both are needed. A window without its date cannot be told from the same
|
||||
// window tomorrow, and grouping them together would put two days' work
|
||||
// under one heading with one deadline.
|
||||
const id = slotId > 0 && date ? `${date}#${slotId}` : NO_WINDOW;
|
||||
|
||||
let group = map.get(id);
|
||||
if (!group) {
|
||||
if (id === NO_WINDOW) {
|
||||
group = blank(NO_WINDOW, 'No delivery window');
|
||||
// Sorts last: 'z' beats every date string.
|
||||
sortKeys.set(id, 'zzzz');
|
||||
} else {
|
||||
const name = (row.deliveryslotname ?? '').trim() || titleCase(row.slotkey ?? 'Window');
|
||||
const hours =
|
||||
row.deliveryslotstart && row.deliveryslotend
|
||||
? `${row.deliveryslotstart}–${row.deliveryslotend}`
|
||||
: undefined;
|
||||
group = blank(id, `${name} · ${readableDate(date)}`, hours);
|
||||
sortKeys.set(id, `${date} ${row.deliveryslotstart ?? '00:00'}`);
|
||||
}
|
||||
map.set(id, group);
|
||||
}
|
||||
fold(group, stop);
|
||||
}
|
||||
|
||||
for (const group of map.values()) {
|
||||
group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b)));
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) =>
|
||||
(sortKeys.get(a.id) ?? '').localeCompare(sortKeys.get(b.id) ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value ? value[0]!.toUpperCase() + value.slice(1) : '';
|
||||
}
|
||||
|
||||
/** "2026-10-08" → "8 Oct", or the raw value if it will not parse — better a
|
||||
heading somebody can match against the data than a silent "Invalid Date". */
|
||||
function readableDate(date: string): string {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) return date;
|
||||
return parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
Bike,
|
||||
Clock,
|
||||
IndianRupee,
|
||||
MapPin,
|
||||
Radio,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow';
|
||||
import { groupByWindow } from '../dispatchModel';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { StickyRow } from '@/components/StickyRow';
|
||||
@@ -70,7 +72,7 @@ import './dispatch.css';
|
||||
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
||||
|
||||
type Board = ViewMode | 'timing' | 'active' | 'profit';
|
||||
const GROUPINGS = new Set<Board>(['riders', 'stores', 'customers']);
|
||||
const GROUPINGS = new Set<Board>(['riders', 'stores', 'customers', 'windows']);
|
||||
const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board);
|
||||
|
||||
/**
|
||||
@@ -186,6 +188,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
if (!isGrouping(mode)) return [];
|
||||
if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches);
|
||||
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
|
||||
if (mode === 'windows') return groupByWindow(stops);
|
||||
return groupByRider(stops);
|
||||
}, [mode, stops, locationsQuery.data, branches, customers.data, branchName]);
|
||||
|
||||
@@ -287,6 +290,14 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
isActive={mode === 'stores'}
|
||||
onClick={() => changeMode('stores')}
|
||||
/>
|
||||
{/* What has to leave next — the question a delivery window creates, and
|
||||
the one the rider, store and customer boards cannot be asked. */}
|
||||
<Tab
|
||||
label="By window"
|
||||
icon={<Clock size={14} />}
|
||||
isActive={mode === 'windows'}
|
||||
onClick={() => changeMode('windows')}
|
||||
/>
|
||||
{/* A shop's customer book is its own. Grouping a partner's rounds by
|
||||
customer would mean reading every merchant's book to put a name to
|
||||
a phone number, so the tab is not offered on a partner board. */}
|
||||
|
||||
Reference in New Issue
Block a user