delivery slot ui
This commit is contained in:
65
src/features/store-admin/WindowFilterSelect.tsx
Normal file
65
src/features/store-admin/WindowFilterSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user