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,
|
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 { matchesWindow, type WindowFilterKey } from '../deliveryWindow';
|
||||||
import { groupByWindow } from '../dispatchModel';
|
import { groupByWindow } from '../dispatchModel';
|
||||||
|
import { WindowFilterSelect } from '../WindowFilterSelect';
|
||||||
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';
|
||||||
@@ -275,6 +276,17 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
own switcher instead of going through `PageHeader`, so it asks for
|
own switcher instead of going through `PageHeader`, so it asks for
|
||||||
the behaviour directly — same component, same row, same canvas. */}
|
the behaviour directly — same component, same row, same canvas. */}
|
||||||
<StickyRow>
|
<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">
|
<TabBar aria-label="Dispatch views">
|
||||||
<Tab
|
<Tab
|
||||||
label="By rider"
|
label="By rider"
|
||||||
@@ -334,6 +346,15 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
onClick={() => changeMode('profit')}
|
onClick={() => changeMode('profit')}
|
||||||
/>
|
/>
|
||||||
</TabBar>
|
</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>
|
</StickyRow>
|
||||||
|
|
||||||
|
|
||||||
@@ -448,29 +469,6 @@ 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,12 +1,12 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
WINDOW_FILTERS,
|
|
||||||
matchesWindow,
|
matchesWindow,
|
||||||
windowHasPassed,
|
windowHasPassed,
|
||||||
windowHours,
|
windowHours,
|
||||||
windowLabel,
|
windowLabel,
|
||||||
type WindowFilterKey,
|
type WindowFilterKey,
|
||||||
} from '../deliveryWindow';
|
} from '../deliveryWindow';
|
||||||
|
import { WindowFilterSelect } from '../WindowFilterSelect';
|
||||||
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';
|
||||||
@@ -203,14 +203,17 @@ export function SalesPage() {
|
|||||||
matchesStatus(status, orderStage(row, stages).status) &&
|
matchesStatus(status, orderStage(row, stages).status) &&
|
||||||
matchesWindow(windowFilter, row),
|
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(
|
const deliveryRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
allDeliveries.filter(
|
allDeliveries.filter(
|
||||||
(row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row),
|
(row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row),
|
||||||
),
|
),
|
||||||
[allDeliveries, status],
|
[allDeliveries, status, windowFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const billRows = useMemo(() => {
|
const billRows = useMemo(() => {
|
||||||
@@ -387,12 +390,20 @@ export function SalesPage() {
|
|||||||
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
|
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
|
||||||
isTabsInline
|
isTabsInline
|
||||||
actions={
|
actions={
|
||||||
|
<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
|
<SearchInput
|
||||||
label="Search sales"
|
label="Search sales"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder={SEARCH_HINT[tab]}
|
placeholder={SEARCH_HINT[tab]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
/* THREE views, not four.
|
/* THREE views, not four.
|
||||||
@@ -477,10 +488,6 @@ 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
|
||||||
@@ -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