delivery slot updated on orders and deliveries

This commit is contained in:
2026-10-06 19:40:42 +05:30
parent 56e8a95c6a
commit d24df891fb
5 changed files with 407 additions and 3 deletions

View File

@@ -698,6 +698,11 @@ export interface OrderRow {
*/ */
deliveryslotid?: number; deliveryslotid?: number;
deliveryslotdate?: string; deliveryslotdate?: string;
/** Joined from `deliveryslots`, so a renamed window reads correctly on old orders. */
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
} }
/** /**
@@ -912,6 +917,19 @@ export interface DeliveryRow {
deliverytime?: string; deliverytime?: string;
canceltime?: string; canceltime?: string;
expecteddeliverytime?: string; expecteddeliverytime?: string;
/**
* The delivery window the customer asked for.
*
* Joined from the ORDER — `deliveries` has no window of its own, on purpose,
* so there is one truth about what was asked for. Absent on most rows.
*/
deliveryslotid?: number;
deliveryslotdate?: string;
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
itemcount?: number; itemcount?: number;
orderamount?: number; orderamount?: number;
pickupcustomer?: string; pickupcustomer?: string;

View File

@@ -0,0 +1,126 @@
/**
* Reading a delivery window off a row.
*
* The filter and the column both run through these, and the case that matters
* most is the dull one: a row with NO window. Windows are set per branch and
* most branches have none, so "absent" is the ordinary state and anything that
* treats it as missing data would mislabel almost every row on the screen.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
hasWindow,
matchesWindow,
windowHasPassed,
windowHours,
windowLabel,
type WithWindow,
} from './deliveryWindow';
const evening: WithWindow = {
deliveryslotid: 3,
deliveryslotdate: '2026-10-06',
slotkey: 'evening',
deliveryslotname: 'Evening',
deliveryslotstart: '17:00',
deliveryslotend: '20:00',
};
/** The common row: placed before windows existed, or from a branch with none. */
const noWindow: WithWindow = {};
describe('hasWindow', () => {
it('is false for a row that named none', () => {
assert.equal(hasWindow(noWindow), false);
});
it('is false for an explicit zero, not just an absent key', () => {
// The backend sends 0 rather than omitting the field on some responses.
assert.equal(hasWindow({ deliveryslotid: 0 }), false);
});
it('is true once a window is named', () => {
assert.equal(hasWindow(evening), true);
});
});
describe('matchesWindow', () => {
it('lets everything through on "all", including rows with no window', () => {
assert.equal(matchesWindow('all', evening), true);
assert.equal(matchesWindow('all', noWindow), true);
});
it('matches on the key', () => {
assert.equal(matchesWindow('evening', evening), true);
assert.equal(matchesWindow('morning', evening), false);
});
it('"none" finds exactly the rows that chose nothing', () => {
// The whole point of that option: while this rolls out, these are most
// orders, and an operator needs to be able to see them as a group.
assert.equal(matchesWindow('none', noWindow), true);
assert.equal(matchesWindow('none', evening), false);
});
it('is not confused by case from the server', () => {
assert.equal(matchesWindow('evening', { ...evening, slotkey: 'Evening' }), true);
});
});
describe('windowLabel', () => {
it('is null when there is no window, so the cell can say so plainly', () => {
assert.equal(windowLabel(noWindow), null);
});
it('carries the date as well as the name', () => {
// "Morning" alone cannot distinguish an order placed at 9pm for tomorrow
// from one placed at 7am for today, and a dispatcher reading a list of them
// needs to know which day each is for.
const label = windowLabel(evening);
assert.ok(label?.startsWith('Evening'), `got ${label}`);
assert.ok(label?.includes('6'), `date missing from ${label}`);
});
it('falls back to the key when a shop cleared the name', () => {
assert.ok(windowLabel({ ...evening, deliveryslotname: '' })?.startsWith('Evening'));
});
it('shows the name alone rather than a half-parsed date', () => {
assert.equal(windowLabel({ ...evening, deliveryslotdate: 'not-a-date' }), 'Evening');
});
});
describe('windowHours', () => {
it('reads as a range', () => {
assert.equal(windowHours(evening), '17:00–20:00');
});
it('is null when either end is missing', () => {
assert.equal(windowHours({ ...evening, deliveryslotend: '' }), null);
});
});
describe('windowHasPassed', () => {
it('is false for a row with no window', () => {
// Nothing was promised, so nothing can be late.
assert.equal(windowHasPassed(noWindow), false);
});
it('is true once the window has ended', () => {
assert.equal(
windowHasPassed({ ...evening, deliveryslotdate: '2020-01-01' }),
true,
);
});
it('is false for a window still to come', () => {
assert.equal(windowHasPassed({ ...evening, deliveryslotdate: '2099-01-01' }), false);
});
it('is false rather than true when the time is unreadable', () => {
// This only tints a row. Guessing "late" from unreadable data would put a
// red mark on rows nobody can act on.
assert.equal(windowHasPassed({ ...evening, deliveryslotend: 'evening' }), false);
});
});

