ui improvement

This commit is contained in:
2026-09-17 17:57:21 +05:30
parent 2cd048e6f0
commit 115a06a02c
79 changed files with 3574 additions and 2186 deletions

View File

@@ -75,7 +75,7 @@ export class ErrorBoundary extends Component<Props, State> {
margin: '48px auto',
maxWidth: 620,
padding: '28px 32px',
borderRadius: 16,
borderRadius: 'var(--card-radius)',
border: '1px solid #F1D3D3',
background: '#FFFBFB',
fontFamily: 'inherit',

View File

@@ -1,99 +1,89 @@
import type { ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import './kpiCard.css';
/**
* A KPI tile — the console's headline figure.
*
* ── There were two of these, and neither worked ─────────────────────────────
*
* This component put an icon inline with the label and CENTRED the value
* underneath it, which is why a row of tiles never lined up: a centred number
* sits in a different place in every tile depending on how long it is.
* `console.css` had a second, unrelated implementation — `.kpi` — with a
* brand-tinted icon tile on the left and the text stacked beside it. The two
* appeared on the same screen. The second was the better design and this is
* now that design, for both.
*
* ── `note` is rendered again, and that is the real change ───────────────────
*
* The line under the figure — "142 app · 38 counter", "12% of app orders" —
* was removed from the tile at some point but never removed from the CALLERS:
* 86 of them still compute and pass it, and it was being thrown away. That is
* the half of a KPI that carries the meaning. A bare `0` under "Unsynced
* bills" reads as "no data" when it means everything is in the books, and `42`
* under "Total orders" says nothing about which channel they came through.
*
* ── `tone` does something now ───────────────────────────────────────────────
*
* All five tones used to map to `var(--color-brand)`, so the prop was
* decorative — every tile was purple whether it reported takings or a failure.
* The tone now colours the icon tile only: the figure itself stays in ink,
* because a number that changes colour with its own value is hard to compare
* against the tile beside it. Brand remains the default, so a tile that is
* merely reporting is not shouting.
*/
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
const TONE_COLOR: Record<KpiTone, string> = {
accent: 'var(--color-brand)',
success: 'var(--color-brand)',
warning: 'var(--color-brand)',
error: 'var(--color-brand)',
neutral: 'var(--color-brand)',
};
export interface KpiCardProps {
/** Small-caps label. Say what it is, not what it means. */
label: string;
value: string;
/**
* Accepted and ignored, like `fill` below.
*
* This was the sub-pill — where the number got its meaning, since "1 of 84
* orders" says something "1.2%" does not. The pill was removed from the tile
* deliberately; 87 call sites across 21 files still pass the text, so the
* prop stays declared to keep them compiling and is not read.
*
* That means every one of those 87 strings is now written and never shown.
* Either drop them from the callers or bring the pill back — right now it is
* neither, and this comment is here so that is visible rather than a puzzle
* for whoever next wonders where "84 orders" went.
* The line under the figure — what the number is made of, or what it is a
* share of. This is where a KPI gets its meaning; see the note above.
*/
note?: string;
/**
* Colours the icon tile. `neutral` and `accent` are both the brand — a tile
* reports by default and only says more when something is actually wrong.
*/
tone?: KpiTone;
icon?: ReactNode;
/**
* Accepted and ignored.
*
* The underline bar this used to drive was removed from the tile. Six call
* sites still pass it, so the prop stays declared to keep them compiling —
* it is not read. Either drop it from the callers or restore the bar; right
* now it is neither, and this comment is here so that is visible.
* The 2px underline bar this drove was removed from the tile deliberately —
* a proportion bar under five tiles turned the strip into a chart. Six call
* sites still pass it, so the prop stays declared to keep them compiling.
* Drop it from those callers and this can go.
*/
fill?: number;
}
/**
* A KPI tile.
*
* Label and icon, then a large tabular value. The sub-pill and the 2px tone
* underline were both removed from the tile; `note` and `fill` still arrive
* from the call sites and are ignored — see the props above. Sized to sit 4–6
* across rather than 4 — a console is glanced at all day, not read once.
*/
export function KpiCard({ label, value, tone = 'neutral', icon }: KpiCardProps) {
const color = TONE_COLOR[tone];
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
{/* Icon first, then the label; the pill sits opposite it. Both are
fixed to this row so tiles line up whether or not they carry one. */}
{/* A fixed height, so a label that wraps to two lines (a long note
squeezes it) does not push that tile's value below its neighbours'. */}
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
<div className="kpi-card" data-tone={tone}>
{/* Label and icon share the top row; the figure gets the full width of
the card underneath them.
The icon used to sit in a column of its own to the LEFT, and at the
width these tiles actually get — five across a 1,224px column, less
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
left about 134px for the number. "₹1,24,500" does not fit in 134px at
24px bold, so it wrapped, and it wrapped mid-figure: the first tile
on the Console read "₹1,24,50" on one line and "0" on the next. */}
<div className="kpi-card-head">
<span className="kpi-card-label">{label}</span>
{icon ? (
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
<span className="kpi-card-icon" aria-hidden>
{icon}
</span>
) : null}
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
>
{label}
</Text>
</HStack>
</div>
{/* Note pill removed as requested */}
</HStack>
<HStack justify="center" align="center" style={{ flex: 1 }}>
<Text
as="div"
hasTabularNumbers
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
>
{value}
</Text>
</HStack>
</VStack>
</Card>
<span className="kpi-card-value">{value}</span>
{note ? <span className="kpi-card-note">{note}</span> : null}
</div>
);
}

139
src/components/Panel.tsx Normal file
View File

@@ -0,0 +1,139 @@
import type { ReactNode } from 'react';
import './panel.css';
/**
* The console's card. Every surface that holds a table, a list or a form is
* one of these.
*
* ── Why it has three zones and the old card had one ─────────────────────────
*
* The card this replaces was a bare rectangle: `<Card padding={0}>` with a
* table inside it and nothing else. The title, the row count, the search box
* and the tabs all lived ABOVE it, in the page header, so the box itself said
* nothing — it drew a line around a table and stopped. No edge treatment fixes
* that. A border, a shadow, a gradient: a rectangle with no anatomy reads as
* unfinished whichever way you paint its outline, which is exactly what it
* looked like.
*
* So the card frames its content now:
*
* ┌───────────────────────────────────────┐
* │ PRODUCTS 1,248 [Filter] [+ Add] │ header — what this is,
* ├───────────────────────────────────────┤ how much of it,
* │ …the table… │ what you can do
* ├───────────────────────────────────────┤
* │ 1–25 of 1,248 ‹ 1 2 3 › │ footer — where you are in it
* └───────────────────────────────────────┘
*
* The header is not decoration. It is the three facts a person needs before
* reading a table, in the place the table is, rather than floating above it
* where they belong to the page instead of to the data.
*
* ── The edge ────────────────────────────────────────────────────────────────
*
* A hairline plus a 1px micro-lift — `0 1px 2px` at 4%. Not a blur: a wide
* soft shadow makes a panel float, and a page of five floating panels is a
* tray of slabs. This is just enough to separate the panel from the canvas
* behind it and to make the hairline read as an edge rather than as a drawn
* line.
*/
export interface PanelProps {
/**
* The eyebrow. Set in caps at 11px, because it labels the panel rather than
* titling the page — a 20px heading inside a card competes with the page's
* own, and the two stacked is what made the old layout repeat itself.
*/
title?: string;
/**
* The row count, beside the title in its own chip.
*
* Passed as a number and formatted here, so every panel in the console counts
* the same way. It is the first thing anybody checks against a filter.
*/
count?: number | undefined;
/** A word for what is counted — "products", "orders". Default: nothing. */
countLabel?: string;
/** Search, filters, a primary action. Right-aligned in the header band. */
actions?: ReactNode;
/**
* The footer band — in practice always a `<TablePager>`.
*
* Absent when the pager has nothing to say: `TablePager` returns null on a
* single page, and a footer band rendered around null is a 44px strip of
* tinted chrome under every short table. Pass it through `footer` and this
* decides; do not put a pager in `children`.
*/
footer?: ReactNode;
/**
* Padding for the body. `none` for a table, which brings its own; `md` for
* prose, a form or an empty state.
*/
padding?: 'none' | 'md';
/** Stretches the panel to fill a grid row, for a row of unequal panels. */
isFilled?: boolean;
children: ReactNode;
}
export function Panel({
title,
count,
countLabel,
actions,
footer,
padding = 'none',
isFilled,
children,
}: PanelProps) {
const hasHead = Boolean(title || actions || count !== undefined);
return (
<section className="panel-card" {...(isFilled ? { 'data-filled': 'true' } : {})}>
{hasHead ? (
<header className="panel-card-head">
<div className="panel-card-title-group">
{title ? <h2 className="panel-card-title">{title}</h2> : null}
{/* Rendered at zero too. "0 products" is a fact — the filter
matched nothing — and hiding it leaves an empty table with no
explanation of whether it is empty or still loading. */}
{count === undefined ? null : (
<span className="panel-card-count">
{count.toLocaleString('en-IN')}
{countLabel ? <span className="panel-card-count-label">{countLabel}</span> : null}
</span>
)}
</div>
{actions ? <div className="panel-card-actions">{actions}</div> : null}
</header>
) : null}
<div className="panel-card-body" data-pad={padding}>
{children}
</div>
{/* `footer` is rendered only when it is truthy, and `TablePager` returns
null rather than false — so a single-page table passes an element that
renders nothing, and the band would still draw. The check is on the
pager's own emptiness instead: see `hasFooter` below. */}
{hasFooter(footer) ? <footer className="panel-card-foot">{footer}</footer> : null}
</section>
);
}
/**
* Whether the footer has anything in it.
*
* `<TablePager>` returns `null` from its own render when there is one page, but
* the ELEMENT is still truthy here — React has not called it yet. So a plain
* `footer ? … : null` drew an empty tinted band under every table short enough
* not to need a pager, which is the one place this design could have looked
* worse than the bare box it replaces.
*
* The band is therefore driven by CSS instead: `:empty` cannot see through the
* component boundary, so the footer element is always rendered when a footer is
* passed, and `.panel-card-foot:not(:has(*))` collapses it to nothing when the
* pager rendered no DOM. `:has` is supported everywhere this console runs.
*/
function hasFooter(footer: ReactNode): boolean {
return footer !== null && footer !== undefined && footer !== false;
}

View File

@@ -0,0 +1,70 @@
import { Search, X } from 'lucide-react';
import './searchInput.css';
/**
* The console's search box, shaped to sit beside a `TabBar`.
*
* Same 32px height, same corner, same hairline and card surface as the track
* the view tabs sit in — so a row reading
*
* [ Orders 85 | Deliveries 1 | Counter sales 312 ] [ 🔍 Search… ]
*
* reads as one row of controls with a gap in it, rather than as a tab strip
* and an unrelated form field that happen to share a line.
*
* ── Why it is not Astryx's `TextInput` ──────────────────────────────────────
*
* Every page that has a search wraps `TextInput size="sm"` in a `div` with a
* hand-picked width — 230px on Users, 240px on Sales, 260px on Stores — and
* `size="sm"` is 32px tall but takes the theme's `--radius-element`, which is
* 12px. Against an 8px track that is visibly a different family of corner.
* This is the same control at the same height with the track's own radius.
*/
export interface SearchInputProps {
value: string;
onChange: (next: string) => void;
placeholder?: string;
/** The accessible name. Say what is being searched — "Search orders". */
label: string;
/**
* Width in px, or `'full'` to fill the container.
*
* The default suits a page header sitting opposite a tab strip. `'full'` is
* for a box that sits directly above the list it filters — a drawer's
* district picker, say — where a fixed width over a full-width list reads as
* two unrelated things.
*/
width?: number | 'full';
}
export function SearchInput({ value, onChange, placeholder, label, width = 260 }: SearchInputProps) {
return (
<div
className="searchbox"
{...(width === 'full' ? { 'data-full': 'true' } : { style: { width } })}
>
<Search size={14} className="searchbox-icon" aria-hidden />
<input
type="search"
className="searchbox-input"
value={value}
aria-label={label}
{...(placeholder ? { placeholder } : {})}
onChange={(event) => onChange(event.target.value)}
/>
{/* Only when there is something to clear. A permanently visible X on an
empty field is a control that does nothing, and it costs the
placeholder ten characters of room. */}
{value ? (
<button
type="button"
className="searchbox-clear"
aria-label="Clear search"
onClick={() => onChange('')}
>
<X size={13} />
</button>
) : null}
</div>
);
}

View File

@@ -66,8 +66,8 @@ export function SheetDropzone({
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 14,
border: '1px solid var(--color-line)',
borderRadius: 'var(--card-radius)',
border: 'var(--card-border)',
background: 'var(--color-surface-subtle)',
}}
>
@@ -153,7 +153,7 @@ export function SheetDropzone({
alignItems: 'center',
gap: 8,
padding: '16px 20px',
borderRadius: 13,
borderRadius: 'var(--card-radius)',
border: `1.5px dashed ${
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
}`,

88
src/components/TabBar.tsx Normal file
View File

@@ -0,0 +1,88 @@
import type { ReactNode } from 'react';
import './tabBar.css';
/**
* The console's view switcher — Orders / Deliveries / Counter sales, Catalogue
* / My stock / My requests, Store users / Terminal accounts / Riders.
*
* ── There were six of these ─────────────────────────────────────────────────
*
* `TabButton` was defined privately in `StoresPage`, `InventoryPage`,
* `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies
* of the same button, each styled inline, and they had already drifted: three
* of them filled the count badge with `--color-warning`, so "85" under Orders
* was drawn in the colour this console reserves for something being wrong. A
* count is not an alarm. Sales had the sensible version. Nobody could see the
* difference because no two of them are ever on screen together.
*
* ── Why it is a track and not loose pills ───────────────────────────────────
*
* The old buttons were transparent until selected, so an unselected set read as
* four pieces of text floating above the page with no indication they were one
* control or that picking one deselects the others. They sit in a recessed
* track now, with the selected tab raised out of it on the card surface — the
* standard segmented control, and the one shape that says "these are the views,
* you are in this one" without needing a label to say so.
*/
export interface TabBarProps {
children: ReactNode;
/** `sm` for a secondary strip — a filter under a primary switcher. */
size?: 'md' | 'sm';
'aria-label'?: string;
}
export function TabBar({ children, size = 'md', ...rest }: TabBarProps) {
return (
<div className="tabbar" data-size={size} role="tablist" aria-label={rest['aria-label']}>
{children}
</div>
);
}
export interface TabProps {
label: string;
icon?: ReactNode;
isActive: boolean;
onClick: () => void;
/**
* The count beside the label. Omit it and no count is drawn.
*
* Whether ZERO is worth drawing depends on what the tab is, so the caller
* decides rather than this component:
*
* - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders
* 85" reads as a broken tab; "Deliveries" alone says nothing and lets the
* empty state explain.
* - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer
* to a question the reader is asking — nothing was cancelled — and a rung
* that loses its number when it empties makes the ladder jump about as
* the day goes on.
*/
count?: number | undefined;
/**
* Draws a hairline before this tab, dividing the track into groups.
*
* Sales needs it: Orders and Deliveries are two halves of the app's own
* trade, and Counter sales is a different channel entirely. The rule says so
* without spending a tab on a heading.
*/
isGroupStart?: boolean;
}
export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: TabProps) {
return (
<button
type="button"
role="tab"
className="tabbar-tab"
aria-selected={isActive}
{...(isGroupStart ? { 'data-group-start': 'true' } : {})}
onClick={onClick}
>
{icon ? <span className="tabbar-icon">{icon}</span> : null}
<span className="tabbar-label">{label}</span>
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
</button>
);
}

140
src/components/kpiCard.css Normal file
View File

@@ -0,0 +1,140 @@
/* ══ The KPI tile ══════════════════════════════════════════════════════════
The console's headline figure, and the card design the rest of the site
follows: the same surface tokens, the same icon tile, the same
label / value / note hierarchy.
Layout is a stack: a top row carrying the label and the icon tile, then the
figure at full card width, then the note.
The figure is LEFT-aligned and owns the whole width, and both halves of that
matter. The old tile centred it, so the number sat in a different place in
every card depending on how many digits it had and a row of tiles never
lined up. The tile before this one put the icon in a column to the left,
which left about 134px for the figure at the width these actually get — and
"₹1,24,500" wrapped mid-number. */
.kpi-card {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
/* A floor, not a height. Labels wrap to two lines at narrow widths — with
Nearle Buddy open these are about 195px wide, and "Cancelled orders"
ellipsised to "Cance…" is a label that has stopped working — so the tile
grows and the row stays level. */
min-height: 104px;
padding: 16px;
/* The console's one card surface. Same tokens as `Panel` and every other
card in the app; see the note in `index.css`. */
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
box-shadow: var(--card-shadow);
}
/* ── The icon tile ──────────────────────────────────────────────────────────
A rounded square in the tone's tint, one step tighter than the card that
holds it — a control radius inside a card radius, which is what keeps it
from reading as a small card of its own. */
.kpi-card-icon {
width: 40px;
height: 40px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--card-radius-sm);
background: var(--kpi-tint);
color: var(--kpi-ink);
}
/* Brand by default — a tile that is merely reporting should not shout. The
other three are for tiles whose figure IS a state. */
.kpi-card {
--kpi-ink: var(--color-brand);
--kpi-tint: var(--color-brand-tint);
}
.kpi-card[data-tone='success'] {
--kpi-ink: var(--color-success, #1f9d55);
--kpi-tint: color-mix(in oklab, var(--color-success, #1f9d55) 12%, transparent);
}
.kpi-card[data-tone='warning'] {
--kpi-ink: var(--color-warning, #b7860b);
--kpi-tint: color-mix(in oklab, var(--color-warning, #b7860b) 14%, transparent);
}
.kpi-card[data-tone='error'] {
--kpi-ink: var(--color-error, #d64545);
--kpi-tint: color-mix(in oklab, var(--color-error, #d64545) 12%, transparent);
}
/* ── The top row ────────────────────────────────────────────────────────── */
/* Label left, icon right. The label takes the space the icon does not, and the
figure below gets the whole card. */
.kpi-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
margin-bottom: 8px;
}
/* The label names the figure and stays quiet doing it. Not caps: at 11px in
caps with letter-spacing, "Cancelled orders" runs wider than the tile and a
two-line eyebrow reads as a heading that broke. Sentence case, ink-3. */
.kpi-card-label {
font: 500 12.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
min-width: 0;
/* Two lines, then ellipsis — never a third. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* The figure. Tabular so a column of tiles lines up on the digits, and pulled
in very slightly — at 25px the default tracking makes a rupee figure look
spaced out rather than set.
IT NEVER WRAPS. `overflow-wrap: anywhere` was on this rule and it broke
"₹1,24,500" across two lines between the 0 and the 0 — a figure split
mid-digit is not a smaller figure, it is a wrong one. A number too wide for
its tile ellipsises instead, which is visibly truncated rather than quietly
misread, and the `title` is not needed because the same figure is on the
page it links to. */
.kpi-card-value {
font: 700 25px/1.2 var(--font-sans);
letter-spacing: -0.02em;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* What the figure is made of. This is the line that makes the number mean
something — see the note in `KpiCard.tsx` for why it had to come back. */
.kpi-card-note {
margin-top: 3px;
font: 400 11.5px/1.4 var(--font-sans);
color: var(--color-ink-4);
/* Two lines at most. "142 app · 312 counter" wraps at tile width and that is
fine; a third line would push the tile past its neighbours. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* `.kpi-grid` is NOT defined here. It already exists in `index.css`, built on
container queries rather than viewport ones — which is what lets a tile row
reflow when Nearle Buddy opens beside it and takes 340px out of the column,
something a viewport breakpoint cannot see. Defining a second one here would
have won on import order and silently thrown that away. */
@media (max-width: 600px) {
.kpi-card { min-height: 96px; padding: 14px; }
.kpi-card-icon { width: 32px; height: 32px; }
.kpi-card-value { font-size: 22px; }
}

141
src/components/panel.css Normal file
View File

@@ -0,0 +1,141 @@
/* ══ The panel — the console's one card ════════════════════════════════════
Three zones: a header band that says what this is and how much of it there
is, a body, and a footer band that says where you are in it. See the note at
the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer
outline.
Every size here comes from the card tokens in `index.css`. */
.panel-card {
display: flex;
flex-direction: column;
min-width: 0;
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
box-shadow: var(--card-shadow);
/* The clip belongs on the element that owns the radius, or the header band's
tint squares off the top two corners. */
overflow: hidden;
}
/* For a grid row of panels that should finish level with each other. */
.panel-card[data-filled='true'] { height: 100%; }
/* ── Header band ────────────────────────────────────────────────────────── */
/* Tinted rather than white, so the band reads as chrome and the rows below it
read as data. A white header separated only by a rule is the same surface
twice, which is how a heading ends up looking like a first table row. */
.panel-card-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-height: 48px;
padding: 8px 16px;
background: var(--color-surface-subtle);
border-bottom: var(--card-border);
}
.panel-card-title-group {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px
title inside a card competes with the page's own title above it — the two
stacked is what made every screen say its subject twice. */
.panel-card-title {
margin: 0;
font: 600 11px/1.2 var(--font-sans);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The count, in its own chip. Tabular, because it changes as a filter narrows
and a figure that shifts sideways while you read it is hard to trust. */
.panel-card-count {
display: inline-flex;
align-items: baseline;
gap: 4px;
flex: none;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-surface-sunken);
font: 600 11.5px/1.45 var(--font-sans);
font-variant-numeric: tabular-nums;
color: var(--color-ink-2);
}
.panel-card-count-label {
font-weight: 400;
font-size: 11px;
color: var(--color-ink-4);
}
.panel-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
/* ── Body ───────────────────────────────────────────────────────────────── */
.panel-card-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.panel-card-body[data-pad='md'] { padding: 16px; }
/* ── Footer band ────────────────────────────────────────────────────────── */
.panel-card-foot {
flex: none;
padding: 8px 16px;
background: var(--color-surface-subtle);
border-top: var(--card-border);
}
/* The pager hides itself on a single page — it returns null — but the band
around it is rendered by the parent, which cannot see that. Without this a
short table carried a 44px strip of tinted chrome saying nothing.
`:has` collapses the band when its child rendered no DOM at all. */
.panel-card-foot:not(:has(*)) {
display: none;
}
/* The pager's own wrapper adds padding meant for sitting loose under a table.
Inside the band the band supplies it. */
.panel-card-foot .table-pager {
padding: 0;
border-top: 0;
}
/* ── A table sitting flush in the body ──────────────────────────────────────
The table brings its own horizontal scroll and its own header row. What it
must not bring is a rounded corner or an outer border — the panel owns both,
and a table with its own edge inside a panel is the double line this design
is meant to remove. */
.panel-card-body > .table-scroll {
border-radius: 0;
border: 0;
}
/* ── Narrow ─────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.panel-card-head { padding: 8px 12px; }
.panel-card-foot { padding: 8px 12px; }
.panel-card-body[data-pad='md'] { padding: 12px; }
/* Actions drop to their own line rather than squeezing the title out. */
.panel-card-actions { margin-left: 0; width: 100%; }
}

View File

@@ -0,0 +1,82 @@
/* ══ Search box ════════════════════════════════════════════════════════════
Shaped to match the view-tab track it sits opposite — see the note in
`SearchInput.tsx`. Every number here is the same number `tabBar.css` uses. */
.searchbox {
display: inline-flex;
align-items: center;
gap: 7px;
/* The track's height and the track's corner. This is the whole point of the
component: a search that is visibly the same object as the buttons at the
other end of the row. */
height: 38px;
padding: 0 10px;
border-radius: var(--card-radius-sm);
border: var(--card-border);
background: var(--card-bg);
box-shadow: var(--card-shadow);
max-width: 100%;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
/* Fills its container, for a box sitting directly above the list it filters. */
.searchbox[data-full='true'] { display: flex; width: 100%; }
.searchbox:hover { border-color: var(--color-ink-4); }
/* Focus lands on the BOX, not on the bare input inside it — the input has no
border of its own, so the default ring would draw inside the box and read as
a second, smaller field. */
.searchbox:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
.searchbox-icon { flex: none; color: var(--color-ink-4); }
.searchbox:focus-within .searchbox-icon { color: var(--color-brand); }
.searchbox-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
padding: 0;
background: transparent;
color: var(--color-ink-1);
font: 400 13px/1 var(--font-sans);
outline: none;
/* Safari draws its own rounded field and inner shadow on a text input, and
`type="search"` adds a magnifier and a cancel button on top of ours. */
appearance: none;
-webkit-appearance: none;
}
.searchbox-input::placeholder { color: var(--color-ink-4); }
.searchbox-input::-webkit-search-decoration,
.searchbox-input::-webkit-search-cancel-button,
.searchbox-input::-webkit-search-results-button {
appearance: none;
-webkit-appearance: none;
display: none;
}
.searchbox-clear {
flex: none;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--color-surface-sunken);
color: var(--color-ink-3);
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.searchbox-clear:hover { background: var(--color-line); color: var(--color-ink-1); }
.searchbox-clear:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* On a phone the header row wraps, and a 260px box beside a scrolling tab
track leaves neither enough room. It takes the full line instead. */
@media (max-width: 600px) {
.searchbox { width: 100% !important; }
}

View File

@@ -105,6 +105,29 @@ export function AppShell({
const isMobile = useIsMobile();
const menuRef = useRef<HTMLDivElement>(null);
/*
A new page starts at the top.
A single-page app does not reload, so the document keeps whatever scroll
offset the last page was left at: read Sales down to row 40, click Inventory,
and Inventory opens 2,000px down — usually past everything it has, so it
looks empty. Nothing in the app was resetting this.
INSTANT, deliberately, and it is the one place in the console that opts out
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
animate those 2,000px on every navigation, which means a second of the new
page flying past before it settles — the page arriving late, rather than the
page arriving. Smooth is for a move you asked for within a page; a route
change is not one.
Keyed on `pathname` alone, not on the whole location: the branch scope and
the date range travel in the query string, and re-scoping a table you are
halfway down should leave you where you were.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
// Escape closes the account menu. A menu that can only be dismissed by
// finding the trigger again is a trap for anyone on a keyboard, and this one
// sits over the page rather than beside it.

143
src/components/tabBar.css Normal file
View File

@@ -0,0 +1,143 @@
/* ══ The view switcher ═════════════════════════════════════════════════════
A segmented control: a recessed track, and the selected view raised out of
it onto the card surface. See the note in `TabBar.tsx` for why this replaced
six private copies of a transparent pill. */
.tabbar {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: var(--card-radius-sm);
background: var(--color-surface-sunken);
/* The track is a control, so it takes the control radius rather than the
card one, and it never stretches: a segmented control that fills the page
width puts its two tabs at either end of the screen. */
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.tabbar::-webkit-scrollbar { display: none; }
.tabbar-tab {
display: inline-flex;
align-items: center;
gap: 7px;
flex: none;
height: 32px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: calc(var(--card-radius-sm) - 3px);
background: transparent;
color: var(--color-ink-3);
font: 500 13px/1 var(--font-sans);
white-space: nowrap;
cursor: pointer;
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
}
/* Hover is a hint of the track's own surface, not the selected state — an
unselected tab that looks selected on hover is the oldest bug in this
control. */
.tabbar-tab:hover:not([aria-selected='true']) {
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
color: var(--color-ink-1);
}
/* Raised out of the track: the card surface, a hairline and the same 1px
micro-lift every card in the console carries. That is what makes it read as
sitting ON the track rather than tinted inside it. */
.tabbar-tab[aria-selected='true'] {
background: var(--card-bg);
border-color: var(--color-line);
box-shadow: var(--card-shadow);
color: var(--color-ink-1);
font-weight: 600;
}
.tabbar-tab:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
/* The icon carries the brand on the selected tab and nothing on the others, so
colour marks the selection once rather than three times. */
.tabbar-icon {
display: inline-flex;
flex: none;
color: var(--color-ink-4);
}
.tabbar-tab[aria-selected='true'] .tabbar-icon { color: var(--color-brand); }
.tabbar-label { min-width: 0; }
/* ── The count ──────────────────────────────────────────────────────────────
A figure, not a badge.
Three of the six copies filled this with `--color-warning` — the amber this
console uses for something needing attention — so a tab reading "Orders 85"
announced eighty-five problems. It is set as a quiet tabular number that
takes the brand only on the selected tab, which is the one whose count the
reader is actually acting on. */
.tabbar-count {
flex: none;
font: 600 11.5px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
padding: 3px 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-ink-4) 10%, transparent);
}
.tabbar-tab[aria-selected='true'] .tabbar-count {
color: var(--color-brand);
background: var(--color-brand-tint);
}
/* ── Group divider ──────────────────────────────────────────────────────────
A hairline inside the track, for a tab that belongs to a different family
from the one before it — Counter sales beside the app's own Orders and
Deliveries. Drawn as a margin plus a border so it sits in the gap rather
than on either tab. */
.tabbar-tab[data-group-start='true'] {
margin-left: 9px;
position: relative;
}
.tabbar-tab[data-group-start='true']::before {
content: '';
position: absolute;
left: -6px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--color-line);
}
/* ── Secondary strip ────────────────────────────────────────────────────────
Under a primary switcher — a status filter, say. Smaller, and with no track
of its own, so two rows of tabs do not read as two controls of equal rank. */
.tabbar[data-size='sm'] {
background: transparent;
padding: 0;
gap: 4px;
}
.tabbar[data-size='sm'] .tabbar-tab {
height: 28px;
padding: 0 10px;
font-size: 12.5px;
border-radius: var(--card-radius-sm);
}
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] {
background: var(--color-brand-tint);
border-color: transparent;
box-shadow: none;
color: var(--color-brand);
}
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] .tabbar-count {
background: color-mix(in oklab, var(--color-brand) 16%, transparent);
}
.tabbar[data-size='sm'] .tabbar-icon { display: none; }
@media (max-width: 600px) {
.tabbar-tab { padding: 0 10px; gap: 6px; }
.tabbar-icon { display: none; }
}

View File

@@ -11,7 +11,7 @@
width: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 10px;
border-radius: var(--card-radius);
background: var(--color-surface-sunken);
}
@@ -84,9 +84,11 @@
.trail-map .leaflet-popup-content-wrapper {
padding: 0 !important;
border-radius: 14px !important;
background: #ffffff !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
border-radius: var(--card-radius) !important;
background: var(--card-bg) !important;
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
genuinely floats, over a map, which is what a shadow is for. */
border: var(--card-border) !important;
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
overflow: hidden !important;
}

View File

@@ -1,4 +1,4 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import {
AlertCircle,
@@ -69,6 +69,40 @@ export function LoginPage() {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
/*
Each step brings its own panel into view.
The three steps are one screen, and on a phone the brand panel sits ABOVE the
form — so the card is taller than the viewport and answering the email step
replaces the panel below the fold. Without this the page does not move: you
press Continue, something changes off screen, and the password field you are
meant to type into is never shown to you.
Smooth rather than a jump, because unlike a route change this is a move
WITHIN a screen the reader is already looking at, and seeing the page travel
is what connects the button they pressed to the field that arrived. It is
skipped on the first render — arriving at a login already scrolled to the
form would hide the brand and the "which account is this" context above it —
and a reader who has asked for reduced motion gets the instant jump, via the
global rule in `index.css`.
DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic:
hooks must run in the same order on every render, and this component returns
early the moment a session exists. Put below that line, these two refs and
the effect would simply stop being called on the render that signs somebody
in — which is React's "rendered fewer hooks than expected" crash, on the
happy path.
*/
const panelRef = useRef<HTMLDivElement>(null);
const isFirstStep = useRef(true);
useEffect(() => {
if (isFirstStep.current) {
isFirstStep.current = false;
return;
}
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [step]);
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
/** Step one: which door does this email need? */
@@ -168,17 +202,31 @@ export function LoginPage() {
>
<div
className="login-split"
/* The sign-in card is the console's card, at the console's corner.
It was the largest radius in the product at 28px, carrying a
two-layer drop shadow, and it was the FIRST surface anybody saw —
so it set an expectation the rest of the app then did not meet. It
is now the same flat hairline card as every other surface, just
bigger. */
style={{
width: '100%',
maxWidth: 1024,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
borderRadius: 28,
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
background: 'var(--card-bg)',
border: 'var(--card-border)',
borderRadius: 'var(--card-radius)',
boxShadow: 'var(--card-shadow)',
overflow: 'hidden',
}}
>
<BrandPanel />
{/* The ref lives on a wrapper rather than inside the two panels, so
neither has to know it is being scrolled to. `display: grid` keeps
it out of the way: as a grid item the wrapper stretches to the row,
and the panel inside stretches with it, which is what centres the
form vertically beside the brand. A plain block here would collapse
to its content's height and un-centre it. */}
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
{step !== 'setup' ? (
<FormPanel
step={step}
@@ -212,6 +260,7 @@ export function LoginPage() {
)}
</div>
</div>
</div>
);
}

View File

@@ -7,10 +7,10 @@ import { IconButton } from '@astryxdesign/core/IconButton';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Skeleton } from '@astryxdesign/core/Skeleton';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { APP_BROWSE_CATEGORY } from './tenantCategories';
@@ -391,16 +391,12 @@ export function CatalogueBrowser({
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<TextInput
<SearchInput
label="Search the catalogue"
isLabelHidden
size="sm"
width={220}
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
startIcon={<Search size={14} />}
hasClear
width={220}
/>
</HStack>

View File

@@ -9,6 +9,9 @@ import {
Bullets,
DrawerButton,
DrawerCard,
Metric,
Metrics,
Mono,
Row,
Section,
} from '@/features/store-admin/drawerKit';
@@ -102,7 +105,7 @@ export function CatalogueDetailDrawer({
subtitle={`${brand} · catalogue id ${product.id}`}
width={540}
onClose={onClose}
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
{...(!isImported && !blockedReason && onImport
? {
isFooterFilled: true,
@@ -162,7 +165,7 @@ export function CatalogueDetailDrawer({
</button>
))}
</div>
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
<span className="drawer-metric-note">
{images.length} photos — only the first is imported
</span>
</>
@@ -171,31 +174,13 @@ export function CatalogueDetailDrawer({
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
<DrawerCard tone="brand">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
}}
>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span className="drawer-metric-label">Market price range</span>
<span
style={{
font: '600 22px/1.2 var(--font-sans)',
color: 'var(--color-ink-1)',
fontVariantNumeric: 'tabular-nums',
}}
>
{product.price_range ?? '—'}
</span>
</span>
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
</div>
</DrawerCard>
{/* The kit's own metric, not a 22px figure typed here. It was the largest
type in any drawer in the console and it sat on a catalogue REFERENCE
— louder than the selling price in the product drawer next to it. */}
<Metrics cols={2}>
<Metric label="Market price range" value={product.price_range ?? '—'} />
<Metric label="Pack size" value={product.size || '—'} isSmall />
</Metrics>
{product.description ? (
<Section title="Description">
@@ -243,7 +228,7 @@ export function CatalogueDetailDrawer({
label={fact.label}
value={
fact.isMono ? (
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
<Mono>{fact.value}</Mono>
) : (
fact.value
)
@@ -282,21 +267,21 @@ export function CatalogueDetailDrawer({
label="Shown in the app under"
value={
aisle ? (
<Badge label={aisle} colour="#0f8a5f" />
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
)
}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
<span className="drawer-caption">
{suggested.rule === 'catalogue'
? `The catalogue's own category for this product.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
</span>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
<span className="drawer-caption">
{aisle
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}

View File

@@ -63,40 +63,84 @@
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
}
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
row and a sparkline; with those gone it is a label and a number, and the
layout should not still be shaped around what it no longer holds. */
.kpi {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 16px;
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
}
.kpi-icon {
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
background: var(--color-brand-tint); color: var(--color-brand);
}
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
has stopped doing its job. The min-height keeps all five value rows level
whether the label took one line or two. */
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
`.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
same object that `components/KpiCard.tsx` renders, with a different icon
radius, a different value size and a different gap. Both appeared on the
same screen. The component won, this strip now renders it, and the classes
are gone rather than left as a near-copy for someone to edit by mistake. */
/* ── Panels ─────────────────────────────────────────────────────────────── */
/* ── Panels ─────────────────────────────────────────────────────────────────
The Console's panels are the SAME object as `components/Panel.tsx` — a
framed card with a header band, a body and a footer band. They stay as CSS
classes rather than becoming that component because these six sections each
lay their own body out (a chart, a donut, a map, a list of alerts) and have
no table or pager to hand it; the component's job is the table case.
What matters is that they are the same DESIGN. They were not: this file had
a white header with a 15.5px title and a sub-line, the component has a
tinted band with an 11px eyebrow, and the two sat on the same screen. The
band and the type below are now the component's, restated here.
The panel no longer pads itself — the bands and the body pad separately, or
a header band cannot reach the panel's edges. */
.panel {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 18px;
display: flex; flex-direction: column; gap: 14px; min-width: 0;
border: var(--card-border); border-radius: var(--card-radius);
background: var(--card-bg); box-shadow: var(--card-shadow);
display: flex; flex-direction: column; min-width: 0;
/* The clip belongs on the element that owns the radius, or the header
band's tint squares off the top two corners. */
overflow: hidden;
}
/* The body. Every direct child of `.panel` that is not a band is content, so
the padding and the stack live on a wrapper the sections already render. */
.panel > :not(.panel-head):not(.panel-foot) {
padding: 16px 18px;
}
/* Consecutive content blocks should not double their gutter. */
.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) {
padding-top: 0;
}
.panel-head {
display: flex; justify-content: space-between; align-items: center;
gap: 14px; flex-wrap: wrap;
min-height: 48px; padding: 8px 18px;
background: var(--color-surface-subtle);
border-bottom: var(--card-border);
}
/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a
card competes with the page's own title above it. */
.panel-title {
margin: 0;
font: 600 11px/1.2 var(--font-sans);
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--color-ink-3);
}
/* Title and sub-line sit on ONE row, the way the table panel's title and count
do. Every section wraps the pair in a plain `div`, which stacks them by
default — and a stacked pair is the two-line header this band replaced. */
.panel-head > div:first-child {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
min-width: 0;
}
.panel-sub {
margin: 0;
font: 400 12px/1.4 var(--font-sans);
color: var(--color-ink-4);
}
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
.panel-foot {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
padding: 8px 18px;
background: var(--color-surface-subtle);
border-top: var(--card-border);
font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3);
}
.panel-empty {
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
@@ -198,8 +242,8 @@
.share {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--color-line); border-radius: 12px;
background: var(--color-surface-subtle);
border: var(--card-border); border-radius: var(--card-radius-sm);
background: var(--card-bg-subtle);
}
.share .donut-key { align-self: center; }
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
@@ -236,8 +280,8 @@
.quick-list { display: flex; flex-direction: column; gap: 8px; }
.quick {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
text-decoration: none; background: var(--color-surface);
padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
text-decoration: none; background: var(--card-bg);
}
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
@@ -277,13 +321,8 @@
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.search {
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
background: var(--color-surface); color: var(--color-ink-4);
}
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
.search:focus-within { border-color: var(--color-brand); }
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
input version of the search box. Both use `components/SearchInput` now. */
/* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
@@ -303,7 +342,7 @@
.attention-card {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius);
}
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
@@ -324,8 +363,8 @@
/* ── Loading ────────────────────────────────────────────────────────────── */
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
.sk-card, .sk-chart {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
border: var(--card-border); border-radius: var(--card-radius);
background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
}
.sk-card { height: 104px; }
.sk-chart { height: 300px; }

View File

@@ -29,7 +29,7 @@ export interface BranchOverviewProps {
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
if (rows.length === 0) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<Text type="body" size="sm" color="secondary">
No branches are set up yet.
</Text>
@@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
);
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<table className="branch-table">
<thead>

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
import { count, money } from '@/features/store-admin/format';
import type { ConsoleTotals } from '../consoleModel';
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
return (
<div className="kpi-strip">
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<Kpi
<KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<KpiCard
icon={<ListChecks size={17} />}
label="Total Orders"
value={count(totals.totalOrders)}
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
/>
<Kpi
<KpiCard
icon={<Ban size={17} />}
label="Cancelled Orders"
value={count(totals.cancelled)}
@@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
: {})}
/>
<Kpi
<KpiCard
icon={<CloudUpload size={17} />}
label="Unsynced Bills"
value={count(totals.pendingBills)}
@@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
);
}
function Kpi({
icon, label, value, note,
}: {
icon: ReactNode;
label: string;
value: string;
note?: string;
}) {
return (
<article className="kpi">
<span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
{note ? <span className="kpi-note">{note}</span> : null}
</div>
</article>
);
}

View File

@@ -29,7 +29,7 @@ export interface ShopSummaryProps {
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
if (!row) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<Text type="body" size="sm" color="secondary">
No branch is selected.
</Text>
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
const total = row.onlineRevenue + row.counterRevenue;
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={3}>
<HStack gap={2} align="start" wrap="wrap">
<span className="shop-mark" aria-hidden>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
import type { BranchRow, HealthTone } from '../consoleModel';
@@ -111,15 +111,13 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
</button>
</div>
<label className="search">
<Search size={14} aria-hidden />
<input
<SearchInput
label="Search tills"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
aria-label="Search tills"
onChange={setTerm}
placeholder={showBranch ? 'Till ID, branch…' : 'Till ID…'}
width={220}
/>
</label>
</div>
</header>

View File

@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
@@ -8,7 +7,7 @@ import { tenantsApi } from '@/api/tenants';
import { useAllPartners, useAppRegions } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
import { Alert, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
/**
* Which delivery partner supplies a merchant's riders.
@@ -89,11 +88,7 @@ export function PartnerAssignDrawer({
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
{error ? <Alert>{error}</Alert> : null}
<Note icon={<Bike size={15} />}>
A partner supplies riders to this shop. With one set, the assign screen offers the

View File

@@ -1,12 +1,17 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus } from 'lucide-react';
import type { Partner, RiderRosterRow } from '@/api/deliveries';
import { usePartnerRiders } from '@/queries/hooks';
import { Drawer } from '@/features/store-admin/Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
import {
Badge,
DrawerButton,
DrawerCard,
LinkAction,
Note,
Row,
Section,
} from '@/features/store-admin/drawerKit';
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
/**
@@ -59,7 +64,11 @@ export function PartnerRidersDrawer({
</>
}
>
<VStack gap={2}>
{/* No `VStack` around this. The drawer body IS a column with the
system's own gap; a second stack inside it set a second, different
one, which is why this drawer's sections sat closer together than
every other drawer's. */}
<>
<Note icon={<Bike size={15} />}>
These riders deliver for every merchant this partner supplies. A rider hired here does
not appear in the on-duty fleet until they open the rider app and start a shift — that
@@ -67,9 +76,7 @@ export function PartnerRidersDrawer({
</Note>
{riders.isLoading ? (
<Text type="body" size="sm" color="secondary">
Reading riders…
</Text>
<Note>Reading riders…</Note>
) : rows.length === 0 ? (
<Section title="No riders yet">
<DrawerCard>
@@ -88,17 +95,17 @@ export function PartnerRidersDrawer({
key={rider.userid}
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
value={
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
<Badge
label={rider.isonduty ? 'On duty' : 'Off shift'}
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => setEditing(rider)}
/* The success token, not a hex green typed here. The
#0f8a5f this used was a fourth green in a console
that has one. */
colour={
rider.isonduty ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-4)'
}
/>
<LinkAction label="Edit" onClick={() => setEditing(rider)} />
</span>
}
/>
@@ -106,7 +113,7 @@ export function PartnerRidersDrawer({
</DrawerCard>
</Section>
)}
</VStack>
</>
</Drawer>
{editing ? (

View File

@@ -139,7 +139,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
</div>
<div className="fleet-body">
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
Where the fleet is registered
@@ -162,7 +162,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
</VStack>
</Card>
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
The day, rider by rider
@@ -202,7 +202,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
</Card>
</div>
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={2}>
<HStack gap={1} align="center">
<span className="fleet-note-icon">

View File

@@ -138,7 +138,7 @@ export function OnboardTenantPage() {
</button>
</div>
<Card padding={4} elevation="low">
<Card padding={4}>
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
@@ -153,7 +153,7 @@ export function OnboardTenantPage() {
</Text>
{created?.tenantid && created?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<Card padding={3} variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
@@ -241,7 +241,7 @@ export function OnboardTenantPage() {
</div>
{/* Section 1: Business Details Card */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{
@@ -318,7 +318,7 @@ export function OnboardTenantPage() {
</Card>
{/* Section 2: Head Office Card */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{

View File

@@ -8,7 +8,6 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
@@ -28,6 +27,8 @@ import { Selector } from '@astryxdesign/core/Selector';
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
import { DataState } from '@/components/DataState';
import { PageHeader } from '@/components/PageHeader';
import { Panel } from '@/components/Panel';
import { SearchInput } from '@/components/SearchInput';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
@@ -256,7 +257,11 @@ export function PartnersPage() {
/>
{/* Partners Table Card */}
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
<Panel
count={paged.total}
countLabel="partners"
footer={<TablePager paged={paged} label="partners" />}
>
<DataState
isLoading={partners.isLoading}
error={null}
@@ -274,9 +279,8 @@ export function PartnersPage() {
dividers="rows"
/>
</div>
<TablePager paged={paged} label="partners" />
</DataState>
</Card>
</Panel>
{/* Drawers */}
{ridersFor ? (
@@ -553,14 +557,15 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
<Text type="label" size="sm" weight="semibold">
District
</Text>
<TextInput
{/* Full width, not a fixed one: this sits above the 38-row district
list it filters, inside a drawer, and a 260px box over a list that
fills the panel reads as unrelated to it. */}
<SearchInput
label="Search districts"
isLabelHidden
size="sm"
value={districtSearch}
onChange={setDistrictSearch}
placeholder="Search all 38 districts…"
hasClear
width="full"
/>
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => {

View File

@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
@@ -12,10 +11,10 @@ import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import type { TenantInfo, TenantLocation } from '@/api/types';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer';
@@ -279,12 +278,13 @@ export function StoreDetailPage() {
</div>
<VStack gap={1.5}>
<SectionHeader
<Panel
title="Branches"
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
/>
<Card padding={0} elevation="low">
count={paged.total}
countLabel="branches"
actions={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
footer={<TablePager paged={paged} label="branches" />}
>
<DataState
isLoading={isLoading}
error={error}
@@ -307,9 +307,8 @@ export function StoreDetailPage() {
dividers="rows"
/>
</div>
<TablePager paged={paged} label="branches" />
</DataState>
</Card>
</Panel>
</VStack>
{isPartnerOpen && tenantid ? (

View File

@@ -6,13 +6,14 @@ import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { Panel } from '@/components/Panel';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
@@ -240,43 +241,66 @@ export function StoresPage() {
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
<TabBar>
<Tab
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
<Tab
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
count={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
</TabBar>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
/* The title, the count and the search are the panel's header band now.
The search filters THIS table; floating above the card it read as a
page-level control that happened to sit nearby, and the count was a
note beside a heading rather than the figure you check a filter
against. */
<Panel
footer={
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
}
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
count={rows.length}
countLabel="tenants"
actions={
<SearchInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
placeholder="Name, company or city…"
width={260}
/>
</div>
}
/>
<Card padding={0} elevation="low">
>
<DataState
isLoading={isLoading}
error={error}
@@ -306,31 +330,7 @@ export function StoresPage() {
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
</Panel>
)}
</VStack>
);
@@ -348,7 +348,7 @@ export function StoresPage() {
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
@@ -360,7 +360,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
@@ -379,7 +379,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -409,59 +409,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({ children }: { children?: React.ReactNode }) {
return (

View File

@@ -2,20 +2,37 @@
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
*/
/* The console's small control: a branch picker, a rider-partner picker and the
Shifts day input all wear this.
Its own shape before was 32px at a 7px corner — a seventh radius in an app
that has one. It takes the search box's measurements now, which matters most
on Inventory, where a `.fleet-select` branch picker sits immediately beside a
`SearchInput`: the two were a different height AND a different corner, six
pixels apart. */
.fleet-select {
height: 32px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: var(--card-shadow);
font: inherit;
font-size: 13px;
color: var(--color-ink-1);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.fleet-select:hover { border-color: var(--color-ink-4); }
/* The same focus treatment as the search box — a brand border and a 3px tint
ring, not the global 2px outline, so two controls sitting side by side do
not light up in different ways. */
.fleet-select:focus,
.fleet-select:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
/* ── The recommendation banner ───────────────────────────────────────────── */
@@ -30,8 +47,8 @@
padding: 12px 14px;
border: 1px solid var(--color-border);
border-left-width: 3px;
border-radius: 9px;
background: var(--color-surface);
border-radius: var(--card-radius);
background: var(--card-bg);
}
.fleet-banner > svg {

View File

@@ -231,6 +231,23 @@ export function OnboardingPage() {
persist(completeStep(state, step, nextOf(step)));
}
/*
Each step of the wizard starts at the top of itself.
The steps are STATE, not routes, so the shell's own reset — which keys on the
pathname — never fires here: the URL is `/admin/onboarding` for all seven of
them. Finishing the inventory step from the bottom of a long list and
pressing Save & continue therefore opened the next step already scrolled past
its heading and its explanation, which is exactly the part a person setting
up a shop for the first time needs to read.
Instant, for the same reason the shell's is: this replaces the whole page
body, so there is nothing continuous for a smooth travel to connect.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [step]);
const heading = HEADINGS[step];
const isFirst = step === 'welcome';
const isLast = step === 'done';

View File

@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
return (
<VStack gap={3} className="ob-panel">
{productCount > 0 ? (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
You already have {productCount} product{productCount === 1 ? '' : 's'}

View File

@@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
{/* Only when it applies. Nothing below exists for a collection-only shop,
and rendering it disabled would be showing work that is not theirs. */}
{value.offersDelivery === true ? (
<Card padding={3} elevation="low" className="ob-panel">
<Card padding={3} className="ob-panel">
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">

View File

@@ -53,7 +53,7 @@ const TIPS = [
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
return (
<VStack gap={3} className="ob-panel">
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
@@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</Card>
<div className="ob-two">
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<HStack justify="between" align="start" gap={2} wrap="wrap">
<VStack gap={0.5}>
@@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</VStack>
</Card>
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={1.5}>
<Text type="label" size="sm" weight="semibold">
Tips

View File

@@ -158,7 +158,7 @@ function Group({
children: React.ReactNode;
}) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">

View File

@@ -113,7 +113,7 @@ export function WelcomeStep({
<div className="ob-benefits">
{benefits.map(({ icon: Icon, title, body }) => (
<Card key={title} padding={3} elevation="low">
<Card key={title} padding={3}>
<VStack gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
<Icon size={20} />

View File

@@ -110,9 +110,9 @@ export function StoreQrPanel({
<div
style={{
padding: 10,
borderRadius: 16,
background: '#ffffff',
border: '1px solid var(--color-line)',
borderRadius: 'var(--card-radius)',
background: 'var(--card-bg)',
border: 'var(--card-border)',
lineHeight: 0,
}}
>

View File

@@ -130,7 +130,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
@@ -164,7 +164,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
const rider = entry.live;
const worries = concerns(rider);
return (
<Card key={rider.userid} padding={0} elevation="low">
<Card key={rider.userid} padding={0}>
<VStack gap={1} padding={2}>
<HStack justify="between" align="center" gap={1}>
<span className="active-name">

View File

@@ -1,13 +1,20 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { EyeOff } from 'lucide-react';
import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
import { Drawer } from './Drawer';
import {
Badge,
DrawerButton,
DrawerCard,
Field,
Metric,
Metrics,
Note,
Row,
Section,
TextField,
} from './drawerKit';
import type { CounterLabels } from './counterLabels';
import type { CounterRow } from './counterRows';
import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows';
import { money } from './format';
import { shortAge } from './posStatus';
@@ -21,6 +28,19 @@ import { shortAge } from './posStatus';
* Rename and hide live here rather than on the row. They are rare actions, and
* a control on every row of a fifteen-row table is fifteen chances to hide a
* counter by mistake.
*
* ── What this used to get wrong ─────────────────────────────────────────────
*
* The table's Status column — Active, Attention, Offline, Never used — was the
* one reading this drawer did not repeat, so the chip that made somebody open a
* counter vanished the moment they did. It is in the header now, from the same
* `state` the row chips from, and the named problem behind an "Attention" is
* stated rather than left as a colour.
*
* The readings were also drawn by a `.reading-list` belonging to the counters
* page stylesheet, which is why this drawer had a different row height, label
* weight and hairline from every other drawer in the console. It is built from
* the kit now, like the rest.
*/
export interface CounterDrawerProps {
row: CounterRow;
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
);
const status = row.status;
const readings: [string, string][] = [
['Counter code', row.terminalId],
['Branch', row.branchName],
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
];
if (status) {
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
// The number that explains a revenue figure reading low, so it is stated
// even when it is zero rather than hidden as "nothing to report".
readings.push(['Waiting to send', String(status.pendingBills)]);
if (status.reason) readings.push(['Reported', status.reason]);
} else {
readings.push(['Status', 'This counter has never reported in.']);
}
const isNamed = labels.isNamed(row.terminalId);
return (
<Drawer
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
{...(isNamed ? {} : { isTitleMono: true })}
subtitle={isNamed ? row.terminalId : row.branchName}
width={460}
onClose={onClose}
>
<VStack gap={3}>
<div className="reading-list">
{readings.map(([label, value]) => (
<div key={label} className="reading">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
<VStack gap={1.5}>
<TextInput
label="Name this counter"
value={draft}
onChange={setDraft}
placeholder={row.terminalId}
description="Shown instead of the code. The code stays on the receipts."
/>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button
/* The same chip the row wears, so the reading that made somebody open
this counter is still on screen once they have. */
meta={
<>
<Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
{isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
</>
}
isFooterSpread
footer={
<>
<DrawerButton
label="Hide this counter"
variant="ghost"
size="sm"
icon={<EyeOff size={14} />}
onClick={() => {
labels.hide(row.terminalId);
onClose();
}}
/>
<Button
<DrawerButton
label="Save name"
variant="primary"
size="sm"
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
isDisabled={draft.trim() === '' && !isNamed}
onClick={() => {
labels.rename(row.terminalId, draft.trim());
onClose();
}}
/>
</HStack>
<Text type="body" size="xsm" color="secondary">
Hiding removes a counter from this page. It keeps recording sales — use it for a till
that has been retired.
</Text>
</VStack>
</VStack>
</>
}
>
{/* ── The period, in the figures the table totals ──────────────────── */}
<Metrics cols={2}>
<Metric
label="Sold in this period"
value={money(row.periodAmount)}
note={`${row.periodBills} bill${row.periodBills === 1 ? '' : 's'}`}
/>
<Metric
label="Today on the till"
value={status ? money(status.todayAmount) : '—'}
note={status ? `${status.todayBills} bill${status.todayBills === 1 ? '' : 's'}` : 'never reported'}
isSmall
/>
</Metrics>
{/* The named problem behind an "Attention" or an "Offline". The board
already worked out what is wrong and how long it has been wrong; the
row could only show the colour. */}
{row.card ? (
<Note icon={<AlertTriangle size={15} />}>
<strong>{row.card.problem.headline}</strong> {row.card.problem.detail}
{row.card.problem.action ? (
<>
{' '}
<strong>{row.card.problem.action}</strong>
</>
) : null}
</Note>
) : null}
<Section title="Readings">
<DrawerCard>
<Row label="Counter code" value={row.terminalId} icon={<Hash size={14} />} />
<Row label="Branch" value={row.branchName} icon={<MapPin size={14} />} />
{status ? (
<>
<Row
label="Last sale"
value={
status.lastBillAt
? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago`
: 'never'
}
icon={<Receipt size={14} />}
{...(status.lastBillAt ? {} : { tone: 'muted' as const })}
/>
<Row
label="Last heard from"
value={status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`}
icon={<Monitor size={14} />}
{...(status.silentForMs == null ? { tone: 'muted' as const } : {})}
/>
{/* Stated even at zero. It is the number that explains a revenue
figure reading low, and hiding it as "nothing to report" is
what leaves somebody hunting for the missing money. */}
<Row
label="Waiting to send"
value={String(status.pendingBills)}
{...(status.pendingBills > 0 ? {} : { tone: 'muted' as const })}
/>
{status.reason ? <Row label="Reported" value={status.reason} isStacked /> : null}
</>
) : (
<Row label="Status" value="This counter has never reported in." isStacked />
)}
</DrawerCard>
</Section>
<Section title="Name and visibility">
<Field
label="Name this counter"
description="Shown instead of the code. The code stays on the receipts."
>
<TextField value={draft} onChange={setDraft} placeholder={row.terminalId} />
</Field>
<Note>
Hiding removes a counter from this page. It keeps recording sales — use it for a till that
has been retired.
</Note>
</Section>
</Drawer>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
import type { DateRange } from '@/api/insights';
import './dateRangePicker.css';
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
@@ -64,7 +65,7 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
}, []);
return (
<div style={{ width: 180 }}>
<div className="datefilter" style={{ width: 180 }}>
<DateRangeInput
width={180}
size="sm"

View File

@@ -19,6 +19,15 @@ import './drawer.css';
* else. Content comes from `drawerKit`. A drawer that styles its own card or
* badge is the beginning of the drift this component exists to end.
*/
/**
* How many drawers are open right now.
*
* Module scope rather than component state, because the count has to be shared
* between separate instances that know nothing about each other — see the
* scroll lock in the component below.
*/
let openDrawers = 0;
export interface DrawerProps {
title: string;
/** One line under the title — a brand, an SKU, a store name. */
@@ -78,6 +87,31 @@ export function Drawer({
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
/*
The page behind stops scrolling while this is open.
`overscroll-behavior: contain` on the body already stops a scroll that
reaches the end of the drawer from continuing into the page underneath. What
it does not stop is a wheel over the SCRIM, which is most of the screen — so
the list behind would scroll away under a drawer that stayed put, and closing
it left the operator somewhere they had not chosen to be.
No padding is added to compensate for the vanished scrollbar: `html` carries
`scrollbar-gutter: stable`, so the gutter is reserved whether or not a bar is
drawn there and the page does not jump sideways as this opens.
*/
useEffect(() => {
/* Counted, not a boolean. Drawers stack — the partner's rider list opens a
rider drawer on top of itself — and closing the inner one must not
unlock the page while the outer one is still covering it. */
openDrawers += 1;
document.body.style.overflow = 'hidden';
return () => {
openDrawers -= 1;
if (openDrawers === 0) document.body.style.overflow = '';
};
}, []);
return (
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
@@ -105,7 +139,10 @@ export function Drawer({
{/* Beside the title, not beneath it. Stacked under the subtitle it was
a third line, and a third line is what made this header twice the
height of the nav bar it now matches. */}
height of the nav bar it now matches.
The badge here is the same badge the table row wears, in the same
colour — see the note on `Badge` in `drawerKit`. */}
{meta ? <div className="drawer-meta">{meta}</div> : null}
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
<X size={16} />

View File

@@ -1,17 +1,26 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check, Copy } from 'lucide-react';
import { Copy, MapPin } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import {
Alert,
Choice,
ChoiceGroup,
DrawerButton,
DrawerCard,
Field,
FieldRow,
Mono,
Note,
Row,
Section,
SelectField,
TextField,
} from './drawerKit';
import { branchLabel } from './format';
/* ── Shell ───────────────────────────────────────────────────────────────── */
@@ -21,6 +30,16 @@ import { branchLabel } from './format';
*
* Right-hand rather than centred, and Escape closes it — the same contract as
* the order drawer, so the two do not behave differently for no reason.
*
* ── Why this file stopped styling itself ────────────────────────────────────
*
* It used to build its own error box, its own Active/Inactive pair and its own
* PIN panel out of `HStack`s and inline styles, and pick Astryx's `TextInput`
* and `Selector` for the fields. None of those matched the drawers on either
* side of them: a different control height, a different label weight, a
* different radius, a third spelling of an error. The kit now carries a field,
* an input, a picker, a choice and an alert, so there is nothing left here to
* drift.
*/
function Sheet({
title,
@@ -38,7 +57,7 @@ function Sheet({
return (
<Drawer
title={title}
width={440}
width={460}
onClose={onClose}
footer={footer}
isFooterSpread
@@ -49,28 +68,6 @@ function Sheet({
);
}
function Problem({ message }: { message: string }) {
return (
<HStack
gap={1}
align="start"
style={{
padding: 12,
borderRadius: 12,
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
}}
>
<AlertTriangle
size={14}
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
/>
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
{message}
</Text>
</HStack>
);
}
/* ── Back-office person ──────────────────────────────────────────────────── */
/**
@@ -81,22 +78,16 @@ function Problem({ message }: { message: string }) {
* ones `resolveRole` already keys on. Editing an existing person shows whatever
* `getstaffs` resolved, even when it is not in this list, because the backend's
* label is authoritative and most accounts carry an id absent from `app_roles`.
*/
/**
* The back-office roles, and what each one can actually reach.
*
* The labels used to be bare — Administrator, Manager, Staff — and the
* difference between them is not a matter of seniority, it is which workspace
* the account lands in. `resolveRole` sends roleids 1 and 3 to the merchant
* console; everything else becomes a branch user, pinned to one shop.
* ── The labels name the consequence, not the seniority ──────────────────────
*
* Somebody choosing "Staff" for a person they meant to be a second
* administrator gets an account that signs in to the wrong workspace, and
* nothing on the way there says so. Naming the consequence in the option is the
* cheapest possible fix.
*/
/*
* Administrator (roleid 3) is NOT offered here, and its absence is the point.
* The difference between these is not rank, it is which workspace the account
* lands in. `resolveRole` sends roleids 1 and 3 to the merchant console;
* everything else becomes a branch user, pinned to one shop. Somebody choosing
* "Staff" for a person they meant to be a second administrator gets an account
* that signs in to the wrong workspace, and nothing on the way there says so.
*
* ── Administrator (roleid 3) is NOT offered, and its absence is the point ───
*
* An administrator is provisioned by Nearle Admin when the business is created
* — `createtenantuser` writes that account and forces the role — so offering it
@@ -106,10 +97,16 @@ function Problem({ message }: { message: string }) {
* Editing an existing administrator is unaffected: `roleOptions` below appends
* whatever role the person already holds when this list does not know it, which
* exists precisely so editing a phone number cannot silently reassign someone.
* Removing the option from CREATION therefore costs nothing on the edit path.
*/
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
/* The default for a NEW person. Read from the list rather than typed twice —
this was `STAFF_ROLES[1]`, an index that has not existed since the list was
cut to one entry, and it only kept working because the `?? 4` behind it
happens to be the same role. Adding a second entry would have silently made
it the default. */
const DEFAULT_STAFF_ROLE = STAFF_ROLES[0]?.id ?? 4;
export function PersonDrawer({
row,
tenantid,
@@ -128,7 +125,7 @@ export function PersonDrawer({
const [lastname, setLastname] = useState(row?.lastname ?? '');
const [email, setEmail] = useState(row?.email ?? '');
const [contactno, setContactno] = useState(row?.contactno ?? '');
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
const [roleid, setRoleid] = useState(String(row?.roleid ?? DEFAULT_STAFF_ROLE));
/**
* No branch by default, rather than the first one.
*
@@ -179,8 +176,8 @@ export function PersonDrawer({
onError: (error) => setProblem(errorMessage(error)),
});
function submit(event: FormEvent) {
event.preventDefault();
function submit(event?: FormEvent) {
event?.preventDefault();
setProblem(null);
if (!firstname.trim()) return setProblem('A first name is required.');
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
@@ -203,46 +200,54 @@ export function PersonDrawer({
onClose={onClose}
footer={
<>
<Button label="Cancel" variant="secondary" onClick={onClose} />
<Button
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
variant="primary"
isDisabled={save.isPending}
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
onClick={() => submit()}
/>
</>
}
>
<form onSubmit={submit}>
<VStack gap={2}>
{problem ? <Problem message={problem} /> : null}
{problem ? <Alert>{problem}</Alert> : null}
<div className="form-grid">
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
<TextInput label="Last name" value={lastname} onChange={setLastname} />
</div>
<Section title="Who they are">
<FieldRow>
<Field label="First name" isRequired>
<TextField value={firstname} onChange={setFirstname} />
</Field>
<Field label="Last name">
<TextField value={lastname} onChange={setLastname} />
</Field>
</FieldRow>
<TextInput
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={email}
onChange={setEmail}
description="What they sign in with."
/>
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
<Field label="Email" isRequired description="What they sign in with.">
<TextField type="email" value={email} onChange={setEmail} />
</Field>
<div className="form-grid">
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
<Selector
<Field label="Mobile">
<TextField type="tel" value={contactno} onChange={setContactno} />
</Field>
</Section>
<Section title="Where they work">
<Field label="Role">
<SelectField value={roleid} onChange={setRoleid} options={roleOptions} />
</Field>
<Field
label="Branch"
description="Leave this if their shop does not exist yet — place them when it opens."
>
<SelectField
value={locationid}
onChange={setLocationid}
placeholder="Not at a shop yet"
description="Leave this if their shop does not exist yet — place them when it opens."
options={[
/* An offered choice, not an absence. Somebody hired before
their outlet opens sits here, signs in, and is met by the
"No store assigned" screen until a branch is ready for
them — which is what makes hiring before opening possible. */
/* An offered choice, not an absence. Somebody hired before their
outlet opens sits here, signs in, and is met by the "No store
assigned" screen until a branch is ready for them — which is
what makes hiring before opening possible. */
{ value: '0', label: 'Not at a shop yet' },
...branches.map((branch) => ({
value: String(branch.locationid),
@@ -250,21 +255,30 @@ export function PersonDrawer({
})),
]}
/>
</div>
</Field>
</Section>
<ActiveToggle
isActive={isActive}
onChange={setIsActive}
activeNote="Can sign in"
inactiveNote="Cannot sign in. History is kept."
<Section title="Access">
<ChoiceGroup>
<Choice
label="Active"
note="Can sign in"
isSelected={isActive}
onSelect={() => setIsActive(true)}
/>
<Choice
label="Inactive"
note="Cannot sign in. History is kept."
isSelected={!isActive}
onSelect={() => setIsActive(false)}
/>
</ChoiceGroup>
<Text type="body" size="xsm" color="secondary">
<Note>
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
backend job until that changes.
</Text>
</VStack>
</form>
</Note>
</Section>
</Sheet>
);
}
@@ -275,6 +289,7 @@ export function TillDrawer({
row,
tenantid,
locationid,
branchName,
roles,
shifts,
cashierOnly = false,
@@ -283,6 +298,15 @@ export function TillDrawer({
row: PosUser | null;
tenantid: number;
locationid: number;
/**
* The outlet this till account belongs to.
*
* Shown, not asked: a till account is created against the branch whose row
* was clicked and cannot be moved from here. It is stated because the table
* carries a Branch column, and a drawer that silently drops a column the row
* shows leaves the reader unsure which shop they are editing.
*/
branchName?: string | undefined;
roles: PosRole[];
shifts: StaffShift[];
/**
@@ -346,8 +370,8 @@ export function TillDrawer({
onError: (error) => setProblem(errorMessage(error)),
});
function submit(event: FormEvent) {
event.preventDefault();
function submit(event?: FormEvent) {
event?.preventDefault();
setProblem(null);
if (!fullName.trim()) return setProblem('A name is required.');
const mobile = normaliseMobile(contactno);
@@ -366,42 +390,30 @@ export function TillDrawer({
title="Account created"
subtitle={fullName}
onClose={onClose}
footer={<Button label="Done" variant="primary" onClick={onClose} />}
footer={<DrawerButton label="Done" variant="primary" onClick={onClose} />}
>
<VStack gap={2}>
<Text type="body" size="sm">
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
is lost, reissue rather than look it up.
</Text>
<HStack
justify="between"
align="center"
style={{
padding: '14px 16px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
}}
>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 22,
fontWeight: 700,
letterSpacing: '0.18em',
}}
>
{issued}
</span>
<Button
<Note tone="brand">
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it is
lost, reissue rather than look it up.
</Note>
<DrawerCard tone="subtle">
<Row
label="PIN"
value={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
<Mono isLarge>{issued}</Mono>
<DrawerButton
label="Copy"
variant="secondary"
size="sm"
icon={<Copy size={13} />}
onClick={() => void navigator.clipboard?.writeText(issued)}
/>
</HStack>
</VStack>
</span>
}
isStacked
/>
</DrawerCard>
</Sheet>
);
}
@@ -427,70 +439,73 @@ export function TillDrawer({
: 'Add a terminal account'
: (row.full_name ?? 'Edit terminal account')
}
subtitle={
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
}
subtitle={isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`}
onClose={onClose}
footer={
<>
<Button label="Cancel" variant="secondary" onClick={onClose} />
<Button
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
variant="primary"
isDisabled={save.isPending}
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
onClick={() => submit()}
/>
</>
}
>
<form onSubmit={submit}>
<VStack gap={2}>
{problem ? <Problem message={problem} /> : null}
{problem ? <Alert>{problem}</Alert> : null}
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
{branchName ? (
<DrawerCard>
<Row label="Branch" value={branchLabel(branchName)} icon={<MapPin size={14} />} />
</DrawerCard>
) : null}
{/* The role is shown either way — a form that silently decides a
field leaves the reader wondering what was decided. A shop just
cannot change it: the merchant issues supervisors, and the backend
only lets roleid 7 manage till users at all. */}
<Section title="Who they are">
<Field label="Full name" isRequired>
<TextField value={fullName} onChange={setFullName} />
</Field>
{/* The role is shown either way — a form that silently decides a field
leaves the reader wondering what was decided. A shop just cannot
change it: the merchant issues supervisors, and the backend only
lets roleid 7 manage till users at all. */}
{cashierOnly ? (
<Selector
label="Role"
<Field label="Role" description="Supervisors are issued by your store administrator.">
<SelectField
value="cashier"
onChange={() => undefined}
options={[{ value: 'cashier', label: 'Cashier' }]}
isDisabled
description="Supervisors are issued by your store administrator."
/>
</Field>
) : (
<>
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
{chosenRole?.description ? (
<Text type="body" size="xsm" color="secondary">
{chosenRole.description}
</Text>
) : null}
</>
<Field label="Role" {...(chosenRole?.description ? { description: chosenRole.description } : {})}>
<SelectField value={role} onChange={setRole} options={roleOptions} />
</Field>
)}
<TextInput
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={contactno}
onChange={setContactno}
<Field
label="Mobile"
isRequired
description="Ten digits. This is what they sign in with at the till."
/>
>
<TextField type="tel" value={contactno} onChange={setContactno} />
</Field>
{isNew ? (
<TextInput
<Field
label="PIN"
value={pin}
onChange={setPin}
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
/>
>
<TextField value={pin} onChange={setPin} />
</Field>
) : null}
</Section>
<Selector
label="Shift"
<Section title="When they work">
<Field label="Shift" description="Informational. Nothing refuses a bill rung outside it.">
<SelectField
value={shiftId}
onChange={setShiftId}
options={[
@@ -500,112 +515,26 @@ export function TillDrawer({
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
})),
]}
description="Informational. Nothing refuses a bill rung outside it."
/>
</Field>
</Section>
<ActiveToggle
isActive={isActive}
onChange={setIsActive}
activeNote="Can open a till"
inactiveNote="Cannot sign in at a till. Their bills are kept."
/>
</VStack>
</form>
</Sheet>
);
}
/* ── Shared ──────────────────────────────────────────────────────────────── */
/**
* Active / inactive, as a pair of buttons rather than a checkbox.
*
* Deactivating is the closest thing this console has to a delete, so it says
* what it does in words on the control itself instead of leaving the
* consequence to a checkbox label the eye skips.
*/
function ActiveToggle({
isActive,
onChange,
activeNote,
inactiveNote,
}: {
isActive: boolean;
onChange: (next: boolean) => void;
activeNote: string;
inactiveNote: string;
}) {
return (
<VStack gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
Access
</Text>
<HStack gap={1}>
<Section title="Access">
<ChoiceGroup>
<Choice
label="Active"
note={activeNote}
note="Can open a till"
isSelected={isActive}
onSelect={() => onChange(true)}
onSelect={() => setIsActive(true)}
/>
<Choice
label="Inactive"
note={inactiveNote}
note="Cannot sign in at a till. Their bills are kept."
isSelected={!isActive}
onSelect={() => onChange(false)}
onSelect={() => setIsActive(false)}
/>
</HStack>
</VStack>
);
}
function Choice({
label,
note,
isSelected,
onSelect,
}: {
label: string;
note: string;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={isSelected}
style={{
flex: 1,
textAlign: 'left',
padding: '10px 12px',
borderRadius: 12,
cursor: 'pointer',
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
transition: 'border-color .15s, background .15s',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 5,
fontSize: 13,
fontWeight: 600,
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
}}
>
{isSelected ? <Check size={12} /> : null}
{label}
</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
{note}
</span>
</button>
</ChoiceGroup>
</Section>
</Sheet>
);
}

View File

@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
@@ -63,7 +64,7 @@ export function PlanVsActualPanel({
if (isLoading) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the day…
@@ -75,7 +76,7 @@ export function PlanVsActualPanel({
if (finished.length === 0) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<Text type="label" size="sm" weight="semibold">
Nothing finished in this range
@@ -94,7 +95,7 @@ export function PlanVsActualPanel({
return (
<VStack gap={1.5}>
{/* ── The promise ────────────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
Against the promise
@@ -134,7 +135,7 @@ export function PlanVsActualPanel({
</Card>
{/* ── Where the time goes ────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="end" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
@@ -191,7 +192,7 @@ export function PlanVsActualPanel({
</Card>
{/* ── Distance ───────────────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
Distance
@@ -222,7 +223,12 @@ export function PlanVsActualPanel({
</Card>
{/* ── The deliveries themselves ──────────────────────────────────── */}
<Card padding={0} elevation="low">
<Panel
title="Planned vs actual"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<VStack gap={1} padding={2} align="stretch">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
@@ -298,8 +304,7 @@ export function PlanVsActualPanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
</Panel>
</VStack>
);
}

View File

@@ -1,10 +1,15 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, Product } from '@/api/types';
import type { CatalogueProduct, Product, StockRequest } from '@/api/types';
/* A pure reading of a `StockRequest`, not a Store user component — the same
three functions the Store user's table chips from, so the row and this
drawer cannot label one request two ways. */
import { REQUEST_COLOR, REQUEST_LABEL, requestState } from '@/features/store-user/requestState';
import { queryKeys } from '@/queries/keys';
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { aisleLabelOf, UNCATEGORISED_AISLE } from './appAisle';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import {
@@ -57,9 +62,23 @@ import {
export function ProductDrawer({
product,
canManage = true,
request,
onClose,
}: {
product: Product;
/**
* The branch's latest stock request for this product, where there is one.
*
* Passed in rather than fetched: the Store user's catalogue already holds
* every request for the page — `latestByProduct` — and a second read here
* would be a request per drawer open for data already in hand. It is absent
* in the merchant's console, which has no "ask for stock" at all.
*
* It is here because the row carries a Stock request column, and a drawer
* that drops a column the row shows makes the reader go back to the list to
* find out what they were just looking at.
*/
request?: StockRequest | undefined;
/**
* False in the Store user workspace.
*
@@ -77,11 +96,20 @@ export function ProductDrawer({
const [shot, setShot] = useState(0);
/* The aisle name for this product's subcategoryid, from the same list the
customer app groups by. */
customer app groups by — and through the same helper the products table
prints with, because the two used to disagree: the row fell back to the
product's own `categoryname` when the platform list had no entry, and this
drawer did not, so one product read "Dairy" in the list and
"Uncategorized" in the sheet opened from it. */
const aisles = useAppAisles(tenantid || undefined);
const aisleName =
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
null;
const aisleNames = useMemo(() => {
const out = new Map<number, string>();
for (const row of aisles.data ?? []) {
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
}
return out;
}, [aisles.data]);
const aisle = aisleLabelOf(product, aisleNames);
/*
What the catalogue said, preferred from the product's own copy.
@@ -115,6 +143,18 @@ export function ProductDrawer({
});
/* The row's chip, rebuilt from the same reading of the request the table
uses. Quantity is stated because "Asked for" without a number is the half
of the fact nobody needs. */
const requestStateValue = requestState(request);
const requestChip = requestStateValue
? {
label: REQUEST_LABEL[requestStateValue],
colour: REQUEST_COLOR[requestStateValue],
detail: `${count(request?.qty ?? 0)} ${[product.unitvalue, product.productunit].filter(Boolean).join(' ') || 'units'}`,
}
: null;
const price = effectivePrice(product);
const cost = product.productcost ?? 0;
/* Margin is derived, not stored. Shown only when both halves are real — a
@@ -238,15 +278,32 @@ export function ProductDrawer({
cosmetic default here: it is the heading the app will print. */}
<Row
label="Shown in the app under"
value={aisleName ?? 'Uncategorized'}
value={aisle}
icon={<Tag size={14} />}
{...(aisleName ? {} : { tone: 'muted' as const })}
{...(aisle === UNCATEGORISED_AISLE ? { tone: 'muted' as const } : {})}
/>
<Row
label="Unit"
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
icon={<Ruler size={14} />}
/>
{/* The row's own Stock request column, repeated here rather than
dropped. Same chip, same wording, same quantity — it is read from
the same `requestState` the table reads. */}
{requestChip ? (
<Row
label="Stock request"
value={
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Badge label={requestChip.label} colour={requestChip.colour} />
{requestChip.detail}
</span>
}
icon={<ClipboardList size={14} />}
/>
) : null}
</DrawerCard>
</Section>

View File

@@ -1,14 +1,14 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { Panel } from '@/components/Panel';
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { aisleLabelOf } from './appAisle';
import { useBranchScope } from './BranchScope';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
@@ -133,11 +133,11 @@ export function ProductsPanel({
[aisles.data],
);
return (
<VStack gap={1.5}>
<SectionHeader
title="Products"
action={
/* The panel's own header owns the category filter now, rather than a
`SectionHeader` floating above the card. It filters THIS table; sitting
outside the panel it read as a page-level control that happened to be
nearby. */
const filter = (
<div style={{ width: 190 }}>
<Selector
label="Category"
@@ -149,9 +149,10 @@ export function ProductsPanel({
options={categoryOptions}
/>
</div>
}
/>
);
return (
<VStack gap={1.5}>
{selection.count > 0 ? (
<div className="bulkbar" data-active="yes">
<HStack justify="between" align="center" gap={2} wrap="wrap">
@@ -180,6 +181,7 @@ export function ProductsPanel({
selection={selection}
queryKey={category}
isLoading={products.isLoading}
actions={filter}
onOpen={setOpen}
onPrice={(product) => setPublishing([product])}
/>
@@ -222,6 +224,7 @@ function ProductTable({
aisleNames,
selection,
isLoading,
actions,
onOpen,
onPrice,
queryKey,
@@ -232,6 +235,8 @@ function ProductTable({
/** The tick state, owned by the panel so its bar can act on the choice. */
selection: ReturnType<typeof useSelection>;
isLoading: boolean;
/** The category filter, rendered in the panel header beside the count. */
actions: ReactNode;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
/** The category filter; changing it returns the pager to page 1. */
@@ -242,20 +247,18 @@ function ProductTable({
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Panel title="Products" actions={actions} padding="md">
<Text type="label" size="sm" weight="semibold">
Reading your catalogue…
</Text>
</VStack>
</Card>
</Panel>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Panel title="Products" count={0} countLabel="products" actions={actions} padding="md">
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Nothing here yet
</Text>
@@ -265,12 +268,18 @@ function ProductTable({
were expecting rows.
</Text>
</VStack>
</Card>
</Panel>
);
}
return (
<Card padding={0} elevation="low">
<Panel
title="Products"
count={rows.length}
countLabel="products"
actions={actions}
footer={<TablePager paged={paged} label="products" />}
>
<div className="table-scroll">
{/* `table-layout: fixed` with an explicit colgroup. Without it the
browser sizes every column to its content, so Category and Status
@@ -342,8 +351,7 @@ function ProductTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="products" />
</Card>
</Panel>
);
}
@@ -445,11 +453,9 @@ function Row({
>
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
selects `a.*` and joins no subcategory table — so it is looked up
from the platform list the app itself groups by. */}
{aisleNames.get(product.subcategoryid ?? 0) ??
product.categoryname ??
product.Subcategoryname ??
'Uncategorized'}
from the platform list the app itself groups by. The drawer prints
the same string from the same helper, or the two disagree. */}
{aisleLabelOf(product, aisleNames)}
</span>
</Td>
<Td isMuted>

View File

@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { coverage, profitability } from './profitability';
@@ -48,7 +49,7 @@ export function ProfitPanel({
if (isLoading) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the day…
@@ -60,7 +61,7 @@ export function ProfitPanel({
if (result.deliveries === 0) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<Text type="label" size="sm" weight="semibold">
Nothing finished in this range
@@ -76,7 +77,7 @@ export function ProfitPanel({
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="end" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
@@ -129,7 +130,7 @@ export function ProfitPanel({
</VStack>
</Card>
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
By rider
@@ -167,7 +168,12 @@ export function ProfitPanel({
</div>
</Card>
<Card padding={0} elevation="low">
<Panel
title="Delivery profit"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
Delivery by delivery
@@ -211,8 +217,7 @@ export function ProfitPanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
</Panel>
<div className="pva-note">
<Info size={13} />

View File

@@ -330,7 +330,7 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
value={
<Badge
label={`${results.length - failed.length} of ${results.length}`}
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : 'var(--color-success, #1f9d55)'}
/>
}
{...(failed.length === 0 ? { tone: 'success' as const } : {})}

View File

@@ -1,19 +1,26 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Info } from 'lucide-react';
import { Bike, Building2, CircleDot } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
import type { TenantLocation } from '@/api/types';
import { useRiderShifts } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { DrawerButton } from './drawerKit';
import './pages/deliveries.css';
import {
Alert,
Badge,
DrawerButton,
DrawerCard,
Field,
FieldRow,
Note,
Row,
Section,
SelectField,
TextField,
} from './drawerKit';
import { branchLabel } from './format';
/**
* Hiring a rider, or editing one.
@@ -30,6 +37,15 @@ import './pages/deliveries.css';
* and start a shift, because that list requires a clock-in stamped today. That
* is correct behaviour and it looks exactly like a failed save, so the drawer
* says it before you press the button rather than leaving you to wonder.
*
* ── What it used to leave out ───────────────────────────────────────────────
*
* The roster table carries Partner and On shift, and this drawer carried
* neither — so opening a rider to check who they ride for lost the answer the
* row had just given. Both are FACTS rather than fields: whose rider this is
* comes from the scope the drawer was opened in, and being on shift means a
* clock-in stamped today, which the console cannot set. They are stated at the
* top, above the things that can actually be typed.
*/
/**
* Whose rider this is.
@@ -140,12 +156,34 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
applocationid > 0 &&
!save.isPending;
/* Whose rider this is, in the words the roster's Partner column uses. An own
rider reads "Own rider" there and here, rather than an empty cell in one
place and nothing at all in the other. */
const partnerName =
owner.kind === 'partner' ? owner.partnername : (row?.partnername ?? 'Own rider');
return (
<Drawer
title={isNew ? 'Add a rider' : row.fullname || 'Rider'}
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
width={480}
onClose={onClose}
/* The roster's Status column, beside the title — the same chip, and the
reason somebody opened this rider in the first place. */
{...(isNew
? {}
: {
meta: (
<Badge
label={row.status || 'Active'}
colour={
(row.status ?? 'Active') === 'Active'
? 'var(--color-success, #1f9d55)'
: 'var(--color-warning, #b7860b)'
}
/>
),
})}
isFooterSpread
footer={
<>
@@ -160,46 +198,61 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
</>
}
>
<VStack gap={3}>
{problem ? <Alert>{problem}</Alert> : null}
{/* The region is stated, not offered. If the branch has none, saying so
here beats a 400 after the form is filled in. */}
{applocationid === 0 ? (
<div className="rider-note" data-tone="stop">
<Info size={15} />
<Text type="body" size="sm">
This branch has no delivery region set, so a rider added to it would not appear
anywhere. Set the region on the branch first.
</Text>
</div>
<Alert tone="warning">
This branch has no delivery region set, so a rider added to it would not appear anywhere.
Set the region on the branch first.
</Alert>
) : null}
<VStack gap={1.5}>
<HStack gap={1.5} wrap="wrap">
<TextInput label="First name" value={form.firstname} onChange={set('firstname')} />
<TextInput label="Last name" value={form.lastname} onChange={set('lastname')} />
</HStack>
<TextInput
label="Phone"
value={form.contactno}
onChange={set('contactno')}
description="How the shop reaches them, and how the app signs them in."
{/* ── What the roster row said ───────────────────────────────────────
Read-only, because neither can be typed: ownership comes from the
scope this drawer was opened in, and being on shift means a clock-in
stamped today, which only the rider app writes. */}
{isNew ? null : (
<DrawerCard>
<Row label="Partner" value={partnerName} icon={<Building2 size={14} />} />
<Row
label="On shift"
value={row.isonduty ? 'On shift' : 'Off shift'}
icon={<CircleDot size={14} />}
{...(row.isonduty ? { tone: 'success' as const } : { tone: 'muted' as const })}
/>
<TextInput label="Email" value={form.email} onChange={set('email')} />
</VStack>
</DrawerCard>
)}
<VStack gap={1.5}>
<Text type="label" size="xsm" color="secondary">
Shift and partner
</Text>
<Selector
label="Shift"
size="sm"
<Section title="Who they are">
<FieldRow>
<Field label="First name" isRequired>
<TextField value={form.firstname} onChange={set('firstname')} />
</Field>
<Field label="Last name">
<TextField value={form.lastname} onChange={set('lastname')} />
</Field>
</FieldRow>
<Field
label="Phone"
isRequired
description="How the shop reaches them, and how the app signs them in."
>
<TextField type="tel" value={form.contactno} onChange={set('contactno')} />
</Field>
<Field label="Email">
<TextField type="email" value={form.email} onChange={set('email')} />
</Field>
</Section>
<Section title="Shift and branch">
<Field label="Shift" isRequired>
<SelectField
value={form.shiftid}
onChange={(value) => set('shiftid')(String(value))}
options={(shifts.data ?? []).map((shift) => ({
value: String(shift.shiftid),
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
}))}
onChange={set('shiftid')}
placeholder={
shifts.isLoading
? 'Loading shifts…'
@@ -207,94 +260,89 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
? 'Choose a shift'
: 'No shifts set up for this region'
}
options={(shifts.data ?? []).map((shift) => ({
value: String(shift.shiftid),
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
}))}
/>
</Field>
{/* A merchant's rider works out of one of that merchant's branches.
A partner's does not — a partner supplies several merchants and is
tied to none of their outlets — so the question is only asked
where it has an answer. */}
{owner.kind === 'tenant' ? (
<Selector
<Field
label="Branch"
size="sm"
description="Which of your outlets this rider works out of. It can be changed later."
>
<SelectField
value={form.locationid}
onChange={(value) => set('locationid')(String(value))}
onChange={set('locationid')}
placeholder="Choose the branch they ride from"
options={owner.branches.map((entry) => ({
value: String(entry.locationid),
label: entry.locationname || `Branch ${entry.locationid}`,
label: branchLabel(entry.locationname) || `Branch ${entry.locationid}`,
}))}
placeholder="Choose the branch they ride from"
description="Which of your outlets this rider works out of. It can be changed later."
/>
</Field>
) : (
<div className="rider-note">
<Info size={14} />
<Text type="body" size="sm" color="secondary">
<Note>
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
partner supplies in this region.
</Text>
</div>
</Note>
)}
</VStack>
</Section>
<VStack gap={1.5}>
<Text type="label" size="xsm" color="secondary">
Vehicle and papers
</Text>
<HStack gap={1.5} wrap="wrap">
<TextInput
label="Vehicle"
<Section title="Vehicle and papers">
<FieldRow>
<Field label="Vehicle">
<TextField
value={form.vehiclename}
onChange={set('vehiclename')}
placeholder="Bike, Scooter…"
/>
<TextInput label="Number plate" value={form.vehicleno} onChange={set('vehicleno')} />
</HStack>
<HStack gap={1.5} wrap="wrap">
<TextInput label="Licence no." value={form.licenseno} onChange={set('licenseno')} />
<TextInput
label="Registration no."
value={form.registrationno}
onChange={set('registrationno')}
/>
</HStack>
<TextInput
label="ID number"
value={form.identificationno}
onChange={set('identificationno')}
/>
</VStack>
</Field>
<Field label="Number plate">
<TextField value={form.vehicleno} onChange={set('vehicleno')} />
</Field>
</FieldRow>
<FieldRow>
<Field label="Licence no.">
<TextField value={form.licenseno} onChange={set('licenseno')} />
</Field>
<Field label="Registration no.">
<TextField value={form.registrationno} onChange={set('registrationno')} />
</Field>
</FieldRow>
<Field label="ID number">
<TextField value={form.identificationno} onChange={set('identificationno')} />
</Field>
</Section>
{!isNew ? (
<Selector
label="Status"
size="sm"
<Section title="Status">
<Field label="Status">
<SelectField
value={form.status}
onChange={(value) => set('status')(String(value))}
onChange={set('status')}
options={[
{ value: 'Active', label: 'Active' },
{ value: 'InActive', label: 'Inactive — cannot be given work' },
]}
/>
) : null}
{problem ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
</Field>
</Section>
) : null}
{/* Said before the button, not after. This is the behaviour most likely
to be read as a failed save. */}
{isNew ? (
<div className="rider-note">
<Info size={15} />
<Text type="body" size="xsm">
They will show here straight away, but not in the fleet you assign deliveries from
until they sign in to the rider app and start a shift.
</Text>
</div>
<Note>
They will show here straight away, but not in the fleet you assign deliveries from until
they sign in to the rider app and start a shift.
</Note>
) : null}
</VStack>
</Drawer>
);
}

View File

@@ -11,7 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { DrawerButton } from './drawerKit';
import { Alert, DrawerButton } from './drawerKit';
import { buildDeliveries, riderName } from './assignDelivery';
import { money } from './format';
import { orderValue } from './orderStatus';
@@ -271,11 +271,7 @@ export function RoutePlanDrawer({
</div>
) : null}
{problem ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : null}
{problem ? <Alert>{problem}</Alert> : null}
{outcome ? (
<Text type="body" size="sm" role="status">

View File

@@ -13,6 +13,8 @@ import {
aisleForCategory,
aisleIdForCategory,
aisleIdsFrom,
aisleLabelOf,
UNCATEGORISED_AISLE,
} from './appAisle';
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
@@ -89,3 +91,36 @@ test('every aisle has an id, and no two share one', () => {
assert.equal(new Set(values).size, values.length);
assert.ok(values.every((id) => id > 0));
});
/* ── The label a row and a drawer must agree on ───────────────────────────── */
// The bug this replaces: the table fell back to `categoryname`, the drawer did
// not, so one product read "Dairy" in the list and "Uncategorized" in the sheet
// opened from it.
test('the printed aisle prefers the platform list, then the row own names', () => {
const names = new Map([[15, 'Dairy, Deli & Egg']]);
assert.equal(aisleLabelOf({ subcategoryid: 15 }, names), 'Dairy, Deli & Egg');
assert.equal(
aisleLabelOf({ subcategoryid: 99, categoryname: 'Dairy' }, names),
'Dairy',
'a subcategoryid the platform list does not know still has the row own name',
);
assert.equal(
aisleLabelOf({ subcategoryid: 99, Subcategoryname: 'Snacks' }, names),
'Snacks',
'the misspelt wire field is read too',
);
});
// `categoryname` comes back as '' on every row `getlocationproducts` returns,
// and `??` does not skip an empty string — it printed a blank cell.
test('a blank name is absent, not a label', () => {
const names = new Map<number, string>();
assert.equal(aisleLabelOf({ subcategoryid: 0, categoryname: '' }, names), 'Uncategorized');
assert.equal(aisleLabelOf({ categoryname: ' ' }, names), 'Uncategorized');
assert.equal(aisleLabelOf({}, names), 'Uncategorized');
});
test('"Uncategorized" is the app own heading, spelt the app own way', () => {
assert.equal(UNCATEGORISED_AISLE, 'Uncategorized');
});

View File

@@ -196,3 +196,44 @@ export function aisleIdForCategory(
if (!aisle) return 0;
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
}
/**
* The aisle name to PRINT for a product, on a row or in a drawer.
*
* This exists because the table and the drawer each grew their own version of
* it and they disagreed: the row fell back to `categoryname` when the platform
* list had no entry for the product's `subcategoryid`, and the drawer did not —
* so the same product read "Dairy" in the list and "Uncategorized" the moment
* you opened it. There is one answer to "where does the app show this", and it
* is here.
*
* Blank is treated as absent, which `??` alone does not do. `categoryname` is
* empty on every row `getlocationproducts` returns — the app's category 2 has
* no row in `productcategories` — and falling back to an empty string printed a
* blank cell where "Uncategorized" is the truth: it is the heading the customer
* app will actually render for a product with no aisle.
*/
export function aisleLabelOf(
product: {
subcategoryid?: number | undefined;
categoryname?: string | undefined;
Subcategoryname?: string | undefined;
},
aisleNames: ReadonlyMap<number, string>,
): string {
const named = aisleNames.get(product.subcategoryid ?? 0);
return (
firstFilled(named, product.categoryname, product.Subcategoryname) ?? UNCATEGORISED_AISLE
);
}
/** The heading the customer app prints for anything with no aisle. */
export const UNCATEGORISED_AISLE = 'Uncategorized';
function firstFilled(...values: (string | undefined)[]): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return undefined;
}

View File

@@ -124,3 +124,42 @@ export function byUrgency(a: CounterRow, b: CounterRow): number {
const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
return rank[a.state] - rank[b.state];
}
/* ── How a counter's state is named and coloured ──────────────────────────── */
/**
* The four states, in the words and colours the whole console uses for them.
*
* These lived as two private maps inside `CountersPage`, which is why the
* drawer opened from that table showed no status at all: the vocabulary was not
* reachable from it. A state has one name and one colour wherever it is
* printed — the table's pill, the attention card, the drawer's header badge.
*/
export const COUNTER_STATE_LABEL: Record<CounterState, string> = {
active: 'Active',
attention: 'Attention',
never: 'Never used',
offline: 'Offline',
};
/** The page's own pill tone, for `data-tone` in `counters.css`. */
export const COUNTER_STATE_TONE: Record<CounterState, string> = {
active: 'healthy',
attention: 'attention',
never: 'neutral',
offline: 'critical',
};
/**
* The same four states as a colour, for the drawer badge.
*
* The badge takes a colour rather than a tone name because `drawerKit`'s
* `Badge` is fed one by every other drawer — statuses in this console come from
* several vocabularies and the shared badge deliberately knows none of them.
*/
export const COUNTER_STATE_COLOUR: Record<CounterState, string> = {
active: 'var(--color-success, #1f9d55)',
attention: 'var(--color-warning, #b7860b)',
never: 'var(--color-ink-4)',
offline: 'var(--color-error, #d64545)',
};

View File

@@ -0,0 +1,38 @@
/* ══ The date filter ═══════════════════════════════════════════════════════
The console's date range sits in the app header and, on Uploads, directly
beside a search box. It has to be the same object as the search and the view
tabs: 38px tall, `--card-radius-sm` at the corner, one hairline.
── Why this is four custom properties and not a rewrite ────────────────────
The control is Astryx's `DateRangeInput` — a trigger, a calendar popover, a
preset list and range logic. None of that is worth rebuilding to change a
corner, and none of it is what looked wrong.
Astryx sets the trigger's height from `--size-element-*` and its corner from
`--radius-element`, both as CSS custom properties read at use time. So
redefining them on a wrapper reaches inside the component through the
cascade, which is the seam the design system deliberately leaves. No
selector here touches a StyleX class name — those are generated and would
break on the next release.
The override is SCOPED to this wrapper on purpose. `--radius-element` is the
corner of every Astryx control in the app — buttons, selectors, inputs — and
they are 32px tall where this is 38. Changing the token globally would give
a 32px button a 10px corner it was not drawn for. */
.datefilter {
/* The picker is rendered `size="sm"`, so this is the one that applies; the
other two are set as well because the size prop is a call-site detail and
this wrapper should not care which one it is given. */
--size-element-sm: 38px;
--size-element-md: 38px;
--size-element-lg: 38px;
--radius-element: var(--card-radius-sm);
}
/* The header gives it a fixed width; on a phone the nav row wraps and a 180px
picker beside a branch picker leaves neither readable. */
@media (max-width: 600px) {
.datefilter { width: 100%; }
}

View File

@@ -1,11 +1,72 @@
/* ══ The drawer design system ══════════════════════════════════════════════
One shell, one header, one card, one row, one badge, one button — used by
every side drawer in the console so they read as one product rather than as
several. Everything below is built on the tokens already in `index.css`:
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
the muted greys. No drawer defines a colour of its own.
One shell, one header, one card, one row, one badge, one button, one field —
used by every side drawer in the console so they read as one product rather
than as several.
Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */
── What changed in this pass, and why ──────────────────────────────────────
1. EVERY SIZE IS A TOKEN NOW. The old sheet typed its own numbers at each
use — 13.5px here, 14.5px there, 11.5px somewhere else, four radii, three
control heights. Half-pixel type is what made the drawers look hand-made
beside the tables they open from: no two labels sat on the same baseline
and no two boxes shared a corner. There is one ramp below and nothing
outside it.
2. THE HEADER IS THE SURFACE, NOT THE BRAND. It used to be a bar of
saturated purple. That cost real information: a status badge cannot be
read in its own colour on a purple ground, so the badge had to be
re-invented there as a white chip with a coloured dot — the one badge in
the console that did not match the chip in the row it was opened from. A
white header with a hairline gives the badge its colour back, lets the
title sit in the same ink as the page, and stops the drawer announcing
itself louder than the data it carries. The brand still has the primary
button, the icons and the timeline.
The header keeps its 57px, which is not cosmetic: `AppShell`'s nav bar is
a 56px row plus a 1px rule, so an open drawer's header finishes on exactly
the line the nav bar finishes on, edge to edge across the screen.
3. THE KIT COVERS FORMS. Seven drawers are forms and the kit had nothing for
them, so each reached for Astryx's inputs and its own inline styles. The
field, input, select, choice and alert rules below are that missing half.
Everything reads from the brand tokens in `index.css`. No rule here invents
a colour. */
.drawer-root {
/* ── The ramp ──────────────────────────────────────────────────────────
Whole pixels, and a real ratio between the steps. Anything that needs a
size takes one of these. */
--dr-text-xs: 11px; /* eyebrow caps: section titles, metric labels */
--dr-text-sm: 12px; /* secondary: subtitles, notes under a value */
--dr-text-md: 13px; /* body: row labels, help text, buttons */
--dr-text-lg: 14px; /* emphasis: row values, field labels, titles */
--dr-text-xl: 18px; /* the one figure a drawer is opened to read */
/* ── Spacing, on a 4px grid ─────────────────────────────────────────── */
--dr-gap-xs: 4px;
--dr-gap-sm: 8px;
--dr-gap-md: 12px;
--dr-gap-lg: 16px;
--dr-gap-xl: 20px;
/* ── Shape ────────────────────────────────────────────────────────────
The card radius is the CONSOLE's card radius, not one of the drawer's
own. A card in a drawer and a card on the page behind it are the same
object seen in two places, and this file had them at 10 and 16.
`--dr-radius-sm` stays a local number because it is a CONTROL radius —
the close button, an input, a photo thumbnail — not a small card. */
--dr-radius: var(--card-radius);
--dr-radius-sm: 8px;
--dr-control: 36px; /* every button and input is this tall */
--dr-pad-x: 20px; /* the panel's own gutter, used by head/body/foot */
--dr-row-pad: 11px 14px;
/* ── Lines ──────────────────────────────────────────────────────────── */
--dr-line: var(--color-line);
--dr-line-soft: color-mix(in oklab, var(--color-line) 55%, transparent);
}
/* ── Shell ──────────────────────────────────────────────────────────────── */
@@ -26,7 +87,7 @@
inset: 0;
border: 0;
padding: 0;
background: rgb(15 23 42 / 0.32);
background: rgb(15 23 42 / 0.28);
cursor: pointer;
animation: drawer-fade 180ms ease;
}
@@ -40,7 +101,10 @@
height: 100%;
max-width: 92vw;
background: var(--color-surface);
box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18);
/* A hairline as well as the shadow. With a white header the panel needs an
edge of its own, or it bleeds into a white table behind it. */
border-left: 1px solid var(--dr-line);
box-shadow: -32px 0 64px -24px rgb(15 23 42 / 0.22);
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
}
@@ -59,36 +123,26 @@
/* ── Header ─────────────────────────────────────────────────────────────────
ONE BAR HIGH, and the same bar the page wears.
ONE BAR HIGH, and the same bar the page wears — see the note at the top of
this file for the 57 and for why it is no longer purple.
`AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's
header is 56 + 1 too: open a drawer and its header sits on exactly the line
the nav bar sits on, edge to edge across the screen, instead of a 116px block
starting level with it and finishing 60px lower.
Getting there meant the header becoming a ROW rather than a stack: title and
subtitle share a line-and-a-half in the middle, the status badge sits beside
them, and the close button ends the row. Nothing was dropped to make it fit —
the same four things are in it.
It is painted in the brand purple. The one place a drawer needs to announce
itself is the moment it slides in over the page, and the purple does that
without the panel needing a border or a heavier shadow. */
It is a ROW rather than a stack: title and subtitle share a line and a half
in the middle, the status badge sits beside them, and the close button ends
the row. A stacked header was a third line, and a third line is what made
this twice the height of the nav bar it now matches. */
.drawer-head {
flex: none;
display: flex;
align-items: center;
gap: 12px;
gap: var(--dr-gap-md);
/* 57, not 56: box-sizing is border-box across the app, so the 1px rule
below eats into the height. The nav bar is a 56px row PLUS its rule, and
the two have to finish on the same pixel. */
height: 57px;
padding: 0 12px 0 20px;
background: var(--color-brand);
/* A darker purple, not the grey page rule: a grey seam across a saturated
ground reads as a rendering fault rather than an edge. */
border-bottom: 1px solid var(--color-brand-strong);
padding: 0 var(--dr-gap-md) 0 var(--dr-pad-x);
background: var(--color-surface);
border-bottom: 1px solid var(--dr-line);
}
.drawer-head-text {
@@ -106,42 +160,44 @@
full text is on the page behind it as well as in the tooltip. */
.drawer-title {
margin: 0;
font: 600 15px/1.3 var(--font-sans);
letter-spacing: -0.005em;
color: #fff;
font: 600 var(--dr-text-lg)/1.3 var(--font-sans);
letter-spacing: -0.006em;
color: var(--color-ink-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* An order id is a code, and a code reads better tabular. */
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; }
.drawer-title[data-mono='true'] {
font-family: var(--font-mono);
font-size: var(--dr-text-md);
letter-spacing: 0;
}
.drawer-sub {
font: 400 11.5px/1.35 var(--font-sans);
/* Not a grey: a grey on purple goes muddy. White at 72% keeps the same
"quieter than the title" relationship the page's ink scale gives. */
color: rgb(255 255 255 / 0.72);
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
color: var(--color-ink-4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Beside the title now, not under it — that stacked row is what made the
header three lines tall. It never wraps: a second badge shortens the title
rather than pushing the row taller. */
/* Beside the title, not under it — that stacked row is what made the header
three lines tall. It never wraps: a second badge shortens the title rather
than pushing the row taller. */
.drawer-meta {
display: flex;
align-items: center;
gap: 8px;
gap: var(--dr-gap-sm);
flex: none;
flex-wrap: nowrap;
}
/* Capped, because the meta keeps its width and the title gives way. A branch
named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
.drawer-meta-text {
font: 400 12px/1.4 var(--font-sans);
color: rgb(255 255 255 / 0.72);
max-width: 30ch;
font: 400 var(--dr-text-sm)/1.4 var(--font-sans);
color: var(--color-ink-4);
max-width: 24ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -150,18 +206,22 @@
.drawer-close {
width: 30px;
height: 30px;
border-radius: 9px;
border: 0;
border-radius: var(--dr-radius-sm);
border: 1px solid transparent;
flex: none;
display: grid;
place-items: center;
background: rgb(255 255 255 / 0.14);
color: #fff;
background: transparent;
color: var(--color-ink-3);
cursor: pointer;
transition: background 160ms ease;
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
}
.drawer-close:hover { background: rgb(255 255 255 / 0.26); }
.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.drawer-close:hover {
background: var(--color-surface-sunken);
border-color: var(--dr-line);
color: var(--color-ink-1);
}
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* ── Body ───────────────────────────────────────────────────────────────── */
@@ -169,11 +229,20 @@
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Stops a scroll that reaches the end of the drawer from continuing into the
page underneath. The page is also locked outright while a drawer is open —
see `Drawer.tsx` — because this does nothing about a wheel over the scrim,
which is most of the screen. */
overscroll-behavior: contain;
padding: 20px 24px 24px;
/* Animates the moves the drawer makes for you: the uploads drawer's jump to
its rejection panel, and the browser's own scroll when you tab to a field
below the fold in a long form. The wheel is untouched. The global
reduced-motion reset in `index.css` turns this back to `auto`. */
scroll-behavior: smooth;
padding: var(--dr-pad-x) var(--dr-pad-x) 24px;
display: flex;
flex-direction: column;
gap: 24px;
gap: var(--dr-gap-xl);
}
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
@@ -197,9 +266,9 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px;
border-top: 1px solid var(--color-line);
gap: var(--dr-gap-sm);
padding: var(--dr-gap-md) var(--dr-pad-x);
border-top: 1px solid var(--dr-line);
background: var(--color-surface);
}
.drawer-foot > * { flex: 0 1 auto; }
@@ -212,23 +281,24 @@
/* ── Section ────────────────────────────────────────────────────────────── */
.drawer-section { display: flex; flex-direction: column; gap: 10px; }
.drawer-section { display: flex; flex-direction: column; gap: var(--dr-gap-md); }
.drawer-section-title {
font: 600 11.5px/1 var(--font-sans);
letter-spacing: 0.08em;
margin: 0;
font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.075em;
text-transform: uppercase;
color: var(--color-ink-3);
color: var(--color-ink-4);
}
/* ── Card ───────────────────────────────────────────────────────────────── */
/* One radius, one border, everywhere. Rows inside are separated by hairlines
rather than by giving each its own card — a stack of cards for four labelled
values is the clutter this redesign exists to remove. */
values is the clutter this system exists to remove. */
.drawer-card {
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
border: 1px solid var(--dr-line);
border-radius: var(--dr-radius);
background: var(--card-bg);
overflow: hidden;
}
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
@@ -242,47 +312,53 @@
.drawer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
gap: var(--dr-gap-lg);
align-items: baseline;
padding: 11px 14px;
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
padding: var(--dr-row-pad);
border-bottom: 1px solid var(--dr-line-soft);
}
.drawer-row:last-child { border-bottom: 0; }
.drawer-row-label {
font: 400 13.5px/1.45 var(--font-sans);
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
color: var(--color-ink-3);
display: inline-flex;
align-items: center;
gap: 8px;
gap: var(--dr-gap-sm);
}
.drawer-row-label svg { color: var(--color-brand); flex: none; }
.drawer-row-label svg { color: var(--color-ink-4); flex: none; }
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
.drawer-row-value {
font: 500 14px/1.45 var(--font-sans);
font: 500 var(--dr-text-lg)/1.45 var(--font-sans);
color: var(--color-ink-1);
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; }
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); }
.drawer-row-value[data-tone='success'] { color: var(--color-success, #1f9d55); font-weight: 600; }
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); font-weight: 400; }
/* A row that wraps its value under the label — for prose, not for figures. */
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; }
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; }
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: var(--dr-gap-xs); }
.drawer-row[data-stacked='true'] .drawer-row-value {
text-align: left;
font-weight: 400;
font-size: var(--dr-text-md);
line-height: 1.55;
color: var(--color-ink-2);
}
/* ── Metric grid ────────────────────────────────────────────────────────── */
/* Three equal columns. Equal, not auto: the whole point is that the figures sit
on a rhythm, and `auto` columns move them every time a value changes width. */
/* Equal columns, not auto: the whole point is that the figures sit on a
rhythm, and `auto` columns move them every time a value changes width. */
.drawer-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
background: var(--color-line);
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--dr-line);
border: 1px solid var(--dr-line);
border-radius: var(--dr-radius);
overflow: hidden;
}
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -291,66 +367,54 @@
.drawer-metric {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
gap: var(--dr-gap-xs);
padding: var(--dr-gap-md) 14px;
background: var(--color-surface);
min-width: 0;
}
.drawer-metric-label {
font: 600 11px/1 var(--font-sans);
letter-spacing: 0.07em;
font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.065em;
text-transform: uppercase;
color: var(--color-ink-4);
}
.drawer-metric-value {
font: 600 17px/1.2 var(--font-sans);
font: 600 var(--dr-text-xl)/1.2 var(--font-sans);
letter-spacing: -0.01em;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; }
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); }
.drawer-metric-value[data-size='sm'] { font-size: var(--dr-text-lg); letter-spacing: 0; }
.drawer-metric-note { font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); }
/* ── Status badge ───────────────────────────────────────────────────────── */
/* One badge, tinted from a single colour, so every status in the console is the
same shape and only the hue carries meaning. */
same shape and only the hue carries meaning.
It looks the same in the header as it does on a card. That is the point of
the header no longer being purple: the chip beside the title is now exactly
the chip in the row the drawer was opened from, rather than a white-on-purple
translation of it with the colour demoted to a dot. */
.drawer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
padding: 3px 9px;
border-radius: 999px;
font: 600 12px/1.5 var(--font-sans);
font: 600 var(--dr-text-sm)/1.5 var(--font-sans);
text-transform: capitalize;
white-space: nowrap;
color: var(--badge);
background: color-mix(in oklab, var(--badge) 12%, transparent);
}
/* In the purple header the status colour cannot be the text colour — a 12%
tint of green on purple is unreadable, and several statuses are darker than
the ground. The label goes white on a translucent chip and the colour moves
to a dot, lightened so it still separates from the purple. The status is
still distinguishable at a glance, which is the badge's whole job. */
.drawer-head .drawer-badge {
color: #fff;
background: rgb(255 255 255 / 0.16);
padding-left: 8px;
}
.drawer-head .drawer-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
flex: none;
background: color-mix(in oklab, var(--badge) 55%, white);
background: color-mix(in oklab, var(--badge) 11%, transparent);
border: 1px solid color-mix(in oklab, var(--badge) 24%, transparent);
}
/* ── Timeline ───────────────────────────────────────────────────────────── */
.drawer-timeline { display: flex; flex-direction: column; }
.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; }
.drawer-step { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: var(--dr-gap-md); }
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
@@ -362,7 +426,7 @@
place-items: center;
flex: none;
background: var(--color-surface-sunken);
border: 1px solid var(--color-line);
border: 1px solid var(--dr-line);
color: var(--color-ink-4);
}
.drawer-step-dot[data-done='true'] {
@@ -375,7 +439,7 @@
.drawer-step-dot[data-current='true'] {
background: var(--color-surface);
border: 2px solid var(--color-brand);
box-shadow: 0 0 0 4px var(--color-brand-tint);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
.drawer-step-dot[data-tone='danger'] {
background: color-mix(in oklab, var(--color-error) 14%, transparent);
@@ -384,24 +448,38 @@
}
/* Purple only as far as the journey has actually got. */
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); }
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 18px; background: var(--dr-line); }
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; }
.drawer-step-body {
padding-bottom: var(--dr-gap-md);
display: flex;
flex-direction: column;
gap: 2px;
}
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
.drawer-step-label { font: 500 var(--dr-text-md)/1.35 var(--font-sans); color: var(--color-ink-4); }
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; }
.drawer-step-at {
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
color: var(--color-ink-4);
font-variant-numeric: tabular-nums;
}
/* ── Route ──────────────────────────────────────────────────────────────── */
.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; }
.drawer-party {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: var(--dr-gap-md);
padding: var(--dr-gap-md) 14px;
}
.drawer-party-icon {
width: 30px;
height: 30px;
border-radius: 9px;
width: 28px;
height: 28px;
border-radius: var(--dr-radius-sm);
display: grid;
place-items: center;
flex: none;
@@ -411,13 +489,21 @@
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.drawer-party-kind {
font: 600 10.5px/1 var(--font-sans);
letter-spacing: 0.09em;
font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.075em;
text-transform: uppercase;
color: var(--color-ink-4);
}
.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; }
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; }
.drawer-party-name {
font: 600 var(--dr-text-lg)/1.35 var(--font-sans);
color: var(--color-ink-1);
overflow-wrap: anywhere;
}
.drawer-party-detail {
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
color: var(--color-ink-3);
overflow-wrap: anywhere;
}
/* The phone reads as something you could act on, without pretending to be a
button — these drawers are opened on a desktop where tel: does nothing. */
@@ -425,8 +511,8 @@
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font: 500 12.5px/1 var(--font-sans);
margin-top: var(--dr-gap-xs);
font: 500 var(--dr-text-md)/1 var(--font-sans);
color: var(--color-brand);
font-variant-numeric: tabular-nums;
text-decoration: none;
@@ -439,8 +525,8 @@
place-items: center;
height: 18px;
color: var(--color-ink-4);
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
border-top: 1px solid var(--dr-line-soft);
border-bottom: 1px solid var(--dr-line-soft);
background: var(--color-surface-subtle);
}
@@ -450,49 +536,195 @@
.drawer-note {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-line);
background: var(--color-surface-subtle);
font: 400 13px/1.5 var(--font-sans);
gap: var(--dr-gap-sm);
padding: var(--dr-gap-md) 14px;
border-radius: var(--dr-radius);
border: 1px solid var(--dr-line);
background: var(--card-bg-subtle);
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
color: var(--color-ink-3);
}
.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; }
.drawer-note svg { color: var(--color-ink-4); margin-top: 2px; flex: none; }
.drawer-note strong { color: var(--color-ink-1); font-weight: 600; }
.drawer-note[data-tone='brand'] {
background: var(--color-brand-tint);
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
color: var(--color-ink-2);
}
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
/* ── Alert ──────────────────────────────────────────────────────────────── */
/* The loud sibling of the note: a save that failed, a precondition that is not
met. Three drawers each wrote their own version of this box; there is one. */
.drawer-alert {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--dr-gap-sm);
padding: var(--dr-gap-md) 14px;
border-radius: var(--dr-radius);
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
}
.drawer-alert svg { margin-top: 2px; flex: none; }
.drawer-alert[data-tone='error'] {
color: var(--color-error, #d64545);
background: color-mix(in oklab, var(--color-error, #d64545) 7%, transparent);
border: 1px solid color-mix(in oklab, var(--color-error, #d64545) 22%, transparent);
}
.drawer-alert[data-tone='warning'] {
color: var(--color-warning, #b7860b);
background: color-mix(in oklab, var(--color-warning, #b7860b) 8%, transparent);
border: 1px solid color-mix(in oklab, var(--color-warning, #b7860b) 24%, transparent);
}
/* ── Fields ─────────────────────────────────────────────────────────────── */
.drawer-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.drawer-field-label {
display: inline-flex;
align-items: center;
gap: 3px;
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
color: var(--color-ink-2);
}
.drawer-field-req { color: var(--color-error, #d64545); }
.drawer-field-help {
font: 400 var(--dr-text-sm)/1.45 var(--font-sans);
color: var(--color-ink-4);
}
.drawer-field-error {
font: 500 var(--dr-text-sm)/1.45 var(--font-sans);
color: var(--color-error, #d64545);
}
/* Two fields on a line, stacking rather than squeezing. 200px is a first name
and a last name side by side at 480px, and one above the other below it. */
.drawer-field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--dr-gap-md);
}
.drawer-input {
width: 100%;
height: var(--dr-control);
padding: 0 12px;
border-radius: var(--dr-radius-sm);
border: 1px solid var(--dr-line);
background: var(--color-surface);
color: var(--color-ink-1);
font: 400 var(--dr-text-lg)/1 var(--font-sans);
transition: border-color 160ms ease, box-shadow 160ms ease;
/* Safari gives a text input its own inner shadow and a rounded-rect it will
not let go of; the whole point of this rule is that the field matches the
button beside it. */
appearance: none;
-webkit-appearance: none;
}
.drawer-input::placeholder { color: var(--color-ink-4); }
.drawer-input:hover:not(:disabled) { border-color: var(--color-ink-4); }
.drawer-input:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
.drawer-input[aria-invalid='true'] {
border-color: var(--color-error, #d64545);
}
.drawer-input[aria-invalid='true']:focus {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-error, #d64545) 14%, transparent);
}
.drawer-input:disabled {
background: var(--color-surface-sunken);
color: var(--color-ink-4);
cursor: not-allowed;
}
/* A native select with the platform arrow suppressed and ours drawn on top, so
a picker and a text field are the same object at a glance. */
.drawer-select { position: relative; display: block; min-width: 0; }
.drawer-select select { padding-right: 34px; cursor: pointer; }
.drawer-select svg {
position: absolute;
right: 11px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--color-ink-4);
}
/* ── Choice cards ───────────────────────────────────────────────────────── */
.drawer-choices { display: flex; gap: var(--dr-gap-sm); }
.drawer-choice {
flex: 1 1 0;
min-width: 0;
text-align: left;
padding: 10px 12px;
border-radius: var(--dr-radius-sm);
cursor: pointer;
border: 1px solid var(--dr-line);
background: var(--color-surface);
transition: border-color 150ms ease, background 150ms ease;
}
.drawer-choice:hover { border-color: var(--color-ink-4); }
.drawer-choice[data-selected='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.drawer-choice:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.drawer-choice-label {
display: flex;
align-items: center;
gap: 5px;
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
color: var(--color-ink-1);
}
.drawer-choice[data-selected='true'] .drawer-choice-label { color: var(--color-brand); }
.drawer-choice-note {
display: block;
margin-top: 2px;
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
color: var(--color-ink-4);
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
/* One height, one radius, one type ramp for every button in every drawer. */
/* One height, one radius, one type ramp for every button in every drawer — and
the same height as an input, so a form does not step up and down its own
right-hand edge. */
.drawer-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 42px;
padding: 0 18px;
border-radius: 11px;
gap: 6px;
height: var(--dr-control);
padding: 0 14px;
border-radius: var(--dr-radius-sm);
border: 1px solid transparent;
font: 600 14px/1 var(--font-sans);
font: 600 var(--dr-text-md)/1 var(--font-sans);
cursor: pointer;
white-space: nowrap;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.drawer-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.drawer-btn svg { flex: none; }
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
.drawer-btn[data-variant='secondary'] {
background: var(--color-surface);
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent);
color: var(--color-brand);
border-color: var(--dr-line);
color: var(--color-ink-1);
}
.drawer-btn[data-variant='secondary']:hover:not(:disabled) {
background: var(--color-surface-sunken);
border-color: var(--color-ink-4);
}
.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); }
/* Outline, never filled: a destructive action should be reachable without
being the brightest thing in the drawer. */
@@ -505,8 +737,11 @@
background: color-mix(in oklab, var(--color-error) 8%, transparent);
}
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); }
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); }
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-3); }
.drawer-btn[data-variant='ghost']:hover:not(:disabled) {
background: var(--color-surface-sunken);
color: var(--color-ink-1);
}
/* A text-weight action that lives inside a section, not in the footer —
"Group with another size". */
@@ -517,22 +752,38 @@
padding: 0;
border: 0;
background: none;
font: 600 13px/1 var(--font-sans);
font: 600 var(--dr-text-md)/1 var(--font-sans);
color: var(--color-brand);
cursor: pointer;
}
.drawer-link-action:hover { text-decoration: underline; }
.drawer-link-action:disabled { opacity: 0.45; cursor: not-allowed; text-decoration: none; }
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
/* ── Codes ──────────────────────────────────────────────────────────────── */
.drawer-mono {
font-family: var(--font-mono);
font-size: var(--dr-text-md);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
/* A PIN shown once, meant to be read across a desk and typed in. */
.drawer-mono[data-size='lg'] {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.18em;
}
/* ── Image area ─────────────────────────────────────────────────────────── */
.drawer-figure {
display: grid;
place-items: center;
padding: 16px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
padding: var(--dr-gap-lg);
border: 1px solid var(--dr-line);
border-radius: var(--dr-radius);
background: var(--card-bg-subtle);
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
will happily size to it and push the image straight out of the panel —
which is exactly what happened. Nothing leaves this box. */
@@ -557,13 +808,13 @@
.drawer-figure[data-tall='true'] { height: 260px; }
.drawer-figure[data-tall='true'] img { max-height: 228px; }
.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.drawer-thumbs { display: flex; gap: var(--dr-gap-sm); flex-wrap: wrap; }
.drawer-thumb {
width: 54px;
height: 54px;
width: 52px;
height: 52px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--color-line);
border-radius: var(--dr-radius-sm);
border: 1px solid var(--dr-line);
background: var(--color-surface);
cursor: pointer;
display: grid;
@@ -579,32 +830,54 @@
/* ── Highlight bullets ──────────────────────────────────────────────────── */
.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.drawer-bullets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--dr-gap-lg);
}
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
.drawer-bullet-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.drawer-bullet {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
gap: var(--dr-gap-sm);
align-items: baseline;
font: 400 13.5px/1.45 var(--font-sans);
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
color: var(--color-ink-2);
}
.drawer-bullet::before {
content: '';
width: 5px;
height: 5px;
width: 4px;
height: 4px;
border-radius: 999px;
background: var(--color-brand);
transform: translateY(-2px);
}
/* ── Caption ────────────────────────────────────────────────────────────── */
/* An explanatory line inside a card — why a product was filed where it was,
what an import will and will not do. Quieter than prose and not a note: it
has no box of its own because it is already inside one. */
.drawer-caption {
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
color: var(--color-ink-3);
}
.drawer-caption strong { color: var(--color-ink-1); font-weight: 600; }
/* ── Prose ──────────────────────────────────────────────────────────────── */
/* 15px and 1.6 — a description is the one thing in a drawer that gets read
rather than scanned. */
/* A description is the one thing in a drawer that gets read rather than
scanned, so it is the one thing set above the body size. */
.drawer-prose {
font: 400 14.5px/1.6 var(--font-sans);
margin: 0;
font: 400 var(--dr-text-lg)/1.6 var(--font-sans);
color: var(--color-ink-2);
overflow-wrap: anywhere;
}
@@ -612,35 +885,33 @@
.drawer-prose p:last-child { margin-bottom: 0; }
/* ── Narrower screens ───────────────────────────────────────────────────── */
/* The panel is already capped at 92vw; below that the padding is what has to
/* The panel is already capped at 92vw; below that the gutter is what has to
give, or a 520px layout inside a 360px panel just clips. */
@media (max-width: 600px) {
.drawer-head { padding: 16px 16px 14px; }
.drawer-body { padding: 16px; gap: 20px; }
.drawer-foot { padding: 12px 16px; }
.drawer-title { font-size: 19px; }
.drawer-root { --dr-pad-x: 16px; }
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.drawer-metrics[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.drawer-choices { flex-direction: column; }
}
/* ── Health score, inside the drawer system ─────────────────────────────── */
/* The panel's own pieces — score chip, points, tags — already sit on the brand
palette and are kept. What is added here is the wrapper that makes it read as
one compact card rather than as a loose stack, which is what the redesign
asks of it: informative, and not competing with the product it describes. */
one compact card rather than as a loose stack. */
.health-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
border: 1px solid var(--dr-line);
border-radius: var(--dr-radius);
background: var(--card-bg-subtle);
}
.health-per {
font: 400 12px/1.3 var(--font-sans);
font: 400 var(--dr-text-sm)/1.3 var(--font-sans);
color: var(--color-ink-4);
text-align: right;
}
.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); }
.health-caveat { font: 400 var(--dr-text-md)/1.55 var(--font-sans); color: var(--color-ink-3); }

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react';
import { Info } from 'lucide-react';
import { AlertTriangle, Check, ChevronDown, Info } from 'lucide-react';
import './drawer.css';
/**
@@ -133,10 +133,15 @@ export function Badge({ label, colour }: { label: string; colour: string }) {
<span
className="drawer-badge"
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
`background` directly, so the stylesheet can decide how to spend it.
An inline colour would win over every rule, and the badge has to look
different in the purple header than it does on a white card — see
`.drawer-head .drawer-badge`. */
`background` directly, so the stylesheet decides how to spend it — as
text, as an 11% fill and as a 24% border, from the one value.
It is now the same badge in the header as on a card. It was not while
the header was painted brand purple: a tinted chip is unreadable on a
saturated ground, so the header had its own white-chip-with-a-dot
variant, and the status beside a drawer's title did not match the chip
in the row it was opened from. Dropping the purple dropped the
variant. */
style={{ '--badge': colour } as CSSProperties}
>
{label}
@@ -227,3 +232,204 @@ export function Bullets({ items }: { items: readonly string[] }) {
</ul>
);
}
/* ── Form pieces ─────────────────────────────────────────────────────────────
Seven of the console's drawers are FORMS — add a rider, edit a person, issue
a till account, name a counter — and until now the kit had nothing for them.
So each one reached for Astryx's `TextInput`, `Selector` and `VStack`, laid
them out with its own inline styles, and wrote its own error box. That is why
the edit drawers and the detail drawers did not look like the same product:
different label case, different field gap, different control height, three
spellings of an error.
These are that missing half. They are deliberately plain — a label, a
control, a line of help, an error — and they carry no layout of their own
beyond the field: `Section` still supplies the grouping, the same as it does
for a read-only drawer. */
/**
* A labelled control.
*
* The label is a real `<label>` wrapping its control, so clicking it focuses
* the field and a screen reader names it — which the hand-rolled versions of
* this got wrong in three drawers by rendering the label as a `Text`.
*/
export function Field({
label,
description,
error,
isRequired,
children,
}: {
label: ReactNode;
/** One line under the control — what it is for, or what it will do. */
description?: ReactNode;
/** Shown in place of the description, in the error colour. */
error?: string | undefined;
isRequired?: boolean;
children: ReactNode;
}) {
return (
<label className="drawer-field">
<span className="drawer-field-label">
{label}
{isRequired ? (
<span className="drawer-field-req" aria-hidden>
*
</span>
) : null}
</span>
{children}
{error ? (
<span className="drawer-field-error" role="alert">
{error}
</span>
) : description ? (
<span className="drawer-field-help">{description}</span>
) : null}
</label>
);
}
/** Two fields on one line, stacking on a narrow panel. */
export function FieldRow({ children }: { children: ReactNode }) {
return <div className="drawer-field-row">{children}</div>;
}
export function TextField({
value,
onChange,
placeholder,
type = 'text',
isDisabled,
isInvalid,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
type?: 'text' | 'email' | 'tel' | 'number';
isDisabled?: boolean;
isInvalid?: boolean;
}) {
return (
<input
className="drawer-input"
type={type}
value={value}
disabled={isDisabled}
{...(isInvalid ? { 'aria-invalid': true } : {})}
{...(placeholder ? { placeholder } : {})}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export function SelectField({
value,
onChange,
options,
placeholder,
isDisabled,
}: {
value: string;
onChange: (next: string) => void;
options: readonly { value: string; label: string }[];
/** The empty first option. Absent when every choice is a real one. */
placeholder?: string;
isDisabled?: boolean;
}) {
return (
<span className="drawer-select">
<select
className="drawer-input"
value={value}
disabled={isDisabled}
onChange={(event) => onChange(event.target.value)}
>
{placeholder ? <option value="">{placeholder}</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown size={15} aria-hidden />
</span>
);
}
/**
* Something that went wrong, or something the reader must know before acting.
*
* Distinct from `Note`, which is quiet by design and explains an absence. This
* one is the save that failed and the precondition that is not met, and it is
* the piece three drawers each wrote for themselves.
*/
export function Alert({
tone = 'error',
children,
}: {
tone?: 'error' | 'warning';
children: ReactNode;
}) {
return (
<div className="drawer-alert" data-tone={tone} role="alert">
<AlertTriangle size={15} />
<span>{children}</span>
</div>
);
}
/**
* One choice out of two or three, as cards rather than as a checkbox.
*
* Deactivating somebody is the closest thing this console has to a delete, so
* the consequence is written on the control itself instead of being left to a
* label the eye skips.
*/
export function ChoiceGroup({ label, children }: { label?: string; children: ReactNode }) {
return (
<div className="drawer-field">
{label ? <span className="drawer-field-label">{label}</span> : null}
<div className="drawer-choices">{children}</div>
</div>
);
}
export function Choice({
label,
note,
isSelected,
onSelect,
}: {
label: string;
note?: string;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className="drawer-choice"
data-selected={isSelected}
aria-pressed={isSelected}
onClick={onSelect}
>
<span className="drawer-choice-label">
{isSelected ? <Check size={12} /> : null}
{label}
</span>
{note ? <span className="drawer-choice-note">{note}</span> : null}
</button>
);
}
/** A code — an id, a PIN, a batch — in the mono face. */
export function Mono({ children, isLarge }: { children: ReactNode; isLarge?: boolean }) {
return (
<span className="drawer-mono" {...(isLarge ? { 'data-size': 'lg' } : {})}>
{children}
</span>
);
}

View File

@@ -137,7 +137,7 @@ export function CounterSalesDrawer({
: ''}
</Text>
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -265,7 +265,7 @@ export function CounterSalesDrawer({
</Text>
) : null}
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>

View File

@@ -192,7 +192,7 @@ export function ConsolePage() {
Loading branches…
</Text>
) : rows.length === 0 ? (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={3}>
<Text type="label" size="sm" weight="semibold">
No branches yet
@@ -215,7 +215,7 @@ export function ConsolePage() {
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="Comparison" />
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<ComparisonTable rows={rows} />
</div>
@@ -233,7 +233,7 @@ export function ConsolePage() {
</Text>
}
/>
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<TillTable rows={rows} />
</div>
@@ -244,7 +244,7 @@ export function ConsolePage() {
<VStack gap={1.5}>
<SectionHeader title="Needs you" />
{alerts.length === 0 ? (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
@@ -285,7 +285,7 @@ function BranchCard({ row }: { row: Row }) {
const isActive = branch.status?.toLowerCase() === 'active';
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="start" gap={1}>
<VStack gap={0}>
@@ -700,7 +700,7 @@ function AlertRow({ alert }: { alert: Alert }) {
: 'var(--color-brand)';
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
<span

View File

@@ -1,9 +1,20 @@
import { useState, useMemo } from 'react';
import { SearchInput } from '@/components/SearchInput';
import { useDateScope } from '@/components/shell/DateScope';
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
import { Activity, AlertTriangle, IndianRupee, Monitor } from 'lucide-react';
import { KpiCard, type KpiTone } from '@/components/KpiCard';
import { useBranchScope } from '../BranchScope';
import { useCounterLabels } from '../counterLabels';
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
import {
byUrgency,
countsOf,
filterRows,
rowsOf,
COUNTER_STATE_LABEL,
COUNTER_STATE_TONE,
type CounterRow,
type CounterState,
} from '../counterRows';
import { branchLabel, count, money } from '../format';
import { shortAge } from '../posStatus';
import { useTerminalBoard } from '../useTerminalBoard';
@@ -33,19 +44,10 @@ import './counters.css';
*/
const PAGE_SIZE = 5;
const STATE_LABEL: Record<CounterState, string> = {
active: 'Active',
attention: 'Attention',
never: 'Never used',
offline: 'Offline',
};
const STATE_TONE: Record<CounterState, string> = {
active: 'healthy',
attention: 'attention',
never: 'neutral',
offline: 'critical',
};
/* The names and tones live with the rows now, in `counterRows`, so the drawer
opened from this table can chip a counter the same way the row does. */
const STATE_LABEL = COUNTER_STATE_LABEL;
const STATE_TONE = COUNTER_STATE_TONE;
export function CountersPage() {
const { branches, scoped, isPinned } = useBranchScope();
@@ -121,15 +123,13 @@ export function CountersPage() {
</div>
<div className="counter-controls">
<label className="search" style={{ flex: 1, minWidth: 240 }}>
<Search size={14} aria-hidden />
<input
<SearchInput
label="Search counters"
value={term}
onChange={(event) => { setTerm(event.target.value); setPage(1); }}
placeholder="Search counters by code, name or branch…"
aria-label="Search counters"
onChange={(next) => { setTerm(next); setPage(1); }}
placeholder="Code, name or branch…"
width={300}
/>
</label>
{showBranch ? (
<label className="picker">
@@ -251,18 +251,31 @@ export function CountersPage() {
/* ── Pieces ───────────────────────────────────────────────────────────────── */
/**
* This page's KPI tile was a THIRD copy of the same object, drawn on
* `.kpi` classes that no longer exist. It is the shared component now; only
* the tone words differ, and they are translated here rather than teaching the
* component a second vocabulary — this page says "healthy" and "attention",
* the component says "success" and "warning", and the counters board is the
* only screen using the first pair.
*/
const KPI_TONE: Record<string, KpiTone> = {
healthy: 'success',
attention: 'warning',
critical: 'error',
};
function Kpi({
icon, label, value, note, tone,
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
return (
<article className="kpi">
<span className="kpi-icon" data-tone={tone} aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
<span className="kpi-note">{note}</span>
</div>
</article>
<KpiCard
icon={icon}
label={label}
value={value}
note={note}
{...(tone && KPI_TONE[tone] ? { tone: KPI_TONE[tone] } : {})}
/>
);
}

View File

@@ -22,6 +22,7 @@ import {
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { useSelection, type Selection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
@@ -461,7 +462,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
: {})}
/>
) : mode === 'riders' || mode === 'stores' ? (
<Card padding={0} elevation="low" className="dispatch-card-elevated">
<Card padding={0} className="dispatch-card-elevated">
<VStack gap={1.5} padding={2}>
{/* No banner over the map.
A title, a stop count, a payload total and a pulsing
@@ -516,7 +517,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={28} />
@@ -785,7 +786,7 @@ function EnhancedGroupDetail({
{assignBar}
{showMap ? (
<Card padding={0} elevation="low" className="dispatch-map-card">
<Card padding={0} className="dispatch-map-card">
<VStack gap={1.5} padding={2}>
<GroupMap stops={group.stops} groupName={group.name} locations={locations} />
@@ -836,7 +837,12 @@ function EnhancedGroupDetail({
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<Panel
title="Stops"
count={paged.total}
countLabel="stops"
footer={<TablePager paged={paged} label="stops" />}
>
<div className="table-scroll">
<table className="stops-table">
<thead>
@@ -921,8 +927,7 @@ function EnhancedGroupDetail({
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
</Panel>
)}
</VStack>
);

View File

@@ -6,7 +6,6 @@ import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import {
AlertTriangle,
@@ -24,10 +23,12 @@ import {
Truck,
X,
} from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { stockApi } from '@/api/stock';
import type { StockRequest } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useSelection } from '@/components/useSelection';
@@ -41,6 +42,7 @@ import {
type StepId,
} from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { branchLabel, count, money } from '../format';
@@ -155,33 +157,33 @@ export function InventoryPage() {
giving it a row of its own below. It only appears on that tab —
an action for a panel you are not looking at is a misfire waiting
to happen. */
<HStack gap={0.5} wrap="wrap">
<TabButton
<TabBar>
<Tab
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => chooseTab('products')}
/>
<TabButton
<Tab
label="Catalogue"
icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'}
onClick={() => chooseTab('catalogue')}
/>
<TabButton
<Tab
label="Stock requests"
icon={<ClipboardList size={14} />}
isActive={tab === 'requests'}
onClick={() => chooseTab('requests')}
badge={pending.length || undefined}
count={pending.length || undefined}
/>
<TabButton
<Tab
label="Stock"
icon={<Layers size={14} />}
isActive={tab === 'stock'}
onClick={() => chooseTab('stock')}
/>
</HStack>
</TabBar>
}
/>
@@ -443,17 +445,14 @@ function RequestsPanel({
</div>
{/* Search box */}
<div style={{ width: 240, minWidth: 180 }}>
<TextInput
<SearchInput
label="Search requests"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search product or branch…"
placeholder="Product or branch…"
width={240}
/>
</div>
</div>
{/* ── Bulk Action Bar ──────────────────────────────────────────────── */}
{selection.count > 0 ? (
@@ -554,7 +553,7 @@ function RequestsPanel({
{/* ── Table Card ──────────────────────────────────────────────────── */}
{isLoading ? (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading stock requests…
@@ -562,7 +561,7 @@ function RequestsPanel({
</VStack>
</Card>
) : filteredRows.length === 0 ? (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}>
<PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} />
<Text type="label" size="sm" weight="semibold">
@@ -578,7 +577,12 @@ function RequestsPanel({
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<Panel
title="Stock requests"
count={paged.total}
countLabel="stock requests"
footer={<TablePager paged={paged} label="stock requests" />}
>
<div className="table-scroll">
<table
style={{
@@ -747,8 +751,7 @@ function RequestsPanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="stock requests" />
</Card>
</Panel>
)}
{/* ── Guidance Note ──────────────────────────────────────────────── */}
@@ -961,22 +964,18 @@ function StockPanel() {
</select>
) : null}
<div style={{ width: 220, minWidth: 160 }}>
<TextInput
<SearchInput
label="Search stock"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
hasClear
width={220}
/>
</div>
</div>
</div>
{/* ── Table Card ──────────────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<Card padding={0}>
{statement.isLoading ? (
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
@@ -1220,63 +1219,7 @@ function BarAction({
);
}
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({
children,

View File

@@ -248,7 +248,7 @@ export function OnboardBranchPage() {
</button>
</div>
<Card padding={4} elevation="low">
<Card padding={4}>
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
@@ -263,7 +263,7 @@ export function OnboardBranchPage() {
</Text>
{mutation.data?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<Card padding={3} variant="transparent">
<StoreQrPanel
tenantid={Number(form.tenantid || defaultTenantId)}
locationid={mutation.data.locationid}
@@ -297,7 +297,7 @@ export function OnboardBranchPage() {
<h1 className="sr-only">Onboard Branch</h1>
{/* Section 1: Business Details & Identity */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{
@@ -368,7 +368,7 @@ export function OnboardBranchPage() {
</Card>
{/* Section 2: Location */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{
@@ -428,7 +428,7 @@ export function OnboardBranchPage() {
</Card>
{/* Section 3: Operating Hours & Delivery */}
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{

View File

@@ -22,12 +22,14 @@ import {
} from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { SectionHeader } from '@/components/SectionHeader';
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -108,26 +110,26 @@ export function ReportsPage() {
<PageHeader
title="Reports"
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
<TabBar>
<Tab
label="Revenue"
icon={<IndianRupee size={14} />}
isActive={tab === 'revenue'}
onClick={() => setTab('revenue')}
/>
<TabButton
<Tab
label="Sales"
icon={<BarChart3 size={14} />}
isActive={tab === 'sales'}
onClick={() => setTab('sales')}
/>
<TabButton
<Tab
label="Inventory"
icon={<Boxes size={14} />}
isActive={tab === 'inventory'}
onClick={() => setTab('inventory')}
/>
</HStack>
</TabBar>
}
/>
@@ -167,9 +169,14 @@ export function ReportsPage() {
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="By branch" />
<Card padding={0} elevation="low">
/* No SectionHeader above it any more — the panel carries its own
title and count, and the two stacked said "By branch" twice. */
<Panel
title="By branch"
count={paged.total}
countLabel="branches"
footer={<TablePager paged={paged} label="branches" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -206,9 +213,7 @@ export function ReportsPage() {
</tbody>
</table>
</div>
<TablePager paged={paged} label="branches" />
</Card>
</VStack>
</Panel>
) : null}
</VStack>
) : tab === 'sales' ? (
@@ -273,7 +278,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) {
return (
<VStack gap={1.5}>
<SectionHeader title="App versus counter" />
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={2} padding={3}>
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
<Bar
@@ -357,7 +362,7 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
return (
<VStack gap={1.5}>
<SectionHeader title="Counter takings by day" />
<Card padding={0} elevation="low">
<Card padding={0}>
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
<div style={{ minWidth: 400, height: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
@@ -460,7 +465,7 @@ function MoverList({
return (
<VStack gap={1.5}>
<SectionHeader title={title} />
<Card padding={0} elevation="low">
<Card padding={0}>
{rows.length === 0 ? (
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
@@ -500,43 +505,7 @@ function MoverList({
);
}
function TabButton({
label,
icon,
isActive,
onClick,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({ children }: { children: React.ReactNode; }) {
return (

View File

@@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SearchInput } from '@/components/SearchInput';
import { Tab, TabBar } from '@/components/TabBar';
import {
useDeliveries,
useOrders,
@@ -37,6 +38,7 @@ import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { isCounterSale } from '../branchStats';
@@ -44,6 +46,21 @@ import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter';
/**
* What the one search box matches, per view.
*
* The page used to draw three separate boxes, one inside each view, and the
* only thing that differed between them was this line — the value behind them
* was already a single `keyword`, filtering orders and deliveries through the
* query and counter bills client-side. So the boxes were three renderings of
* one control, and the placeholder is what survives of them.
*/
const SEARCH_HINT: Record<Tab, string> = {
orders: 'Order ID, customer…',
deliveries: 'Order ID, rider…',
counter: 'Invoice, till, cashier…',
};
/**
* The bill number a counter sale was imported under.
*
@@ -287,43 +304,58 @@ export function SalesPage() {
<VStack gap={3}>
<PageHeader
title="Sales"
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
icon={<ShoppingCart size={14} />}
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
isActive={tab !== 'counter'}
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
isTabsInline
actions={
<SearchInput
label="Search sales"
value={keyword}
onChange={setKeyword}
placeholder={SEARCH_HINT[tab]}
/>
}
tabs={
/* THREE views, not four.
{tab !== 'counter' ? (
<>
<TabButton
There used to be an "Online Orders" tab in front of these carrying
the combined count — 86 beside Orders 85 and Deliveries 1. It was
never a view of its own: it reported active whenever the page was
NOT on Counter sales, and clicking it from either of the two tabs
it contained did nothing at all. In the old flat pills that passed
unnoticed. In a segmented control it does not: a segmented control
means "one of these", and Online Orders and Orders were both drawn
selected at the same time, which is simply a broken control.
Nothing is lost by dropping it. Its count was the sum of the two
beside it, and the combined figure is already on the KPI row above.
Counter sales keeps the divider: Orders and Deliveries are two
halves of the app's own trade and the till is a different channel,
which is the grouping "Online Orders" was there to express. */
<TabBar aria-label="Sales views">
<Tab
label="Orders"
icon={<ShoppingCart size={14} />}
badge={allOrders.length || undefined}
count={allOrders.length || undefined}
isActive={tab === 'orders'}
onClick={() => setTab('orders')}
/>
<TabButton
<Tab
label="Deliveries"
icon={<Truck size={14} />}
badge={allDeliveries.length || undefined}
count={allDeliveries.length || undefined}
isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')}
/>
</>
) : null}
<TabButton
<Tab
label="Counter sales"
icon={<Receipt size={14} />}
badge={billRows.length || undefined}
count={billRows.length || undefined}
isActive={tab === 'counter'}
onClick={() => setTab('counter')}
isGroupStart
/>
</HStack>
</TabBar>
}
/>
@@ -367,9 +399,6 @@ export function SalesPage() {
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
}
/>
{/* Above the table, not below it and not in a drawer.
Assigning is something you do WHILE reading the day's orders —
@@ -451,9 +480,6 @@ export function SalesPage() {
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
}
/>
<DeliveriesTable
rows={deliveryRows}
@@ -514,11 +540,9 @@ export function SalesPage() {
</div>
<VStack gap={1.5}>
{/* No status strip here: a counter bill is rung and done, so it
has no lifecycle to filter by. The search keeps the row. */}
<HStack justify="end" wrap="wrap">
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
</HStack>
{/* No status strip and no search row: a counter bill is rung and
done, so it has no lifecycle to filter by, and the search is in
the page header with the tabs — one box for all three views. */}
<BillsTable
rows={billRows}
queryKey={queryKey}
@@ -580,35 +604,46 @@ function freshRow(
* in view, the filters decide what is in view, and the table shows it. Reading
* downward now follows that order.
*/
/**
* The status ladder, under the view tabs.
*
* The SECONDARY size of the same control the views use — no track, smaller
* type, a brand tint on the selected one. That subordination is the point: two
* strips of tabs at equal weight on one screen read as two equally important
* choices, when this one only narrows whatever the strip above it selected.
*
* It used to be a private `StatusTab` — a 30px chip with a 12px radius, a
* hairline when idle and a solid brand fill when selected — which was a fourth
* treatment of "tab" on a page that now has one.
*
* The wrapper it sat in is gone with it. It was an `HStack justify="between"`
* holding the tabs and the search; the search moved to the page header, so the
* row was a space-between with nothing on the right of it.
*/
function FilterRow({
tabs,
status,
counts,
onStatus,
search,
}: {
/** Which ladder to show — orders and deliveries do not share one. */
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
status: StatusKey;
counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void;
search: React.ReactNode;
}) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap">
<TabBar size="sm" aria-label="Filter by status">
{tabs.map((entry) => (
<StatusTab
<Tab
key={entry.key}
label={entry.label}
n={counts[entry.key]}
count={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</HStack>
{search}
</HStack>
</TabBar>
);
}
@@ -649,7 +684,12 @@ function OrdersTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Orders"
count={paged.total}
countLabel="orders"
footer={<TablePager paged={paged} label="orders" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -773,8 +813,7 @@ function OrdersTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="orders" />
</Card>
</Panel>
);
}
@@ -807,7 +846,12 @@ function DeliveriesTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Deliveries"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -948,8 +992,7 @@ function DeliveriesTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
</Panel>
);
}
@@ -980,7 +1023,12 @@ function BillsTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Counter sales"
count={paged.total}
countLabel="bills"
footer={<TablePager paged={paged} label="bills" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -1062,8 +1110,7 @@ function BillsTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="bills" />
</Card>
</Panel>
);
}
@@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string {
});
}
function Search({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (next: string) => void;
placeholder: string;
}) {
return (
<div style={{ width: 240 }}>
<TextInput
label="Search"
isLabelHidden
size="sm"
value={value}
onChange={onChange}
placeholder={placeholder}
hasClear
/>
</div>
);
}
/* The page-local search box lived here. It is `components/SearchInput`
now, in the header beside the tabs, and there is one of it. */
/** A status chip in the lifecycle's own colour. */
function Chip({ label, color }: { label: string | undefined; color: string }) {
@@ -1222,117 +1248,15 @@ function TotalsBar({
);
}
function StatusTab({
label,
n,
isActive,
onClick,
}: {
label: string;
n: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 30,
padding: '0 11px',
borderRadius: 12,
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
// Transparent when idle, not white. A row of white chips on a tinted
// page reads as six raised slabs; only the selected one should carry a
// fill, and the hairline is enough to keep the rest legible as chips.
background: isActive ? 'var(--color-brand)' : 'transparent',
color: isActive ? '#fff' : 'var(--color-ink-2)',
fontSize: 12.5,
fontWeight: 500,
cursor: 'pointer',
transition: 'background .2s, color .2s, border-color .2s',
}}
>
{label}
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontWeight: 700,
opacity: isActive ? 0.85 : 0.55,
}}
>
{n}
</span>
</button>
);
}
/* The page-local `StatusTab` lived here — it is the `sm` size of
`components/TabBar` now. */
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: isActive ? '#fff' : 'var(--color-ink-3)',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The page-local `TabButton` lived here. It is `components/TabBar` now —
one control for the six pages that had each grown their own. */
function Loading({ what }: { what: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading {what}…
@@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) {
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold">

View File

@@ -26,6 +26,7 @@ import { useCounterLabels, type CounterLabels } from '../counterLabels';
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -198,13 +199,14 @@ export function TerminalsPage() {
*
* A hairline and a radius, no fill. Every block here used to be a white
* `elevation="low"` card, which on a tinted page reads as a stack of raised
* slabs — and the console has been moving away from that everywhere else.
* Defined once so the edges cannot drift apart the next time one of these is
* edited.
* slabs — this page went flat first, and the rest of the console has since
* followed: the card tokens in `index.css` are now that treatment, applied
* everywhere. What is left here is the shared card minus the fill, so the
* page's own tint reads through.
*/
const panelStyle: React.CSSProperties = {
borderRadius: 14,
border: '1px solid var(--color-line)',
borderRadius: 'var(--card-radius)',
border: 'var(--card-border)',
};
/* ── Groups ──────────────────────────────────────────────────────────────── */
@@ -662,8 +664,12 @@ function FineTable({
</Card>
) : null}
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, overflow: 'hidden' }}>
<Panel
title="All terminals"
count={paged.total}
countLabel="terminals"
footer={<TablePager paged={paged} label="terminals" />}
>
<Table<FineRowData>
data={paged.rows}
columns={columns}
@@ -672,9 +678,7 @@ function FineTable({
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="terminals" />
</Card>
</Panel>
</VStack>
);
}

View File

@@ -4,12 +4,13 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import type { RiderRosterRow } from '@/api/deliveries';
import type { PosUser, StaffInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { SectionHeader } from '@/components/SectionHeader';
import {
usePosRoles,
@@ -23,6 +24,7 @@ import { branchLabel } from '../format';
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
import { RiderDrawer } from '../RiderDrawer';
import { isUnplaced } from '../staffPlacement';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -165,21 +167,21 @@ export function UsersPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
<TabBar>
<Tab
label="Store users"
icon={<UserCog size={14} />}
badge={staff.data?.length || undefined}
count={staff.data?.length || undefined}
isActive={group === 'store'}
onClick={() => {
setGroup('store');
setRoleFilter('');
}}
/>
<TabButton
<Tab
label="Terminal accounts"
icon={<Monitor size={14} />}
badge={tillRows.length || undefined}
count={tillRows.length || undefined}
isActive={group === 'till'}
onClick={() => {
setGroup('till');
@@ -191,17 +193,17 @@ export function UsersPage() {
and not a till — so listing them together would invite an edit
that moves someone between logins that do not exist for each
other. */}
<TabButton
<Tab
label="Riders"
icon={<Bike size={14} />}
badge={riderRows.length || undefined}
count={riderRows.length || undefined}
isActive={group === 'rider'}
onClick={() => {
setGroup('rider');
setRoleFilter('');
}}
/>
</HStack>
</TabBar>
}
/>
@@ -226,17 +228,13 @@ export function UsersPage() {
/>
</div>
) : null}
<div style={{ width: 220 }}>
<TextInput
<SearchInput
label="Search people"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Name, email, mobile…"
hasClear
width={220}
/>
</div>
</HStack>
}
/>
@@ -279,6 +277,9 @@ export function UsersPage() {
row={editingTill.row === 'new' ? null : editingTill.row}
tenantid={tenantid}
locationid={editingTill.locationid}
branchName={
branches.find((entry) => entry.locationid === editingTill.locationid)?.locationname ?? ''
}
roles={roles.data ?? []}
shifts={shifts.data ?? []}
onClose={() => setEditingTill(null)}
@@ -325,7 +326,7 @@ function RiderTable({
if (isLoading) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading riders…
@@ -337,7 +338,7 @@ function RiderTable({
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<Bike size={22} />
@@ -354,7 +355,12 @@ function RiderTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Riders"
count={rows.length}
countLabel="riders"
footer={<TablePager paged={paged} label="riders" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -411,8 +417,7 @@ function RiderTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="riders" />
</Card>
</Panel>
);
}
@@ -445,7 +450,12 @@ function StaffTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Store users"
count={rows.length}
countLabel="people"
footer={<TablePager paged={paged} label="people" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -504,8 +514,7 @@ function StaffTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="people" />
</Card>
</Panel>
);
}
@@ -534,7 +543,12 @@ function TillTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Terminal accounts"
count={rows.length}
countLabel="terminal accounts"
footer={<TablePager paged={paged} label="terminal accounts" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -598,8 +612,7 @@ function TillTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="terminal accounts" />
</Card>
</Panel>
);
}
@@ -664,68 +677,11 @@ function StatusChip({ status }: { status: string | undefined }) {
);
}
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: isActive ? '#fff' : 'var(--color-ink-3)',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Loading({ what }: { what: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading {what}…
@@ -737,7 +693,7 @@ function Loading({ what }: { what: string }) {
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold">

View File

@@ -24,7 +24,7 @@
flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--color-brand);
border-radius: 12px;
border-radius: var(--card-radius);
background: var(--color-brand-tint);
}
@@ -84,8 +84,8 @@
.size-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-line);
border-radius: 10px;
border: var(--card-border);
border-radius: var(--card-radius);
overflow: hidden;
}
.size-row {
@@ -139,9 +139,9 @@
.size-add {
padding: 13px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface-subtle);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg-subtle);
}
/* ── Health score ───────────────────────────────────────────────────────── */
@@ -155,7 +155,7 @@
align-items: baseline;
gap: 2px;
padding: 5px 11px;
border-radius: 10px;
border-radius: var(--card-radius);
font: 700 22px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
}
@@ -196,7 +196,7 @@
gap: 8px;
padding: 9px 12px;
border: 1px solid #f2d9a8;
border-radius: 10px;
border-radius: var(--card-radius);
background: #fdf6e8;
color: #8a5a00;
font: 500 12.5px/1.4 var(--font-sans);
@@ -204,8 +204,8 @@
.health-allergens svg { flex: none; }
.health-facts {
border: 1px solid var(--color-line);
border-radius: 10px;
border: var(--card-border);
border-radius: var(--card-radius);
overflow: hidden;
}
.health-facts > div {
@@ -259,8 +259,8 @@
.stop-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-line);
border-radius: 10px;
border: var(--card-border);
border-radius: var(--card-radius);
overflow: hidden;
}
.stop-row {
@@ -331,7 +331,7 @@
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid #f2d9a8;
border-radius: 10px;
border-radius: var(--card-radius);
background: #fdf6e8;
color: #8a5a00;
}

View File

@@ -2,23 +2,40 @@
Curated palette with deep purple/indigo accents (#662582), glassmorphic elements,
live telemetry cards and interactive map timelines. */
/* ── Dispatch's palette is now the console's ────────────────────────────────
These fourteen names are kept, and every one of them is an ALIAS. About
fifty rules in this file reference them, so pointing the names at the shared
tokens restyles the whole page at once — and, more to the point, makes it
impossible for Dispatch to drift away from the rest of the console again.
It had drifted. This file arrived as a port of another product and brought
that product's palette with it: `#e2e8f0` for a hairline where the console
uses `#e0e4ea`, `#1e293b` for body text where the console uses `#0f172a`,
`#f8fafc` for a sunken fill against the console's `#fafbfc`. Each is a
shade off the real token — close enough to look like a rendering
inconsistency rather than a decision, and far enough that a Dispatch card
beside a Console card were visibly two different whites.
The two shadow steps are now the card's, which is to say nothing at all.
`--dispatch-shadow-lg` is referenced by no rule in this file and is gone
rather than aliased. */
:root {
--dispatch-bg: #ffffff;
--dispatch-bg-sub: #f8fafc;
--dispatch-card-bg: #ffffff;
--dispatch-border: #e2e8f0;
--dispatch-border-active: #662582;
--dispatch-text: #1e293b;
--dispatch-text-muted: #64748b;
--dispatch-accent: #662582;
--dispatch-bg: var(--color-surface);
--dispatch-bg-sub: var(--color-surface-subtle);
--dispatch-card-bg: var(--card-bg);
--dispatch-border: var(--color-line);
--dispatch-border-active: var(--color-brand);
--dispatch-text: var(--color-ink-1);
--dispatch-text-muted: var(--color-ink-3);
--dispatch-accent: var(--color-brand);
--dispatch-accent-light: #9255ab;
--dispatch-accent-soft: rgba(102, 37, 130, 0.08);
--dispatch-success: #10b981;
--dispatch-warning: #f59e0b;
--dispatch-error: #ef4444;
--dispatch-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06);
--dispatch-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08);
--dispatch-shadow-lg: 0 12px 30px rgba(102, 37, 130, 0.12);
--dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent);
--dispatch-success: var(--color-success, #1f9d55);
--dispatch-warning: var(--color-warning, #b7860b);
--dispatch-error: var(--color-error, #d64545);
--dispatch-shadow-sm: var(--card-shadow);
--dispatch-shadow-md: var(--card-shadow);
}
.dispatch-container {
@@ -30,221 +47,28 @@
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── */
.dispatch-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 14px;
min-height: 44px;
background: var(--dispatch-card-bg);
border: 1px solid var(--dispatch-border);
border-radius: 10px;
box-shadow: var(--dispatch-shadow-sm);
flex-wrap: wrap;
}
/* ── Top Operations Header (xpressconsole #hdr) ─────────────────────────────
.logo-section {
display: flex;
align-items: center;
gap: 10px;
}
GONE, because it was never rendered.
.logo-badge {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #662582, #9255ab);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 11.5px;
color: #ffffff;
box-shadow: 0 2px 6px rgba(102, 37, 130, 0.25);
}
This file arrived as a port of another product, and it brought that
product's whole operations header with it: `.dispatch-header`, a logo lockup,
a city picker, a demo/live toggle, a live-GPS chip, a running clock, and
`.date-chip` — a pill with prev/next arrows for stepping through days.
.logo-name {
font-size: 15px;
font-weight: 800;
color: var(--dispatch-text);
letter-spacing: -0.02em;
}
Not one of those class names appears in any markup in this repository. The
Dispatch page reads the date from `useDateScope()`, which is the range
picker in the app's own top bar; two comments in `DispatchPage.tsx` already
tell the reader to "change the date range in the top bar". So the header was
215 lines of CSS for a component that does not exist, and `.date-chip` was a
date filter nobody can see.
.logo-name em {
color: var(--dispatch-accent);
font-style: normal;
margin-left: 2px;
}
If the ported header is wanted later it is in the git history, but it would
need rebuilding against this console's own controls rather than restoring:
the date range, the branch scope and the account menu are all in the app
shell, and a second set of them on one page is the drift this pass has been
removing everywhere else. */
.logo-city-wrap {
position: relative;
display: inline-block;
}
.logo-city {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
background: var(--dispatch-accent-soft);
border: 1px solid rgba(102, 37, 130, 0.25);
color: var(--dispatch-accent);
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s ease;
}
.logo-city:hover {
background: rgba(102, 37, 130, 0.14);
border-color: rgba(102, 37, 130, 0.45);
}
.demo-toggle-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(102, 37, 130, 0.06));
border: 1px solid rgba(16, 185, 129, 0.35);
color: #065f46;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.demo-toggle-chip:hover {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(102, 37, 130, 0.12));
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
}
.toggle-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.toggle-dot.is-demo {
background: var(--dispatch-success);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
animation: pulse-ring 1.8s infinite;
}
.toggle-dot.is-live {
background: var(--dispatch-warning);
}
.hdr-stats {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hdr-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
background: var(--dispatch-bg-sub);
border: 1px solid var(--dispatch-border);
color: var(--dispatch-text);
}
.live-gps-chip {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.3);
color: #065f46;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dispatch-success);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse-ring 1.8s infinite;
}
.strat-stat-orders {
background: var(--dispatch-accent-soft);
border-color: rgba(102, 37, 130, 0.2);
color: var(--dispatch-accent);
}
.strat-stat-profit {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.25);
color: #059669;
}
/* Date Chip */
.date-chip {
display: inline-flex;
align-items: center;
padding: 1px 3px;
background: #ffffff;
border: 1px solid var(--dispatch-border);
border-radius: 999px;
}
.date-nav-btn {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: none;
border: 0;
color: var(--dispatch-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.date-nav-btn:hover {
background: var(--dispatch-bg-sub);
color: var(--dispatch-accent);
}
.date-chip-main {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 5px;
font-size: 11px;
font-weight: 700;
}
.today-badge {
font-size: 8.5px;
font-weight: 800;
padding: 1px 5px;
border-radius: 999px;
background: var(--dispatch-accent);
color: #ffffff;
letter-spacing: 0.04em;
}
.hdr-clock {
font-family: 'JetBrains Mono', monospace, monospace;
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
background: #0f172a;
color: #38bdf8;
border-radius: 6px;
letter-spacing: 0.06em;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
}
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
.strat-row {
@@ -256,7 +80,7 @@
min-height: 42px;
background: var(--dispatch-card-bg);
border: 1px solid var(--dispatch-border);
border-radius: 10px;
border-radius: var(--card-radius);
box-shadow: var(--dispatch-shadow-sm);
flex-wrap: wrap;
}
@@ -454,23 +278,21 @@
.dispatch-container .rcard,
.rcard {
background: #ffffff;
background: var(--card-bg);
border: 1px solid var(--dispatch-border);
border-radius: 14px;
border-radius: var(--card-radius);
padding: 14px 16px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
position: relative;
text-align: left;
user-select: none;
}
/* Hover is the line, not a lift -- see the note on `.pcard` in index.css. */
.dispatch-container .rcard:hover,
.rcard:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
border-color: rgba(102, 37, 130, 0.35);
border-color: var(--dispatch-accent);
}
.dispatch-container .rcard.is-focused,
@@ -645,10 +467,10 @@
.dispatch-card-elevated,
.dispatch-focus-card,
.dispatch-map-card {
background: #ffffff !important;
border: 1px solid var(--dispatch-border) !important;
border-radius: 16px !important;
box-shadow: var(--dispatch-shadow-md) !important;
background: var(--card-bg) !important;
border: var(--card-border) !important;
border-radius: var(--card-radius) !important;
box-shadow: var(--card-shadow) !important;
overflow: hidden;
}
@@ -707,7 +529,7 @@
padding: 10px 14px;
background: var(--dispatch-bg-sub);
border: 1px solid var(--dispatch-border);
border-radius: 12px;
border-radius: var(--card-radius);
}
.rl-item {
@@ -774,19 +596,16 @@
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
border-radius: var(--card-radius);
border: 1px solid var(--dispatch-border);
background: #ffffff;
background: var(--card-bg);
cursor: pointer;
transition: all 0.18s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: border-color 0.18s ease, background 0.18s ease;
}
.timeline-step-card:hover,
.stop-timeline-card:hover {
border-color: var(--dispatch-accent);
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(102, 37, 130, 0.12);
}
.timeline-step-card.is-current {
@@ -981,7 +800,7 @@
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid rgba(102, 37, 130, 0.25);
border-radius: 12px;
border-radius: var(--card-radius);
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
}

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
@@ -9,7 +8,7 @@ import { errorMessage } from '@/api/client';
import { stockApi } from '@/api/stock';
import type { Product } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
import { queryKeys } from '@/queries/keys';
/**
@@ -178,13 +177,7 @@ export function BulkRequestDialog({
))}
</div>
{problem ? (
<Card padding={2} variant="transparent">
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
</Card>
) : null}
{problem ? <Alert>{problem}</Alert> : null}
</VStack>
</Drawer>

View File

@@ -11,7 +11,7 @@ import { queryKeys } from '@/queries/keys';
import { money } from '@/features/store-admin/format';
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
import { Thumb } from './ui';
const QUICK = [10, 25, 50, 100] as const;
@@ -141,9 +141,7 @@ export function RequestDialog({
</Text>
{ask.isError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
That did not go through. Check your connection and try again.
</Text>
<Alert>That did not go through. Check your connection and try again.</Alert>
) : null}
</Drawer>

View File

@@ -2,17 +2,19 @@ import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { useCustomers, useOrders } from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerCard, Row as DrawerRow, Section } from '@/features/store-admin/drawerKit';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -126,17 +128,13 @@ export function StoreCustomersPage() {
<PageHeader
title="Customers"
actions={
<div style={{ width: 230 }}>
<TextInput
<SearchInput
label="Search customers"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Name, phone, address…"
hasClear
width={240}
/>
</div>
}
/>
@@ -185,7 +183,11 @@ export function StoreCustomersPage() {
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
over their whole history
</Text>
<Card padding={0} elevation="low">
<Panel
count={paged.total}
countLabel="customers"
footer={<TablePager paged={paged} label="customers" />}
>
<div className="table-scroll">
<table
style={{
@@ -221,8 +223,7 @@ export function StoreCustomersPage() {
</tbody>
</table>
</div>
<TablePager paged={paged} label="customers" />
</Card>
</Panel>
</VStack>
)}
@@ -364,60 +365,47 @@ function CustomerDrawer({
width={420}
onClose={onClose}
>
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Line
icon={<ShoppingBag size={13} />}
{/* Built from the drawer kit, not from Astryx cards and a `Line` of its
own. The local version set its label column to a fixed 74px and its
text one size down, so this drawer's rows sat at a different height
and a different weight from every other drawer's — and an address long
enough to wrap pushed its value out of the panel. */}
<Section title="How to reach them">
<DrawerCard>
<DrawerRow label="Phone" value={customer.contactno || '—'} icon={<Phone size={14} />} />
<DrawerRow label="Email" value={customer.email || '—'} icon={<Mail size={14} />} />
{/* Stacked: an address is prose, and right-aligning six comma-joined
parts against a 74px label is how it ended up unreadable. */}
<DrawerRow label="Address" value={address || '—'} icon={<MapPin size={14} />} isStacked />
</DrawerCard>
</Section>
<Section title="What they have bought">
<DrawerCard>
<DrawerRow
label="Orders"
value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`}
icon={<ShoppingBag size={14} />}
{...(orders === 0 ? { tone: 'muted' as const } : {})}
/>
{/* 97% of customers have coordinates. Shown as a reference rather
than a map: this is a drawer, and a rider's app is where a pin
actually gets used. */}
<Line
icon={<MapPin size={13} />}
<DrawerRow
label="Map ref"
value={
customer.latitude && customer.longitude
? `${customer.latitude}, ${customer.longitude}`
: 'Not placed'
}
icon={<MapPin size={14} />}
{...(customer.latitude && customer.longitude ? {} : { tone: 'muted' as const })}
/>
</VStack>
</Card>
</VStack>
</DrawerCard>
</Section>
</Drawer>
);
}
function Line({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<HStack gap={1.5} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }}>{icon}</span>
<Text type="body" size="sm" color="secondary" style={{ width: 74, flex: 'none' }}>
{label}
</Text>
<Text type="body" size="sm" style={{ minWidth: 0 }}>
{value}
</Text>
</HStack>
);
}
function Initials({ name }: { name: string }) {
const initials =
@@ -451,7 +439,7 @@ function Initials({ name }: { name: string }) {
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}

View File

@@ -5,12 +5,13 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { stockApi } from '@/api/stock';
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { queryKeys } from '@/queries/keys';
import {
useAppAisles,
@@ -34,7 +35,8 @@ import {
canAskAgain, requestState,
stockHealth,
} from '../requestState';
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
import { Chip, Td, Th, Thumb } from '../ui';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -155,41 +157,37 @@ export function StoreProductsPage() {
title="Products"
actions={
tab === 'requests' ? null : (
<div style={{ width: 230 }}>
<TextInput
<SearchInput
label="Search products"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
hasClear
width={240}
/>
</div>
)
}
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
<TabBar>
<Tab
label="Catalogue"
icon={<PackageSearch size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
<TabButton
<Tab
label="My stock"
icon={<Boxes size={14} />}
isActive={tab === 'stock'}
onClick={() => setTab('stock')}
/>
<TabButton
<Tab
label="My requests"
icon={<ClipboardList size={14} />}
isActive={tab === 'requests'}
onClick={() => setTab('requests')}
badge={waiting || undefined}
count={waiting || undefined}
/>
</HStack>
</TabBar>
}
/>
@@ -211,7 +209,16 @@ export function StoreProductsPage() {
<RequestsPanel rows={requestRows} isLoading={requests.isLoading} />
)}
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
{open ? (
/* The row shows a Stock request chip; the drawer opened from it shows
the same one, off the same map — see `request` on ProductDrawer. */
<ProductDrawer
product={open}
canManage={false}
{...(latest.get(open.productid) ? { request: latest.get(open.productid) } : {})}
onClose={() => setOpen(null)}
/>
) : null}
{askingMany && askingMany.length > 0 && tenantid && locationid ? (
<BulkRequestDialog
@@ -349,7 +356,12 @@ function CataloguePanel({
</div>
) : null}
<Card padding={0} elevation="low">
<Panel
title="Catalogue"
count={paged.total}
countLabel="products"
footer={<TablePager paged={paged} label="products" />}
>
<div className="table-scroll">
<table
style={{
@@ -398,8 +410,7 @@ function CataloguePanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="products" />
</Card>
</Panel>
</VStack>
)}
</VStack>
@@ -482,8 +493,14 @@ function CatalogueRow({
</HStack>
</Td>
<Td isMuted>{unit || '—'}</Td>
<Td isStrong>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>
{/* An em dash for an unpriced product, not "₹0". Zero is a price a till
would ring up; no price at all is why it cannot. The merchant's own
products table and this product's drawer both say so — this cell was
the one printing a figure that does not exist. */}
<Td isStrong={effectivePrice(product) > 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{effectivePrice(product) > 0 ? money(effectivePrice(product)) : '—'}
</span>
</Td>
<Td isMuted={stock === 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
@@ -550,7 +567,12 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
Today’s ledger. <strong>Opening</strong> is what was on the shelf before today — stock
received today counts under <strong>In</strong> and becomes tomorrow’s opening.
</Text>
<Card padding={0} elevation="low">
<Panel
title="My stock"
count={paged.total}
countLabel="stock rows"
footer={<TablePager paged={paged} label="stock rows" />}
>
<div className="table-scroll">
<table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
@@ -622,8 +644,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
</tbody>
</table>
</div>
<TablePager paged={paged} label="stock rows" />
</Card>
</Panel>
</VStack>
);
}
@@ -674,7 +695,12 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<Panel
title="My requests"
count={paged.total}
countLabel="requests"
footer={<TablePager paged={paged} label="requests" />}
>
<div className="table-scroll">
<table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
@@ -743,8 +769,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
</tbody>
</table>
</div>
<TablePager paged={paged} label="requests" />
</Card>
</Panel>
</VStack>
);
}
@@ -760,7 +785,7 @@ function stamp(value: string | undefined): string {
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}

View File

@@ -22,6 +22,7 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -163,7 +164,7 @@ export function StoreStaffPage() {
A suspended cashier is refused by the till, and now that these rows
are asked for rather than hidden, the page can say so. */}
{suspended > 0 ? (
<Card padding={0} elevation="low">
<Card padding={0}>
<HStack gap={1.5} padding={2} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
<TriangleAlert size={16} />
@@ -199,7 +200,11 @@ export function StoreStaffPage() {
? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet`
: ' · no supervisor at this outlet'}
</Text>
<Card padding={0} elevation="low">
<Panel
count={paged.total}
countLabel="people"
footer={<TablePager paged={paged} label="people" />}
>
<div className="table-scroll">
<table
style={{
@@ -284,8 +289,7 @@ export function StoreStaffPage() {
</tbody>
</table>
</div>
<TablePager paged={paged} label="people" />
</Card>
</Panel>
</VStack>
)}
@@ -425,7 +429,7 @@ function AccessChip({ person }: { person: PosUser }) {
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}

View File

@@ -11,64 +11,8 @@ import { ImageOff } from 'lucide-react';
* merchant pages already went through.
*/
/** A page tab, as used in the Store Admin's PageHeader tab row. */
export function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The sixth and last copy of `TabButton` lived here, exported for the Store
user pages. Every one of them now imports `components/TabBar`. */
/** A header action drawn like the tabs, so the two rows read as one bar. */
export function BarAction({

View File

@@ -5,7 +5,6 @@ import {
CircleDot,
Circle,
Download,
FileSpreadsheet,
Info,
PackageCheck,
} from 'lucide-react';
@@ -79,7 +78,28 @@ export function UploadDetailDrawer({
return (
<Drawer
title="Upload details"
/* The FILE names this drawer, not the word "details".
It used to be titled "Upload details" with the header otherwise empty,
and then re-stated the filename, the status chip and the uploader in a
block of its own at the top of the body — a second header, in a
different type scale, three lines below the real one. Every other
drawer in the console names its subject in the bar; this one now does
too, and the block is gone. */
title={receipt.filename || 'Spreadsheet'}
subtitle={`${totals.rows} row${totals.rows === 1 ? '' : 's'}${
totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''
}`}
meta={
<>
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
<span className="drawer-meta-text">
{[showTenant ? receipt.tenantname : null, receipt.locationname, receipt.uploadedname]
.filter(Boolean)
.join(' · ')}
</span>
</>
}
width={540}
onClose={onClose}
isFooterFilled={products.length === 0}
@@ -109,33 +129,6 @@ export function UploadDetailDrawer({
</>
}
>
{/* ── The file ──────────────────────────────────────────────────────── */}
<div className="up-head">
<span className="up-head-icon">
<FileSpreadsheet size={22} />
</span>
<div className="up-head-body">
<span className="up-head-name">{receipt.filename || 'Spreadsheet'}</span>
<span className="up-head-sub">
{totals.rows} row{totals.rows === 1 ? '' : 's'}
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
</span>
<div className="up-head-row">
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
</div>
<span className="up-head-meta">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
when(receipt.created),
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
</div>
{/* ── Summary ───────────────────────────────────────────────────────── */}
<Metrics>
<Metric label="Total rows" value={totals.rows || '—'} />
@@ -251,6 +244,11 @@ export function UploadDetailDrawer({
<Section title="File details">
<DrawerCard>
<Row label="File name" value={receipt.filename || '—'} />
{/* The table's Uploaded on column. It used to be in the block of
header this drawer drew for itself, and it is the one fact from
that block the bar has no room for — the meta line there carries
who and where, and a fourth item would push the title out. */}
<Row label="Uploaded on" value={when(receipt.created)} />
<Row label="Size" value={fileSize(file?.size_bytes)} />
<Row label="Rows" value={totals.rows || '—'} />
<Row label="Added" value={totals.added} />

View File

@@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { errorMessage } from '@/api/client';
import {
fetchBatch,
@@ -53,6 +54,7 @@ import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { UploadDetailDrawer } from './UploadDetailDrawer';
@@ -350,13 +352,12 @@ export function UploadsPanel({
<div className="up-toolbar-spacer" />
<input
className="up-search"
type="search"
<SearchInput
label="Search by file name"
value={search}
placeholder="Search by file name…"
aria-label="Search by file name"
onChange={(event) => setSearch(event.target.value)}
onChange={setSearch}
placeholder="File name…"
width={230}
/>
{/* "Check again" stood here. It is gone because the page reads the
moving rows by itself now — every fifteen seconds while something is
@@ -404,7 +405,12 @@ export function UploadsPanel({
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<Panel
title="Uploads"
count={paged.total}
countLabel="uploads"
footer={<TablePager paged={paged} label="uploads" />}
>
<div className="table-scroll">
<table className="up-table">
<thead>
@@ -484,8 +490,7 @@ export function UploadsPanel({
</tbody>
</table>
</div>
<TablePager paged={paged} label="uploads" />
</Card>
</Panel>
)}
{open ? (

View File

@@ -53,19 +53,29 @@
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
.up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.up-search, .up-date {
/* `.up-search` was here. The toolbar uses `components/SearchInput` now, like
every other search in the console.
The date field stays, and takes the search box's measurements so the two sit
together: the same 38px, the same corner and the same hairline. It was 11px
where the search is `--card-radius-sm`, which beside each other read as two
different controls. */
.up-date {
height: 38px;
padding: 0 12px;
border: 1px solid var(--color-line);
border-radius: 11px;
background: var(--color-surface);
font: 400 13.5px/1 var(--font-sans);
color: var(--color-ink-1);
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: var(--card-shadow);
font: 400 13px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
color: var(--color-ink-2);
}
.up-date:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
.up-search { min-width: 220px; }
.up-date { font-variant-numeric: tabular-nums; color: var(--color-ink-2); }
.up-search:focus, .up-date:focus { outline: 2px solid var(--color-brand); outline-offset: -1px; }
.up-search::placeholder { color: var(--color-ink-4); }
/* ── Table ──────────────────────────────────────────────────────────────── */
@@ -114,32 +124,10 @@
white-space: nowrap;
}
/* ── Drawer: the file header ────────────────────────────────────────────── */
/* This is what makes it an Upload Details workspace rather than the generic
drawer: the file itself is the subject, so it gets an icon and its own block
above everything. */
.up-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; }
.up-head-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
flex: none;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.up-head-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.up-head-name {
font: 600 17px/1.3 var(--font-sans);
color: var(--color-ink-1);
overflow-wrap: anywhere;
}
.up-head-sub { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
.up-head-meta { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-4); }
.up-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* The drawer's file header used to live here — an icon, the filename and the
status chip, drawn as a second header inside the body because the real one
only said "Upload details". The bar names the file now, so these rules have
no markup left to style. */
/* ── Drawer: the journey ────────────────────────────────────────────────── */
@@ -238,7 +226,7 @@
has to go and fix a spreadsheet. */
.up-reject {
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent);
border-radius: 12px;
border-radius: var(--card-radius);
background: color-mix(in oklab, var(--color-error) 5%, transparent);
overflow: hidden;
}

View File

@@ -50,6 +50,56 @@
--color-surface-sunken: #f2f4f7;
--color-line: #e0e4ea;
/* ── The card ───────────────────────────────────────────────────────────
ONE card for the whole console: a white fill, a hairline, a 12px corner
and NO SHADOW. Separation comes from the line alone, so the page reads as
a single plane with regions marked on it rather than as a tray of raised
slabs — which is what it had become. A table inside a flat card reads as
the table's container; inside a floating one it reads as a tile that
happens to have a grid in it.
── Why these are tokens and not four numbers typed per surface ─────────
The app had grown FIVE card radii — 10, 12, 14, 16 and 18 — plus three
unrelated shadow recipes, across surfaces that are all the same object:
`.kpi`, `.panel`, `.pcard`, `.rcard`, `.ob-frame`, `.sp-card`,
`.dispatch-map-card` and a dozen more. Each was right on the day it was
written and none of them agreed. Anything card-shaped now reads these.
`--card-radius` is kept in step with Astryx's `--radius-container` BY
HAND rather than aliased to it, because the generated theme sheet is
scoped to `[data-astryx-theme]` and these tokens have to resolve outside
it too — on the login, and inside a drawer. The two numbers are the same
number; `src/theme/nearle.ts` is where the decision is recorded.
── The edge ────────────────────────────────────────────────────────────
A hairline PLUS a 1px micro-lift at 4%. Not a blur. A wide soft shadow
makes a panel float, and a page of five floating panels is a tray of
slabs; one crisp pixel is enough to separate the panel from the canvas
and to make the hairline read as an edge rather than as a drawn line.
A bigger shadow does still exist in the app, deliberately, and belongs to
things that genuinely float ABOVE the page: the drawer panel, a map
popup, a chart tooltip. A card is not one of those.
── The edge is only half of it ─────────────────────────────────────────
These tokens paint a box. What makes the box read as designed rather than
as a default is the ANATOMY inside it — a header band that names the
panel and counts its rows, a body, a footer band that owns the pager.
That lives in `components/Panel.tsx`, and a card that is just these
tokens wrapped round a table is the bare rectangle this replaced. */
--card-radius: 14px;
--card-radius-sm: 10px; /* a card nested inside another card */
--card-border: 1px solid var(--color-line);
--card-bg: var(--color-surface);
--card-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
/* The fill for a card that is deliberately quieter than the page — an
explanatory block, a disabled region. Still flat, still hairlined. */
--card-bg-subtle: var(--color-surface-subtle);
/* Neutral ramp — the ink scale every surface and label reads from. */
--color-slate-50: #f7f7f7;
--color-slate-100: #eff1f4;
@@ -133,6 +183,111 @@ body {
}
}
/* ── Scrolling ──────────────────────────────────────────────────────────────
One scrolling behaviour for the whole console — the page, every panel that
scrolls inside it, and every side drawer.
── What `scroll-behavior: smooth` does and does not do ─────────────────────
It animates PROGRAMMATIC scrolls: `scrollIntoView`, `scrollTo`, a jump to a
`#hash`, and the browser's own move when focus lands on something off
screen. It does NOT touch the mouse wheel, a trackpad or a touch drag —
those are the operating system's and are already smooth. So this is the
difference between a page that JUMPS when you tab into a field below the
fold, or when the uploads drawer jumps you to its rejection panel, and one
that travels there.
Declared here rather than per component so nothing is left out: a container
that scrolls and is not in the list below is the one that will feel wrong.
Accessibility is already handled — the reduced-motion block above resets
`scroll-behavior` to `auto !important` for everything, so a person who has
asked for less motion gets the instant jump back.
── The two properties on `html` that are not about smoothness ─────────────
`scroll-padding-top` is the sticky header's height. Without it, anything
scrolled to by the browser lands UNDER the 56px bar — the field you tabbed
to is behind the nav, which reads as the page having scrolled to the wrong
place.
`scrollbar-gutter: stable` reserves the scrollbar's width whether or not
there is one. It stops the whole layout shifting sideways when a short page
becomes a long one, when a filter empties a table, and — the reason it is
here — when a drawer opens and locks the page behind it. */
html {
scroll-behavior: smooth;
/* 56px bar + its 1px rule + 8px so the target is not flush against it. */
scroll-padding-top: 65px;
scrollbar-gutter: stable;
}
/* Every panel that scrolls inside the page.
`:where()` keeps the specificity at zero, so any of these can still set its
own behaviour without an `!important` fight. */
:where(
.table-scroll, /* every table, sideways */
.rail-scroll, /* the catalogue's category rail */
.bulkreq-list, /* the bulk stock request */
.district-list, /* choosing a partner's district */
.kpi-strip, /* the console's KPI row, sideways */
.chart2, /* the console's bar chart, sideways */
.rail-filter-chips, /* dispatch's filter chips, sideways */
.stops-timeline-carousel
) {
scroll-behavior: smooth;
}
/* The side drawers are not in that list. `.drawer-body` sets its own, beside
the `overscroll-behavior` it already had — the drawer system owns its
scrolling in `drawer.css`, and splitting half of it across two files is how
one of the two gets changed alone. */
/* ── One scrollbar ──────────────────────────────────────────────────────────
Both properties are inherited, so declaring them on `html` reaches every
scroll container in the app — including the ones inside drawers, which is
how a drawer's scrollbar stops being a 15px platform-grey slab beside a
520px panel.
The webkit block below says the same thing again for Chrome, Edge and
Safari, where it wins over the standard properties. It is not redundant:
only the pseudo-elements can round the thumb and give it a hover state, and
a bar that responds to the pointer is most of what reads as "considered"
rather than "default". */
html {
scrollbar-width: thin;
scrollbar-color: var(--color-line) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--color-line);
/* Drawn inside a transparent border, which is the only way to inset a
webkit thumb — it leaves the bar reading as a 6px rail rather than a 10px
slab, while keeping a 10px target for the pointer. */
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-ink-4);
background-clip: padding-box;
}
/* Where the two bars meet. Left at the platform default it is a small grey
square in the corner of every scrolling panel. */
::-webkit-scrollbar-corner {
background: transparent;
}
/* Numeric columns line up only with tabular figures. Used by every KPI value
and every money/count cell in a table. */
.tabular {
@@ -681,9 +836,9 @@ main {
}
.catalogue-rail {
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
position: sticky;
top: 72px;
/* The clip lives here, on the element that owns the radius. */
@@ -694,10 +849,10 @@ main {
padding: 6px;
max-height: calc(100dvh - 98px);
overflow-y: auto;
/* A thin, quiet scrollbar. The default is wide enough to read as a second
column beside a 216px rail. */
scrollbar-width: thin;
scrollbar-color: var(--color-line) transparent;
/* The thin, quiet scrollbar this rail used to declare for itself is now the
whole console's — both scrollbar properties are inherited, so the pair on
`html` reaches here. This rail was the only place that had been fixed, and
it is why every other panel's bar looked like a different product's. */
}
.rail-scroll::-webkit-scrollbar {
@@ -810,16 +965,18 @@ main {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
overflow: hidden;
transition: border-color .18s, box-shadow .18s;
transition: border-color .18s;
}
/* Hover is a stronger LINE, not a lift. In a flat system a card that rises on
hover is the only thing on the page with a z-axis, which reads as a bug
rather than as feedback. */
.pcard:hover {
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent);
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
border-color: var(--color-brand);
}
.pcard-media {
@@ -1278,9 +1435,9 @@ main {
width: 100%;
text-align: left;
padding: 20px;
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
cursor: pointer;
transition: border-color .18s, background .18s, transform .18s;
}
@@ -1300,8 +1457,8 @@ main {
read. */
.ob-drop {
border: 1.5px dashed var(--color-slate-300);
border-radius: 14px;
background: var(--color-surface-subtle);
border-radius: var(--card-radius);
background: var(--card-bg-subtle);
padding: 32px 20px;
text-align: center;
transition: border-color .18s, background .18s;
@@ -1359,9 +1516,9 @@ main {
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: var(--color-surface-subtle);
border: 1px solid var(--color-line);
border-radius: var(--card-radius-sm);
background: var(--card-bg-subtle);
border: var(--card-border);
}
.ob-flow-icon {
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
@@ -1403,9 +1560,9 @@ main {
body (the step's own fields) and foot (Back / Continue). The bands are
separated by rules rather than gaps so the card reads as one object. */
.ob-frame {
border: 1px solid var(--color-line);
border-radius: 18px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
overflow: hidden;
}
@@ -1465,7 +1622,7 @@ main {
.ob-returnbar {
padding: 12px 16px;
border: 1px solid var(--color-brand);
border-radius: 14px;
border-radius: var(--card-radius);
background: var(--color-brand-tint);
}
@@ -1499,9 +1656,9 @@ main {
gap: 12px;
align-items: start;
padding: 14px 16px;
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
}
/* Text renders inline, so the title and its detail ran together on one line
@@ -1540,7 +1697,7 @@ main {
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 12px;
border-radius: var(--card-radius);
border: 1px solid var(--color-warning-line, #e8d9a8);
background: var(--color-warning-tint, #fdf7e6);
color: var(--color-ink-1);
@@ -1593,9 +1750,9 @@ main {
something is picked, then takes the brand tint and shows its actions. */
.bulkbar {
padding: 10px 14px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
transition: background .15s ease, border-color .15s ease;
}
@@ -1685,9 +1842,9 @@ main {
gap: 12px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
}
.qty-step {
@@ -1737,9 +1894,9 @@ main {
gap: 12px;
align-items: start;
padding: 14px 16px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg-subtle);
}
.ob-guide[data-tone='warn'] {
@@ -1814,7 +1971,10 @@ main {
laptop and sit under the panel. The brief is explicit that the panel is not
to be touched, and a background is not worth the risk of moving it. */
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff);
border-radius: 16px;
/* The card radius, even though this is a tinted REGION rather than a card:
it holds cards, and a 16px region around 12px cards reads as a rounding
error rather than as a nesting. */
border-radius: var(--card-radius);
padding: 18px;
}
@media (max-width: 720px) { .sp-page { padding: 12px; } }
@@ -1827,10 +1987,9 @@ main {
gap: 16px;
align-items: center;
padding: 18px 20px;
border: 1px solid var(--color-line);
border-radius: 16px;
background: var(--color-surface);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
}
/* The store, not the person. A shop mark rather than an avatar — this record
@@ -1946,10 +2105,9 @@ main {
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
.sp-card {
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
overflow: hidden;
}
.sp-card[data-span='full'] { grid-column: 1 / -1; }
@@ -2078,9 +2236,9 @@ main {
.district-list {
max-height: 208px;
overflow-y: auto;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
}
.district-row {

View File

@@ -1,7 +1,7 @@
/*
* @generated by `astryx theme build` — do not edit manually.
* Source: src/theme/nearle.ts
* Command: astryx theme build src/theme/nearle.ts
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.4.5
* Core: @astryxdesign/core@0.4.5
*/
@@ -88,9 +88,9 @@
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--radius-element: 12px;
--radius-container: 16px;
--radius-page: 16px;
--radius-chat: 16px;
--radius-container: 14px;
--radius-page: 14px;
--radius-chat: 14px;
--text-body-size: 14px;
--text-label-size: 14px;
--text-large-size: 17px;

View File

@@ -1,7 +1,7 @@
/**
* @generated by `astryx theme build` — do not edit manually.
* Source: src/theme/nearle.ts
* Command: astryx theme build src/theme/nearle.ts
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.4.5
* Core: @astryxdesign/core@0.4.5
*/

View File

@@ -1,7 +1,7 @@
/**
* @generated by `astryx theme build` — do not edit manually.
* Source: src/theme/nearle.ts
* Command: astryx theme build src/theme/nearle.ts
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.4.5
* Core: @astryxdesign/core@0.4.5
*/
@@ -21,9 +21,9 @@ export const nearleTheme = {
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
"--radius-element": "12px",
"--radius-container": "16px",
"--radius-page": "16px",
"--radius-chat": "16px",
"--radius-container": "14px",
"--radius-page": "14px",
"--radius-chat": "14px",
"--text-body-size": "14px",
"--text-label-size": "14px",
"--text-large-size": "17px",

View File

@@ -50,16 +50,33 @@ export const nearleTheme = defineTheme({
},
tokens: {
// ── Radius, matched to KROW ──────────────────────────────────────────
// KROW uses exactly three radii: 16px on cards, 12px on controls, full on
// pills. Astryx defaults to 8px on controls and 12px on cards, with
// page/chat at 28px — that 28px is where the odd oversized surfaces in the
// old console came from. `--radius-inner` (4px) is left alone: KROW does
// use a 4px radius on its smallest inline markers.
// ── Radius ───────────────────────────────────────────────────────────
// ONE card radius for the whole console, and this is where it is set:
// `--radius-container` is what Astryx's `Card` rounds by, so every one of
// the 110 cards in the app takes its corner from this line. The
// hand-rolled surfaces read the same number through `--card-radius` in
// `index.css`, which is defined from this token.
//
// 14. The app had grown five card radii — 10, 12, 14, 16 and 18 — across
// surfaces that are all the same object, and none of them agreed.
//
// 14 rather than 12 because the card is a FRAMED panel now, with a tinted
// header band and a footer band inside the same clip: at 12 the band's
// corner and the panel's corner were close enough to look like a
// mis-rounding, and the header's fill reads better against a slightly
// softer edge. A plain box is happier at 12; this is not a plain box —
// see `components/Panel.tsx`.
//
// `--radius-element` stays at 12: a control is one step tighter than the
// panel that holds it, which is what keeps a button inside a card from
// looking like a small card.
//
// `--radius-inner` (4px) is left alone — Astryx uses it on the smallest
// inline markers, which are not cards.
'--radius-element': '12px',
'--radius-container': '16px',
'--radius-page': '16px',
'--radius-chat': '16px',
'--radius-container': '14px',
'--radius-page': '14px',
'--radius-chat': '14px',
// Control heights are NOT overridden: Astryx's `--size-element-md` is
// already 32px, which is exactly KROW's control height.