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 {
|
import {
|
||||||
dayTotals,
|
dayTotals,
|
||||||
groupByCustomer,
|
groupByCustomer,
|
||||||
|
groupByWindow,
|
||||||
groupByRider,
|
groupByRider,
|
||||||
groupByStore,
|
groupByStore,
|
||||||
isToday,
|
isToday,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
stopKey,
|
stopKey,
|
||||||
stopValue,
|
stopValue,
|
||||||
toStops,
|
toStops,
|
||||||
|
NO_WINDOW,
|
||||||
UNASSIGNED,
|
UNASSIGNED,
|
||||||
WAITING,
|
WAITING,
|
||||||
ymd,
|
ymd,
|
||||||
@@ -407,3 +409,93 @@ test('today is today', () => {
|
|||||||
assert.equal(isToday(ymd(new Date())), true);
|
assert.equal(isToday(ymd(new Date())), true);
|
||||||
assert.equal(isToday('2020-01-01'), false);
|
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.
|
* 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. */
|
/** A job somebody is carrying, or an order still waiting for a rider. */
|
||||||
export type Stop =
|
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 { VStack } from '@astryxdesign/core/VStack';
|
||||||
import {
|
import {
|
||||||
Bike,
|
Bike,
|
||||||
|
Clock,
|
||||||
IndianRupee,
|
IndianRupee,
|
||||||
MapPin,
|
MapPin,
|
||||||
Radio,
|
Radio,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||||
import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow';
|
import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow';
|
||||||
|
import { groupByWindow } from '../dispatchModel';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { Tab, TabBar } from '@/components/TabBar';
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { StickyRow } from '@/components/StickyRow';
|
import { StickyRow } from '@/components/StickyRow';
|
||||||
@@ -70,7 +72,7 @@ import './dispatch.css';
|
|||||||
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
||||||
|
|
||||||
type Board = ViewMode | 'timing' | 'active' | 'profit';
|
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);
|
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 (!isGrouping(mode)) return [];
|
||||||
if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches);
|
if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches);
|
||||||
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
|
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
|
||||||
|
if (mode === 'windows') return groupByWindow(stops);
|
||||||
return groupByRider(stops);
|
return groupByRider(stops);
|
||||||
}, [mode, stops, locationsQuery.data, branches, customers.data, branchName]);
|
}, [mode, stops, locationsQuery.data, branches, customers.data, branchName]);
|
||||||
|
|
||||||
@@ -287,6 +290,14 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
isActive={mode === 'stores'}
|
isActive={mode === 'stores'}
|
||||||
onClick={() => changeMode('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
|
{/* 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
|
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. */}
|
a phone number, so the tab is not offered on a partner board. */}
|
||||||
|
|||||||
Reference in New Issue
Block a user