View File

@@ -0,0 +1,113 @@
/**
* Reading the delivery window off an order or a delivery row.
*
* Both carry the same six fields — the order holds them, and the delivery
* query joins them off the order — so one set of helpers serves the orders
* table, the deliveries table and anything added later.
*
* ── Most rows have no window ────────────────────────────────────────────────
*
* Windows are new and set per branch, so the overwhelming majority of rows have
* none and always will. Absence is the ordinary case, not missing data, and
* every helper here answers it plainly rather than with a blank that reads like
* a loading state.
*/
/** The shape both OrderRow and DeliveryRow satisfy once the window is joined. */
export interface WithWindow {
deliveryslotid?: number;
deliveryslotdate?: string;
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
}
/**
* The filter's options.
*
* `none` is not a leftover — it is how an operator finds the orders that were
* placed without choosing a window, which is most of them today and is exactly
* the population worth looking at while windows are being rolled out.
*/
export const WINDOW_FILTERS = [
{ key: 'all', label: 'All windows' },
{ key: 'morning', label: 'Morning' },
{ key: 'afternoon', label: 'Afternoon' },
{ key: 'evening', label: 'Evening' },
{ key: 'none', label: 'No window' },
] as const;
export type WindowFilterKey = (typeof WINDOW_FILTERS)[number]['key'];
export function hasWindow(row: WithWindow): boolean {
return Number(row.deliveryslotid ?? 0) > 0;
}
export function matchesWindow(filter: WindowFilterKey, row: WithWindow): boolean {
if (filter === 'all') return true;
if (filter === 'none') return !hasWindow(row);
return hasWindow(row) && (row.slotkey ?? '').toLowerCase() === filter;
}
/**
* What the cell reads: "Evening · 6 Oct".
*
* The DATE is part of it and not decoration. An order placed at 9pm for
* tomorrow morning and one placed at 7am for this morning are both "Morning",
* and a dispatcher reading a list of them needs to know which day each is for.
*/
export function windowLabel(row: WithWindow): string | null {
if (!hasWindow(row)) return null;
const name = (row.deliveryslotname ?? '').trim() || titleCase(row.slotkey ?? '');
const date = shortDate(row.deliveryslotdate);
return date ? `${name} · ${date}` : name;
}
/** The hours, for a tooltip or a second line: "17:00–20:00". */
export function windowHours(row: WithWindow): string | null {
const start = (row.deliveryslotstart ?? '').trim();
const end = (row.deliveryslotend ?? '').trim();
return start && end ? `${start}–${end}` : null;
}
/**
* Is this window already over?
*
* Only ever used to TINT a row that is still undelivered — never to filter one
* away. A morning order still sitting there at 2pm is the most important thing
* on the screen, and hiding it to keep the list tidy would bury the one row
* somebody needs to act on.
*
* Decided on the browser's clock, which is fine for a visual hint and is not
* fine for anything else: the server owns whether a window is open, because the
* device's clock is not something we control.
*/
export function windowHasPassed(row: WithWindow): boolean {
if (!hasWindow(row)) return false;
const date = (row.deliveryslotdate ?? '').trim();
const end = (row.deliveryslotend ?? '').trim();
if (!date || !/^\d{2}:\d{2}$/.test(end)) return false;
const ended = new Date(`${date}T${end}:00`);
return !Number.isNaN(ended.getTime()) && ended.getTime() < Date.now();
}
function titleCase(value: string): string {
return value ? value[0]!.toUpperCase() + value.slice(1) : '';
}
/** "2026-10-06" → "6 Oct". Returns null for anything unreadable rather than
printing a half-parsed date beside a window name. */
function shortDate(value: string | undefined): string | null {
const trimmed = (value ?? '').trim();
if (!/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return null;
const parsed = new Date(`${trimmed.slice(0, 10)}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}

View File

@@ -21,6 +21,7 @@ import {
TriangleAlert, TriangleAlert,
} 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 { 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';
@@ -97,6 +98,15 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const [detail, setDetail] = useState<Stop | null>(null); const [detail, setDetail] = useState<Stop | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all');
/*
Which delivery window the board is showing.
Applied to the DELIVERIES, before they become stops — so the map, the
totals, the rider groups and the list all narrow together. Filtering only
the visible list would leave the counters above it describing a different
day's work to the one on screen.
*/
const [windowFilter, setWindowFilter] = useState<WindowFilterKey>('all');
/* One id or the other, never both — see `DispatchScope`. */ /* One id or the other, never both — see `DispatchScope`. */
const isPartnerBoard = scope.kind === 'partner'; const isPartnerBoard = scope.kind === 'partner';
@@ -148,7 +158,13 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
vanished from every chip except "All". The date filter in the top bar already vanished from every chip except "All". The date filter in the top bar already
narrows the day and never drops a row. narrows the day and never drops a row.
*/ */
const filteredDeliveries = rawDeliveries; const filteredDeliveries = useMemo(
() =>
windowFilter === 'all'
? rawDeliveries
: rawDeliveries.filter((row) => matchesWindow(windowFilter, row)),
[rawDeliveries, windowFilter],
);
const isOneDay = Boolean( const isOneDay = Boolean(
dates.range.fromdate && dates.range.fromdate === dates.range.todate, dates.range.fromdate && dates.range.fromdate === dates.range.todate,
@@ -420,6 +436,30 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
> >
Unassigned Unassigned
</button> </button>
{/* Narrows the whole board, not just this list — the counters
and the map follow it, because a count that describes more
work than the screen shows is worse than no count. */}
<select
value={windowFilter}
onChange={(event) => setWindowFilter(event.target.value as WindowFilterKey)}
aria-label="Filter by delivery window"
style={{
marginLeft: 8,
padding: '5px 9px',
fontSize: 12,
border: '1px solid var(--color-line, #d7dce5)',
borderRadius: 999,
background: 'var(--color-surface, #fff)',
color: 'var(--color-ink-1)',
}}
>
{WINDOW_FILTERS.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div> </div>
<EnhancedGroupList <EnhancedGroupList

View File

@@ -1,4 +1,12 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import {
WINDOW_FILTERS,
matchesWindow,
windowHasPassed,
windowHours,
windowLabel,
type WindowFilterKey,
} from '../deliveryWindow';
import { useDateScope } from '@/components/shell/DateScope'; import { useDateScope } from '@/components/shell/DateScope';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
@@ -116,6 +124,14 @@ export function SalesPage() {
const { scoped, selected, branches, tenantid } = useBranchScope(); const { scoped, selected, branches, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('orders'); const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all'); const [status, setStatus] = useState<StatusKey>('all');
/*
Which delivery window to show.
Separate from `status`, and both apply: "evening orders that are still
pending" is the question a dispatcher actually asks, and folding the two
into one control would make it unaskable.
*/
const [windowFilter, setWindowFilter] = useState<WindowFilterKey>('all');
const dates = useDateScope(); const dates = useDateScope();
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
/* What the tables are currently showing. Any change to it sends every pager /* What the tables are currently showing. Any change to it sends every pager
@@ -181,11 +197,19 @@ export function SalesPage() {
* carries those tabs now, so the filter follows the column. * carries those tabs now, so the filter follows the column.
*/ */
const orderRows = useMemo( const orderRows = useMemo(
() => allOrders.filter((row) => matchesStatus(status, orderStage(row, stages).status)), () =>
allOrders.filter(
(row) =>
matchesStatus(status, orderStage(row, stages).status) &&
matchesWindow(windowFilter, row),
),
[allOrders, status, stages], [allOrders, status, stages],
); );
const deliveryRows = useMemo( const deliveryRows = useMemo(
() => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)), () =>
allDeliveries.filter(
(row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row),
),
[allDeliveries, status], [allDeliveries, status],
); );
@@ -453,6 +477,10 @@ export function SalesPage() {
counts={tabCounts} counts={tabCounts}
onStatus={setStatus} onStatus={setStatus}
/> />
{/* Beside the status tabs, not inside them. Both filters apply at
once, because "evening orders still pending" is the question
somebody actually has. */}
<WindowFilter value={windowFilter} onChange={setWindowFilter} />
{/* Above the table, not below it and not in a drawer. {/* Above the table, not below it and not in a drawer.
Assigning is something you do WHILE reading the day's orders — Assigning is something you do WHILE reading the day's orders —
glancing between the drop addresses and who is free — so the glancing between the drop addresses and who is free — so the
@@ -772,6 +800,7 @@ function OrdersTable({
<Th>Drop</Th> <Th>Drop</Th>
<Th>Qty</Th> <Th>Qty</Th>
<Th>Amount</Th> <Th>Amount</Th>
<Th>Window</Th>
<Th>Status</Th> <Th>Status</Th>
<Th /> <Th />
</tr> </tr>
@@ -855,6 +884,7 @@ function OrdersTable({
<Td isStrong={amount > 0} isMuted={amount === 0}> <Td isStrong={amount > 0} isMuted={amount === 0}>
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono> <Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td> </Td>
<WindowCell row={row} />
<Td> <Td>
{/* The stage, not the order's own word — which sits on `pending` {/* The stage, not the order's own word — which sits on `pending`
from assignment until delivery. The ladder has to follow from assignment until delivery. The ladder has to follow
@@ -924,6 +954,7 @@ function DeliveriesTable({
<Th>Rider</Th> <Th>Rider</Th>
<Th>KM</Th> <Th>KM</Th>
<Th>Charge / value</Th> <Th>Charge / value</Th>
<Th>Window</Th>
<Th>Status</Th> <Th>Status</Th>
<Th>Notes</Th> <Th>Notes</Th>
</tr> </tr>
@@ -1027,6 +1058,7 @@ function DeliveriesTable({
<span style={{ color: 'var(--color-ink-4)' }}>—</span> <span style={{ color: 'var(--color-ink-4)' }}>—</span>
) : null} ) : null}
</Td> </Td>
<WindowCell row={row} />
<Td> <Td>
<Chip <Chip
label={row.orderstatus} label={row.orderstatus}
@@ -1457,3 +1489,78 @@ function Td({
); );
} }
/* ── Delivery window ──────────────────────────────────────────────────────── */
/**
* The window a customer asked for, as a table cell.
*
* Most rows have none, and those read "—" rather than staying blank: an empty
* cell in a table of filled ones looks like something failed to load, and this
* is the ordinary case, not a failure.
*/
function WindowCell({ row }: { row: OrderRow | DeliveryRow }) {
const label = windowLabel(row);
if (!label) {
return (
<Td isMuted>
<span>—</span>
</Td>
);
}
const hours = windowHours(row);
// A window that has ended while the order is still on the board is the one
// thing here worth noticing. Tinted, never hidden — burying it would hide the
// row somebody most needs to act on.
const isLate = windowHasPassed(row);
return (
<Td>
<span
title={hours ?? undefined}
style={isLate ? { color: 'var(--color-error, #d64545)', fontWeight: 600 } : undefined}
>
{label}
</span>
</Td>
);
}
/**
* Which window to show.
*
* "No window" earns its place: it is how an operator finds orders placed
* without choosing one, which is most of them while this rolls out, and is the
* population worth watching to see the feature being used at all.
*/
function WindowFilter({
value,
onChange,
}: {
value: WindowFilterKey;
onChange: (next: WindowFilterKey) => void;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value as WindowFilterKey)}
aria-label="Filter by delivery window"
style={{
padding: '6px 10px',
fontSize: 12.5,
border: '1px solid var(--color-line, #d7dce5)',
borderRadius: 7,
background: 'var(--color-surface, #fff)',
color: 'var(--color-ink-1)',
}}
>
{WINDOW_FILTERS.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
);
}