delivery slot ui

This commit is contained in:
2026-10-08 15:09:36 +05:30
parent 262b83ffbc
commit 8960fb8ebb
3 changed files with 107 additions and 73 deletions

View File

@@ -0,0 +1,65 @@
import { WINDOW_FILTERS, type WindowFilterKey } from './deliveryWindow';
/**
* Pick a delivery window to narrow by.
*
* One component, because this control now stands in two places — the dispatch
* board's view switcher and the sales page's status strip — and both sit in a
* row of tab-shaped buttons. Styled twice, the two would drift the first time
* either row was touched, and a filter that looks subtly different on two
* screens reads as two different filters.
*
* ── Shaped like a tab, deliberately not pretending to be one ────────────────
*
* The measurements below are `.tabbar-tab`'s own — 32px tall, 13px/500 type,
* the radius derived from the same token — so it belongs in the strip rather
* than looking like a form field dropped beside it.
*
* It keeps what a tab does not: a visible border, because it stands outside the
* sunken track the tabs sit on and would otherwise float, and a chevron,
* because it opens rather than toggles. Making it indistinguishable from a tab
* would promise a click that does something it does not do.
*/
export function WindowFilterSelect({
value,
onChange,
/** `auto` pushes it to the end of a flex row. The sales strip wants it inline. */
align = 'inline',
}: {
value: WindowFilterKey;
onChange: (next: WindowFilterKey) => void;
align?: 'inline' | 'end';
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value as WindowFilterKey)}
aria-label="Filter by delivery window"
style={{
...(align === 'end' ? { marginLeft: 'auto' } : {}),
height: 32,
padding: '0 28px 0 12px',
border: '1px solid var(--color-line)',
borderRadius: 'calc(var(--card-radius-sm) - 3px)',
background: 'var(--color-surface)',
color: 'var(--color-ink-3)',
font: '500 13px/1 var(--font-sans)',
cursor: 'pointer',
/* The browser's own arrow box is a different height on every platform
and breaks the 32px line that keeps this level with the tabs beside
it, so the chevron is drawn here instead. */
appearance: 'none',
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23777' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>\")",
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 9px center',
}}
>
{WINDOW_FILTERS.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
);
}

View File

@@ -22,8 +22,9 @@ import {
TriangleAlert,
} from 'lucide-react';
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow';
import { matchesWindow, type WindowFilterKey } from '../deliveryWindow';
import { groupByWindow } from '../dispatchModel';
import { WindowFilterSelect } from '../WindowFilterSelect';
import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow';
@@ -275,6 +276,17 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
own switcher instead of going through `PageHeader`, so it asks for
the behaviour directly — same component, same row, same canvas. */}
<StickyRow>
{/* A row of its own, because `.page-sticky` is a COLUMN flex — without
this the filter drops onto its own line under the tabs. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
flexWrap: 'wrap',
}}
>
<TabBar aria-label="Dispatch views">
<Tab
label="By rider"
@@ -334,6 +346,15 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
onClick={() => changeMode('profit')}
/>
</TabBar>
{/* Beside the board switcher, not down with the status pills.
It narrows every board — the groups, the counters above them and the
map — so it belongs with the controls that change what the whole
screen is about, rather than looking like one more status filter
over a single list. */}
<WindowFilterSelect value={windowFilter} onChange={setWindowFilter} align="end" />
</div>
</StickyRow>
@@ -448,29 +469,6 @@ 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,12 +1,12 @@
import { useState, useMemo } from 'react';
import {
WINDOW_FILTERS,
matchesWindow,
windowHasPassed,
windowHours,
windowLabel,
type WindowFilterKey,
} from '../deliveryWindow';
import { WindowFilterSelect } from '../WindowFilterSelect';
import { useDateScope } from '@/components/shell/DateScope';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card';
@@ -203,14 +203,17 @@ export function SalesPage() {
matchesStatus(status, orderStage(row, stages).status) &&
matchesWindow(windowFilter, row),
),
[allOrders, status, stages],
// windowFilter belongs here. Left out, this memo never recomputed when the
// dropdown changed, so the control moved and the table did not — a filter
// that silently does nothing.
[allOrders, status, stages, windowFilter],
);
const deliveryRows = useMemo(
() =>
allDeliveries.filter(
(row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row),
),
[allDeliveries, status],
[allDeliveries, status, windowFilter],
);
const billRows = useMemo(() => {
@@ -387,12 +390,20 @@ export function SalesPage() {
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
isTabsInline
actions={
<SearchInput
label="Search sales"
value={keyword}
onChange={setKeyword}
placeholder={SEARCH_HINT[tab]}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* Not on the counter tab. Imported till bills are not deliveries
and can never carry a window, so the control would be one that
does nothing on a third of this page. */}
{tab === 'counter' ? null : (
<WindowFilterSelect value={windowFilter} onChange={setWindowFilter} />
)}
<SearchInput
label="Search sales"
value={keyword}
onChange={setKeyword}
placeholder={SEARCH_HINT[tab]}
/>
</div>
}
tabs={
/* THREE views, not four.
@@ -477,10 +488,6 @@ 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
@@ -1528,39 +1535,3 @@ function WindowCell({ row }: { row: OrderRow | DeliveryRow }) {
);
}
/**
* 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>
);
}