delivery slot updated on orders and deliveries
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
126
src/features/store-admin/deliveryWindow.test.ts
Normal file
126
src/features/store-admin/deliveryWindow.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
113
src/features/store-admin/deliveryWindow.ts
Normal file
113
src/features/store-admin/deliveryWindow.ts
Normal 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' });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user