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;
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;
canceltime?: 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;
orderamount?: number;
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,
} from 'lucide-react';
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow';
import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow';
@@ -97,6 +98,15 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const [detail, setDetail] = useState<Stop | null>(null);
const [searchQuery, setSearchQuery] = useState('');
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`. */
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
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(
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
@@ -420,6 +436,30 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
>
Unassigned
</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>
<EnhancedGroupList

View File

@@ -1,4 +1,12 @@
import { useState, useMemo } from 'react';
import {
WINDOW_FILTERS,
matchesWindow,
windowHasPassed,
windowHours,
windowLabel,
type WindowFilterKey,
} from '../deliveryWindow';
import { useDateScope } from '@/components/shell/DateScope';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card';
@@ -116,6 +124,14 @@ export function SalesPage() {
const { scoped, selected, branches, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('orders');
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 [keyword, setKeyword] = useState('');
/* 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.
*/
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],
);
const deliveryRows = useMemo(
() => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)),
() =>
allDeliveries.filter(
(row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row),
),
[allDeliveries, status],
);
@@ -453,6 +477,10 @@ export function SalesPage() {
counts={tabCounts}
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.
Assigning is something you do WHILE reading the day's orders —
glancing between the drop addresses and who is free — so the
@@ -772,6 +800,7 @@ function OrdersTable({
<Th>Drop</Th>
<Th>Qty</Th>
<Th>Amount</Th>
<Th>Window</Th>
<Th>Status</Th>
<Th />
</tr>
@@ -855,6 +884,7 @@ function OrdersTable({
<Td isStrong={amount > 0} isMuted={amount === 0}>
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td>
<WindowCell row={row} />
<Td>
{/* The stage, not the order's own word — which sits on `pending`
from assignment until delivery. The ladder has to follow
@@ -924,6 +954,7 @@ function DeliveriesTable({
<Th>Rider</Th>
<Th>KM</Th>
<Th>Charge / value</Th>
<Th>Window</Th>
<Th>Status</Th>
<Th>Notes</Th>
</tr>
@@ -1027,6 +1058,7 @@ function DeliveriesTable({
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
) : null}
</Td>
<WindowCell row={row} />
<Td>
<Chip
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>
);
}