delivery slot updated in orders and deliveries fix

This commit is contained in:
2026-10-08 11:06:55 +05:30
parent d24df891fb
commit 262b83ffbc
3 changed files with 196 additions and 2 deletions

View File

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

View File

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

View File

@@ -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. */}