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', margin: '48px auto',
maxWidth: 620, maxWidth: 620,
padding: '28px 32px', padding: '28px 32px',
borderRadius: 16, borderRadius: 'var(--card-radius)',
border: '1px solid #F1D3D3', border: '1px solid #F1D3D3',
background: '#FFFBFB', background: '#FFFBFB',
fontFamily: 'inherit', fontFamily: 'inherit',

View File

@@ -1,99 +1,89 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card'; import './kpiCard.css';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; /**
import { VStack } from '@astryxdesign/core/VStack'; * 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'; 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 { export interface KpiCardProps {
/** Small-caps label. Say what it is, not what it means. */ /** Small-caps label. Say what it is, not what it means. */
label: string; label: string;
value: string; value: string;
/** /**
* Accepted and ignored, like `fill` below. * 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.
* 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.
*/ */
note?: string; 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; tone?: KpiTone;
icon?: ReactNode; icon?: ReactNode;
/** /**
* Accepted and ignored. * Accepted and ignored.
* *
* The underline bar this used to drive was removed from the tile. Six call * The 2px underline bar this drove was removed from the tile deliberately —
* sites still pass it, so the prop stays declared to keep them compiling — * a proportion bar under five tiles turned the strip into a chart. Six call
* it is not read. Either drop it from the callers or restore the bar; right * sites still pass it, so the prop stays declared to keep them compiling.
* now it is neither, and this comment is here so that is visible. * Drop it from those callers and this can go.
*/ */
fill?: number; fill?: number;
} }
/** export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
* 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];
return ( return (
<Card padding={0} elevation="low"> <div className="kpi-card" data-tone={tone}>
<VStack gap={1} padding={2} style={{ minHeight: 108 }}> {/* Label and icon share the top row; the figure gets the full width of
{/* Icon first, then the label; the pill sits opposite it. Both are the card underneath them.
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 The icon used to sit in a column of its own to the LEFT, and at the
squeezes it) does not push that tile's value below its neighbours'. */} width these tiles actually get — five across a 1,224px column, less
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}> 340px whenever Nearle Buddy is open — a 40px tile plus its gutter
<HStack gap={1} align="center" style={{ minWidth: 0 }}> 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 ? ( {icon ? (
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden> <span className="kpi-card-icon" aria-hidden>
{icon} {icon}
</span> </span>
) : null} ) : null}
<Text </div>
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
>
{label}
</Text>
</HStack>
{/* Note pill removed as requested */} <span className="kpi-card-value">{value}</span>
</HStack> {note ? <span className="kpi-card-note">{note}</span> : null}
</div>
<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>
); );
} }

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', alignItems: 'center',
gap: 14, gap: 14,
padding: '14px 16px', padding: '14px 16px',
borderRadius: 14, borderRadius: 'var(--card-radius)',
border: '1px solid var(--color-line)', border: 'var(--card-border)',
background: 'var(--color-surface-subtle)', background: 'var(--color-surface-subtle)',
}} }}
> >
@@ -153,7 +153,7 @@ export function SheetDropzone({
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
padding: '16px 20px', padding: '16px 20px',
borderRadius: 13, borderRadius: 'var(--card-radius)',
border: `1.5px dashed ${ border: `1.5px dashed ${
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))' 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 isMobile = useIsMobile();
const menuRef = useRef<HTMLDivElement>(null); 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 // 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 // finding the trigger again is a trap for anyone on a keyboard, and this one
// sits over the page rather than beside it. // 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%; width: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 10px; border-radius: var(--card-radius);
background: var(--color-surface-sunken); background: var(--color-surface-sunken);
} }
@@ -84,9 +84,11 @@
.trail-map .leaflet-popup-content-wrapper { .trail-map .leaflet-popup-content-wrapper {
padding: 0 !important; padding: 0 !important;
border-radius: 14px !important; border-radius: var(--card-radius) !important;
background: #ffffff !important; background: var(--card-bg) !important;
border: 1px solid rgba(226, 232, 240, 0.95) !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; 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; 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 { Navigate, useNavigate } from 'react-router-dom';
import { import {
AlertCircle, AlertCircle,
@@ -69,6 +69,40 @@ export function LoginPage() {
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = 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 />; if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
/** Step one: which door does this email need? */ /** Step one: which door does this email need? */
@@ -168,17 +202,31 @@ export function LoginPage() {
> >
<div <div
className="login-split" 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={{ style={{
width: '100%', width: '100%',
maxWidth: 1024, maxWidth: 1024,
background: 'var(--color-surface)', background: 'var(--card-bg)',
border: '1px solid var(--color-line)', border: 'var(--card-border)',
borderRadius: 28, borderRadius: 'var(--card-radius)',
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)', boxShadow: 'var(--card-shadow)',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<BrandPanel /> <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' ? ( {step !== 'setup' ? (
<FormPanel <FormPanel
step={step} step={step}
@@ -212,6 +260,7 @@ export function LoginPage() {
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

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

View File

@@ -9,6 +9,9 @@ import {
Bullets, Bullets,
DrawerButton, DrawerButton,
DrawerCard, DrawerCard,
Metric,
Metrics,
Mono,
Row, Row,
Section, Section,
} from '@/features/store-admin/drawerKit'; } from '@/features/store-admin/drawerKit';
@@ -102,7 +105,7 @@ export function CatalogueDetailDrawer({
subtitle={`${brand} · catalogue id ${product.id}`} subtitle={`${brand} · catalogue id ${product.id}`}
width={540} width={540}
onClose={onClose} 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 {...(!isImported && !blockedReason && onImport
? { ? {
isFooterFilled: true, isFooterFilled: true,
@@ -162,7 +165,7 @@ export function CatalogueDetailDrawer({
</button> </button>
))} ))}
</div> </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 {images.length} photos — only the first is imported
</span> </span>
</> </>
@@ -171,31 +174,13 @@ export function CatalogueDetailDrawer({
{/* A RANGE, not a price. What the shop charges is set after the import, {/* 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. */} and conflating the two is how a catalogue figure ends up on a shelf. */}
<DrawerCard tone="brand"> {/* The kit's own metric, not a 22px figure typed here. It was the largest
<div type in any drawer in the console and it sat on a catalogue REFERENCE
style={{ — louder than the selling price in the product drawer next to it. */}
display: 'flex', <Metrics cols={2}>
alignItems: 'center', <Metric label="Market price range" value={product.price_range ?? '—'} />
justifyContent: 'space-between', <Metric label="Pack size" value={product.size || '—'} isSmall />
gap: 16, </Metrics>
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>
{product.description ? ( {product.description ? (
<Section title="Description"> <Section title="Description">
@@ -243,7 +228,7 @@ export function CatalogueDetailDrawer({
label={fact.label} label={fact.label}
value={ value={
fact.isMono ? ( fact.isMono ? (
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span> <Mono>{fact.value}</Mono>
) : ( ) : (
fact.value fact.value
) )
@@ -282,21 +267,21 @@ export function CatalogueDetailDrawer({
label="Shown in the app under" label="Shown in the app under"
value={ value={
aisle ? ( aisle ? (
<Badge label={aisle} colour="#0f8a5f" /> <Badge label={aisle} colour="var(--color-success, #1f9d55)" />
) : ( ) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span> <span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
) )
} }
/> />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}> <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' {suggested.rule === 'catalogue'
? `The catalogue's own category for this product.` ? `The catalogue's own category for this product.`
: product.category : 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 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}.`} : `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
</span> </span>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}> <span className="drawer-caption">
{aisle {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. 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.'} : '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; } .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 /* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
row and a sparkline; with those gone it is a label and a number, and the `.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
layout should not still be shaped around what it no longer holds. */ same object that `components/KpiCard.tsx` renders, with a different icon
.kpi { radius, a different value size and a different gap. Both appeared on the
border: 1px solid var(--color-line); border-radius: 16px; same screen. The component won, this strip now renders it, and the classes
background: var(--color-surface); padding: 16px; are gone rather than left as a near-copy for someone to edit by mistake. */
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; }
/* ── 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 { .panel {
border: 1px solid var(--color-line); border-radius: 16px; border: var(--card-border); border-radius: var(--card-radius);
background: var(--color-surface); padding: 18px; background: var(--card-bg); box-shadow: var(--card-shadow);
display: flex; flex-direction: column; gap: 14px; min-width: 0; 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 { .panel-foot {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; 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 { .panel-empty {
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center; display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
@@ -198,8 +242,8 @@
.share { .share {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--color-line); border-radius: 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
} }
.share .donut-key { align-self: center; } .share .donut-key { align-self: center; }
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); } .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-list { display: flex; flex-direction: column; gap: 8px; }
.quick { .quick {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; 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; padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
text-decoration: none; background: var(--color-surface); text-decoration: none; background: var(--card-bg);
} }
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .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); } .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[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } .chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.search { /* `.search` was here — the Console and Counters each drew a label-wrapping-an-
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px; input version of the search box. Both use `components/SearchInput` now. */
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); }
/* ── Segmented control ──────────────────────────────────────────────────── */ /* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); } .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 { .attention-card {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px; 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="critical"] { background: #fdefee; border-color: #f2cbc7; }
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; } .attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
@@ -324,8 +363,8 @@
/* ── Loading ────────────────────────────────────────────────────────────── */ /* ── Loading ────────────────────────────────────────────────────────────── */
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } } @keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
.sk-card, .sk-chart { .sk-card, .sk-chart {
border: 1px solid var(--color-line); border-radius: 16px; border: var(--card-border); border-radius: var(--card-radius);
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite; background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
} }
.sk-card { height: 104px; } .sk-card { height: 104px; }
.sk-chart { height: 300px; } .sk-chart { height: 300px; }

View File

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

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react'; import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
import { count, money } from '@/features/store-admin/format'; import { count, money } from '@/features/store-admin/format';
import type { ConsoleTotals } from '../consoleModel'; import type { ConsoleTotals } from '../consoleModel';
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
return ( return (
<div className="kpi-strip"> <div className="kpi-strip">
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} /> <KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} /> <KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<Kpi <KpiCard
icon={<ListChecks size={17} />} icon={<ListChecks size={17} />}
label="Total Orders" label="Total Orders"
value={count(totals.totalOrders)} value={count(totals.totalOrders)}
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`} note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
/> />
<Kpi <KpiCard
icon={<Ban size={17} />} icon={<Ban size={17} />}
label="Cancelled Orders" label="Cancelled Orders"
value={count(totals.cancelled)} 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` } ? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
: {})} : {})}
/> />
<Kpi <KpiCard
icon={<CloudUpload size={17} />} icon={<CloudUpload size={17} />}
label="Unsynced Bills" label="Unsynced Bills"
value={count(totals.pendingBills)} 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) { export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
if (!row) { if (!row) {
return ( return (
<Card padding={3} elevation="low"> <Card padding={3}>
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
No branch is selected. No branch is selected.
</Text> </Text>
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
const total = row.onlineRevenue + row.counterRevenue; const total = row.onlineRevenue + row.counterRevenue;
return ( return (
<Card padding={3} elevation="low"> <Card padding={3}>
<VStack gap={3}> <VStack gap={3}>
<HStack gap={2} align="start" wrap="wrap"> <HStack gap={2} align="start" wrap="wrap">
<span className="shop-mark" aria-hidden> <span className="shop-mark" aria-hidden>

View File

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

View File

@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, Truck } from 'lucide-react'; import { Bike, Check, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
@@ -8,7 +7,7 @@ import { tenantsApi } from '@/api/tenants';
import { useAllPartners, useAppRegions } from '@/queries/hooks'; import { useAllPartners, useAppRegions } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer'; 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. * Which delivery partner supplies a merchant's riders.
@@ -89,11 +88,7 @@ export function PartnerAssignDrawer({
} }
> >
<VStack gap={2}> <VStack gap={2}>
{error ? ( {error ? <Alert>{error}</Alert> : null}
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<Note icon={<Bike size={15} />}> <Note icon={<Bike size={15} />}>
A partner supplies riders to this shop. With one set, the assign screen offers the 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 { 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 { Bike, Plus } from 'lucide-react';
import type { Partner, RiderRosterRow } from '@/api/deliveries'; import type { Partner, RiderRosterRow } from '@/api/deliveries';
import { usePartnerRiders } from '@/queries/hooks'; import { usePartnerRiders } from '@/queries/hooks';
import { Drawer } from '@/features/store-admin/Drawer'; 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'; 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} />}> <Note icon={<Bike size={15} />}>
These riders deliver for every merchant this partner supplies. A rider hired here does 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 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> </Note>
{riders.isLoading ? ( {riders.isLoading ? (
<Text type="body" size="sm" color="secondary"> <Note>Reading riders…</Note>
Reading riders…
</Text>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<Section title="No riders yet"> <Section title="No riders yet">
<DrawerCard> <DrawerCard>
@@ -88,17 +95,17 @@ export function PartnerRidersDrawer({
key={rider.userid} key={rider.userid}
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`} label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
value={ value={
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
<Badge <Badge
label={rider.isonduty ? 'On duty' : 'Off shift'} label={rider.isonduty ? 'On duty' : 'Off shift'}
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'} /* The success token, not a hex green typed here. The
/> #0f8a5f this used was a fourth green in a console
<Button that has one. */
label="Edit" colour={
variant="ghost" rider.isonduty ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-4)'
size="sm" }
onClick={() => setEditing(rider)}
/> />
<LinkAction label="Edit" onClick={() => setEditing(rider)} />
</span> </span>
} }
/> />
@@ -106,7 +113,7 @@ export function PartnerRidersDrawer({
</DrawerCard> </DrawerCard>
</Section> </Section>
)} )}
</VStack> </>
</Drawer> </Drawer>
{editing ? ( {editing ? (

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
@@ -28,6 +27,8 @@ import { Selector } from '@astryxdesign/core/Selector';
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts'; import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
import { DataState } from '@/components/DataState'; import { DataState } from '@/components/DataState';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { Panel } from '@/components/Panel';
import { SearchInput } from '@/components/SearchInput';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { import {
@@ -256,7 +257,11 @@ export function PartnersPage() {
/> />
{/* Partners Table Card */} {/* 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 <DataState
isLoading={partners.isLoading} isLoading={partners.isLoading}
error={null} error={null}
@@ -274,9 +279,8 @@ export function PartnersPage() {
dividers="rows" dividers="rows"
/> />
</div> </div>
<TablePager paged={paged} label="partners" />
</DataState> </DataState>
</Card> </Panel>
{/* Drawers */} {/* Drawers */}
{ridersFor ? ( {ridersFor ? (
@@ -553,14 +557,15 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
District District
</Text> </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" label="Search districts"
isLabelHidden
size="sm"
value={districtSearch} value={districtSearch}
onChange={setDistrictSearch} onChange={setDistrictSearch}
placeholder="Search all 38 districts…" placeholder="Search all 38 districts…"
hasClear width="full"
/> />
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts"> <div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => { {shown.map((option) => {

View File

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

View File

@@ -6,13 +6,14 @@ import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react'; import { Building2, Plus, Store, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { DataState } from '@/components/DataState'; import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; 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 { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types'; import type { TenantInfo } from '@/api/types';
@@ -240,43 +241,66 @@ export function StoresPage() {
/> />
</div> </div>
<HStack gap={0.5} wrap="wrap"> <TabBar>
<TabButton <Tab
label="Directory" label="Directory"
isActive={tab === 'directory'} isActive={tab === 'directory'}
onClick={() => setTab('directory')} onClick={() => setTab('directory')}
/> />
<TabButton <Tab
label="Awaiting approval" label="Awaiting approval"
isActive={tab === 'pending'} isActive={tab === 'pending'}
badge={pending.data?.length || undefined} count={pending.data?.length || undefined}
onClick={() => setTab('pending')} onClick={() => setTab('pending')}
/> />
</HStack> </TabBar>
{tab === 'pending' ? ( {tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} /> <PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : ( ) : (
<VStack gap={1.5}> /* The title, the count and the search are the panel's header band now.
<SectionHeader 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" title="Tenant directory"
note={`${rows.length} shown`} count={rows.length}
action={ countLabel="tenants"
<div className="toolbar-stack" style={{ maxWidth: 260 }}> actions={
<TextInput <SearchInput
label="Search tenants" label="Search tenants"
isLabelHidden
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder="Search by name, company or city…" placeholder="Name, company or city…"
hasClear width={260}
size="sm"
/> />
</div>
} }
/> >
<Card padding={0} elevation="low">
<DataState <DataState
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
@@ -306,31 +330,7 @@ export function StoresPage() {
/> />
</div> </div>
</DataState> </DataState>
</Card> </Panel>
<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>
)} )}
</VStack> </VStack>
); );
@@ -348,7 +348,7 @@ export function StoresPage() {
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) { function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) { if (isLoading) {
return ( return (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack padding={3}> <VStack padding={3}>
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
Reading the approval queue… Reading the approval queue…
@@ -360,7 +360,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack gap={0.5} padding={3}> <VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
Nothing waiting 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. 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. Approving is a database change today — the backend has no endpoint for it.
</Text> </Text>
<Card padding={0} elevation="low"> <Card padding={0}>
<div className="table-scroll"> <div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
@@ -409,59 +409,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
); );
} }
function TabButton({ /* The page-local `TabButton` lived here — see `components/TabBar`. */
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>
);
}
function Th({ children }: { children?: React.ReactNode }) { function Th({ children }: { children?: React.ReactNode }) {
return ( return (

View File

@@ -2,20 +2,37 @@
* The fleet page: a banner, a map beside a gantt, and the gantt itself. * 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 { .fleet-select {
height: 32px; height: 38px;
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--color-border); border: var(--card-border);
border-radius: 7px; border-radius: var(--card-radius-sm);
background: var(--color-surface); background: var(--card-bg);
box-shadow: var(--card-shadow);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
color: var(--color-ink-1); 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 { .fleet-select:focus-visible {
outline: 2px solid var(--color-brand); outline: none;
outline-offset: 1px; border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
} }
/* ── The recommendation banner ───────────────────────────────────────────── */ /* ── The recommendation banner ───────────────────────────────────────────── */
@@ -30,8 +47,8 @@
padding: 12px 14px; padding: 12px 14px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-left-width: 3px; border-left-width: 3px;
border-radius: 9px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
} }
.fleet-banner > svg { .fleet-banner > svg {

View File

@@ -231,6 +231,23 @@ export function OnboardingPage() {
persist(completeStep(state, step, nextOf(step))); 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 heading = HEADINGS[step];
const isFirst = step === 'welcome'; const isFirst = step === 'welcome';
const isLast = step === 'done'; const isLast = step === 'done';

View File

@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
return ( return (
<VStack gap={3} className="ob-panel"> <VStack gap={3} className="ob-panel">
{productCount > 0 ? ( {productCount > 0 ? (
<Card padding={3} elevation="low"> <Card padding={3}>
<VStack gap={0.5}> <VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
You already have {productCount} product{productCount === 1 ? '' : 's'} 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, {/* Only when it applies. Nothing below exists for a collection-only shop,
and rendering it disabled would be showing work that is not theirs. */} and rendering it disabled would be showing work that is not theirs. */}
{value.offersDelivery === true ? ( {value.offersDelivery === true ? (
<Card padding={3} elevation="low" className="ob-panel"> <Card padding={3} className="ob-panel">
<VStack gap={2}> <VStack gap={2}>
<VStack gap={0}> <VStack gap={0}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button'; import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
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 { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import {
Badge,
DrawerButton,
DrawerCard,
Field,
Metric,
Metrics,
Note,
Row,
Section,
TextField,
} from './drawerKit';
import type { CounterLabels } from './counterLabels'; 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 { money } from './format';
import { shortAge } from './posStatus'; 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 * 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 * a control on every row of a fifteen-row table is fifteen chances to hide a
* counter by mistake. * 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 { export interface CounterDrawerProps {
row: CounterRow; row: CounterRow;
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '', labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
); );
const status = row.status; const status = row.status;
const isNamed = labels.isNamed(row.terminalId);
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.']);
}
return ( return (
<Drawer <Drawer
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId} title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName} {...(isNamed ? {} : { isTitleMono: true })}
subtitle={isNamed ? row.terminalId : row.branchName}
width={460} width={460}
onClose={onClose} onClose={onClose}
> /* The same chip the row wears, so the reading that made somebody open
<VStack gap={3}> this counter is still on screen once they have. */
<div className="reading-list"> meta={
{readings.map(([label, value]) => ( <>
<div key={label} className="reading"> <Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
<span>{label}</span> {isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
<strong>{value}</strong> </>
</div> }
))} isFooterSpread
</div> footer={
<>
<VStack gap={1.5}> <DrawerButton
<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
label="Hide this counter" label="Hide this counter"
variant="ghost" variant="ghost"
size="sm"
icon={<EyeOff size={14} />} icon={<EyeOff size={14} />}
onClick={() => { onClick={() => {
labels.hide(row.terminalId); labels.hide(row.terminalId);
onClose(); onClose();
}} }}
/> />
<Button <DrawerButton
label="Save name" label="Save name"
variant="primary" variant="primary"
size="sm" isDisabled={draft.trim() === '' && !isNamed}
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
onClick={() => { onClick={() => {
labels.rename(row.terminalId, draft.trim()); labels.rename(row.terminalId, draft.trim());
onClose(); 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. {/* ── The period, in the figures the table totals ──────────────────── */}
</Text> <Metrics cols={2}>
</VStack> <Metric
</VStack> 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> </Drawer>
); );
} }

View File

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

View File

@@ -19,6 +19,15 @@ import './drawer.css';
* else. Content comes from `drawerKit`. A drawer that styles its own card or * 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. * 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 { export interface DrawerProps {
title: string; title: string;
/** One line under the title — a brand, an SKU, a store name. */ /** One line under the title — a brand, an SKU, a store name. */
@@ -78,6 +87,31 @@ export function Drawer({
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]); }, [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 ( return (
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}> <div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} /> <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 {/* 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 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} {meta ? <div className="drawer-meta">{meta}</div> : null}
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}> <button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
<X size={16} /> <X size={16} />

View File

@@ -1,17 +1,26 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button'; import { Copy, MapPin } from 'lucide-react';
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 { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people'; import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types'; import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import {
Alert,
Choice,
ChoiceGroup,
DrawerButton,
DrawerCard,
Field,
FieldRow,
Mono,
Note,
Row,
Section,
SelectField,
TextField,
} from './drawerKit';
import { branchLabel } from './format'; import { branchLabel } from './format';
/* ── Shell ───────────────────────────────────────────────────────────────── */ /* ── Shell ───────────────────────────────────────────────────────────────── */
@@ -21,6 +30,16 @@ import { branchLabel } from './format';
* *
* Right-hand rather than centred, and Escape closes it — the same contract as * 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. * 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({ function Sheet({
title, title,
@@ -38,7 +57,7 @@ function Sheet({
return ( return (
<Drawer <Drawer
title={title} title={title}
width={440} width={460}
onClose={onClose} onClose={onClose}
footer={footer} footer={footer}
isFooterSpread 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 ──────────────────────────────────────────────────── */ /* ── Back-office person ──────────────────────────────────────────────────── */
/** /**
@@ -81,22 +78,16 @@ function Problem({ message }: { message: string }) {
* ones `resolveRole` already keys on. Editing an existing person shows whatever * 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 * `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`. * 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 * ── The labels name the consequence, not the seniority ──────────────────────
* 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.
* *
* Somebody choosing "Staff" for a person they meant to be a second * The difference between these is not rank, it is which workspace the account
* administrator gets an account that signs in to the wrong workspace, and * lands in. `resolveRole` sends roleids 1 and 3 to the merchant console;
* nothing on the way there says so. Naming the consequence in the option is the * everything else becomes a branch user, pinned to one shop. Somebody choosing
* cheapest possible fix. * "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 here, and its absence is the point. * ── Administrator (roleid 3) is NOT offered, and its absence is the point ───
* *
* An administrator is provisioned by Nearle Admin when the business is created * An administrator is provisioned by Nearle Admin when the business is created
* — `createtenantuser` writes that account and forces the role — so offering it * — `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 * Editing an existing administrator is unaffected: `roleOptions` below appends
* whatever role the person already holds when this list does not know it, which * 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. * 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' }]; 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({ export function PersonDrawer({
row, row,
tenantid, tenantid,
@@ -128,7 +125,7 @@ export function PersonDrawer({
const [lastname, setLastname] = useState(row?.lastname ?? ''); const [lastname, setLastname] = useState(row?.lastname ?? '');
const [email, setEmail] = useState(row?.email ?? ''); const [email, setEmail] = useState(row?.email ?? '');
const [contactno, setContactno] = useState(row?.contactno ?? ''); 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. * No branch by default, rather than the first one.
* *
@@ -179,8 +176,8 @@ export function PersonDrawer({
onError: (error) => setProblem(errorMessage(error)), onError: (error) => setProblem(errorMessage(error)),
}); });
function submit(event: FormEvent) { function submit(event?: FormEvent) {
event.preventDefault(); event?.preventDefault();
setProblem(null); setProblem(null);
if (!firstname.trim()) return setProblem('A first name is required.'); 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.'); 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} onClose={onClose}
footer={ footer={
<> <>
<Button label="Cancel" variant="secondary" onClick={onClose} /> <DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<Button <DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'} label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
variant="primary" variant="primary"
isDisabled={save.isPending} isDisabled={save.isPending}
onClick={() => submit(new Event('submit') as unknown as FormEvent)} onClick={() => submit()}
/> />
</> </>
} }
> >
<form onSubmit={submit}> {problem ? <Alert>{problem}</Alert> : null}
<VStack gap={2}>
{problem ? <Problem message={problem} /> : null}
<div className="form-grid"> <Section title="Who they are">
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} /> <FieldRow>
<TextInput label="Last name" value={lastname} onChange={setLastname} /> <Field label="First name" isRequired>
</div> <TextField value={firstname} onChange={setFirstname} />
</Field>
<Field label="Last name">
<TextField value={lastname} onChange={setLastname} />
</Field>
</FieldRow>
<TextInput <Field label="Email" isRequired description="What they sign in with.">
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} <TextField type="email" value={email} onChange={setEmail} />
value={email} </Field>
onChange={setEmail}
description="What they sign in with."
/>
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
<div className="form-grid"> <Field label="Mobile">
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} /> <TextField type="tel" value={contactno} onChange={setContactno} />
<Selector </Field>
</Section>
<Section title="Where they work">
<Field label="Role">
<SelectField value={roleid} onChange={setRoleid} options={roleOptions} />
</Field>
<Field
label="Branch" label="Branch"
description="Leave this if their shop does not exist yet — place them when it opens."
>
<SelectField
value={locationid} value={locationid}
onChange={setLocationid} onChange={setLocationid}
placeholder="Not at a shop yet"
description="Leave this if their shop does not exist yet — place them when it opens."
options={[ options={[
/* An offered choice, not an absence. Somebody hired before /* An offered choice, not an absence. Somebody hired before their
their outlet opens sits here, signs in, and is met by the outlet opens sits here, signs in, and is met by the "No store
"No store assigned" screen until a branch is ready for assigned" screen until a branch is ready for them — which is
them — which is what makes hiring before opening possible. */ what makes hiring before opening possible. */
{ value: '0', label: 'Not at a shop yet' }, { value: '0', label: 'Not at a shop yet' },
...branches.map((branch) => ({ ...branches.map((branch) => ({
value: String(branch.locationid), value: String(branch.locationid),
@@ -250,21 +255,30 @@ export function PersonDrawer({
})), })),
]} ]}
/> />
</div> </Field>
</Section>
<ActiveToggle <Section title="Access">
isActive={isActive} <ChoiceGroup>
onChange={setIsActive} <Choice
activeNote="Can sign in" label="Active"
inactiveNote="Cannot sign in. History is kept." 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 No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
backend job until that changes. backend job until that changes.
</Text> </Note>
</VStack> </Section>
</form>
</Sheet> </Sheet>
); );
} }
@@ -275,6 +289,7 @@ export function TillDrawer({
row, row,
tenantid, tenantid,
locationid, locationid,
branchName,
roles, roles,
shifts, shifts,
cashierOnly = false, cashierOnly = false,
@@ -283,6 +298,15 @@ export function TillDrawer({
row: PosUser | null; row: PosUser | null;
tenantid: number; tenantid: number;
locationid: 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[]; roles: PosRole[];
shifts: StaffShift[]; shifts: StaffShift[];
/** /**
@@ -346,8 +370,8 @@ export function TillDrawer({
onError: (error) => setProblem(errorMessage(error)), onError: (error) => setProblem(errorMessage(error)),
}); });
function submit(event: FormEvent) { function submit(event?: FormEvent) {
event.preventDefault(); event?.preventDefault();
setProblem(null); setProblem(null);
if (!fullName.trim()) return setProblem('A name is required.'); if (!fullName.trim()) return setProblem('A name is required.');
const mobile = normaliseMobile(contactno); const mobile = normaliseMobile(contactno);
@@ -366,42 +390,30 @@ export function TillDrawer({
title="Account created" title="Account created"
subtitle={fullName} subtitle={fullName}
onClose={onClose} onClose={onClose}
footer={<Button label="Done" variant="primary" onClick={onClose} />} footer={<DrawerButton label="Done" variant="primary" onClick={onClose} />}
> >
<VStack gap={2}> <Note tone="brand">
<Text type="body" size="sm"> This is the only time this PIN is shown. Fiesta never returns it from a listing — if it is
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it lost, reissue rather than look it up.
is lost, reissue rather than look it up. </Note>
</Text>
<HStack <DrawerCard tone="subtle">
justify="between" <Row
align="center" label="PIN"
style={{ value={
padding: '14px 16px', <span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
borderRadius: 12, <Mono isLarge>{issued}</Mono>
border: '1px solid var(--color-line)', <DrawerButton
background: 'var(--color-surface-subtle)',
}}
>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 22,
fontWeight: 700,
letterSpacing: '0.18em',
}}
>
{issued}
</span>
<Button
label="Copy" label="Copy"
variant="secondary" variant="secondary"
size="sm"
icon={<Copy size={13} />} icon={<Copy size={13} />}
onClick={() => void navigator.clipboard?.writeText(issued)} onClick={() => void navigator.clipboard?.writeText(issued)}
/> />
</HStack> </span>
</VStack> }
isStacked
/>
</DrawerCard>
</Sheet> </Sheet>
); );
} }
@@ -427,70 +439,73 @@ export function TillDrawer({
: 'Add a terminal account' : 'Add a terminal account'
: (row.full_name ?? 'Edit terminal account') : (row.full_name ?? 'Edit terminal account')
} }
subtitle={ subtitle={isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`}
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
}
onClose={onClose} onClose={onClose}
footer={ footer={
<> <>
<Button label="Cancel" variant="secondary" onClick={onClose} /> <DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<Button <DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'} label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
variant="primary" variant="primary"
isDisabled={save.isPending} isDisabled={save.isPending}
onClick={() => submit(new Event('submit') as unknown as FormEvent)} onClick={() => submit()}
/> />
</> </>
} }
> >
<form onSubmit={submit}> {problem ? <Alert>{problem}</Alert> : null}
<VStack gap={2}>
{problem ? <Problem message={problem} /> : 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 <Section title="Who they are">
field leaves the reader wondering what was decided. A shop just <Field label="Full name" isRequired>
cannot change it: the merchant issues supervisors, and the backend <TextField value={fullName} onChange={setFullName} />
only lets roleid 7 manage till users at all. */} </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 ? ( {cashierOnly ? (
<Selector <Field label="Role" description="Supervisors are issued by your store administrator.">
label="Role" <SelectField
value="cashier" value="cashier"
onChange={() => undefined} onChange={() => undefined}
options={[{ value: 'cashier', label: 'Cashier' }]} options={[{ value: 'cashier', label: 'Cashier' }]}
isDisabled isDisabled
description="Supervisors are issued by your store administrator."
/> />
</Field>
) : ( ) : (
<> <Field label="Role" {...(chosenRole?.description ? { description: chosenRole.description } : {})}>
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} /> <SelectField value={role} onChange={setRole} options={roleOptions} />
{chosenRole?.description ? ( </Field>
<Text type="body" size="xsm" color="secondary">
{chosenRole.description}
</Text>
) : null}
</>
)} )}
<TextInput <Field
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} label="Mobile"
value={contactno} isRequired
onChange={setContactno}
description="Ten digits. This is what they sign in with at the till." description="Ten digits. This is what they sign in with at the till."
/> >
<TextField type="tel" value={contactno} onChange={setContactno} />
</Field>
{isNew ? ( {isNew ? (
<TextInput <Field
label="PIN" label="PIN"
value={pin}
onChange={setPin}
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once." description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
/> >
<TextField value={pin} onChange={setPin} />
</Field>
) : null} ) : null}
</Section>
<Selector <Section title="When they work">
label="Shift" <Field label="Shift" description="Informational. Nothing refuses a bill rung outside it.">
<SelectField
value={shiftId} value={shiftId}
onChange={setShiftId} onChange={setShiftId}
options={[ options={[
@@ -500,112 +515,26 @@ export function TillDrawer({
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`, label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
})), })),
]} ]}
description="Informational. Nothing refuses a bill rung outside it."
/> />
</Field>
</Section>
<ActiveToggle <Section title="Access">
isActive={isActive} <ChoiceGroup>
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}>
<Choice <Choice
label="Active" label="Active"
note={activeNote} note="Can open a till"
isSelected={isActive} isSelected={isActive}
onSelect={() => onChange(true)} onSelect={() => setIsActive(true)}
/> />
<Choice <Choice
label="Inactive" label="Inactive"
note={inactiveNote} note="Cannot sign in at a till. Their bills are kept."
isSelected={!isActive} isSelected={!isActive}
onSelect={() => onChange(false)} onSelect={() => setIsActive(false)}
/> />
</HStack> </ChoiceGroup>
</VStack> </Section>
); </Sheet>
}
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>
); );
} }

View File

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

View File

@@ -1,10 +1,15 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { 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 { queryKeys } from '@/queries/keys';
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks'; import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { aisleLabelOf, UNCATEGORISED_AISLE } from './appAisle';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import { import {
@@ -57,9 +62,23 @@ import {
export function ProductDrawer({ export function ProductDrawer({
product, product,
canManage = true, canManage = true,
request,
onClose, onClose,
}: { }: {
product: Product; 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. * False in the Store user workspace.
* *
@@ -77,11 +96,20 @@ export function ProductDrawer({
const [shot, setShot] = useState(0); const [shot, setShot] = useState(0);
/* The aisle name for this product's subcategoryid, from the same list the /* 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 aisles = useAppAisles(tenantid || undefined);
const aisleName = const aisleNames = useMemo(() => {
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ?? const out = new Map<number, string>();
null; 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. 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 price = effectivePrice(product);
const cost = product.productcost ?? 0; const cost = product.productcost ?? 0;
/* Margin is derived, not stored. Shown only when both halves are real — a /* 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. */} cosmetic default here: it is the heading the app will print. */}
<Row <Row
label="Shown in the app under" label="Shown in the app under"
value={aisleName ?? 'Uncategorized'} value={aisle}
icon={<Tag size={14} />} icon={<Tag size={14} />}
{...(aisleName ? {} : { tone: 'muted' as const })} {...(aisle === UNCATEGORISED_AISLE ? { tone: 'muted' as const } : {})}
/> />
<Row <Row
label="Unit" label="Unit"
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'} value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
icon={<Ruler size={14} />} 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> </DrawerCard>
</Section> </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 { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react'; import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader'; import { Panel } from '@/components/Panel';
import { useAppAisles, useLocationProducts } from '@/queries/hooks'; import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { aisleLabelOf } from './appAisle';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { count, money } from './format'; import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer'; import { ProductDrawer } from './ProductDrawer';
@@ -133,11 +133,11 @@ export function ProductsPanel({
[aisles.data], [aisles.data],
); );
return ( /* The panel's own header owns the category filter now, rather than a
<VStack gap={1.5}> `SectionHeader` floating above the card. It filters THIS table; sitting
<SectionHeader outside the panel it read as a page-level control that happened to be
title="Products" nearby. */
action={ const filter = (
<div style={{ width: 190 }}> <div style={{ width: 190 }}>
<Selector <Selector
label="Category" label="Category"
@@ -149,9 +149,10 @@ export function ProductsPanel({
options={categoryOptions} options={categoryOptions}
/> />
</div> </div>
} );
/>
return (
<VStack gap={1.5}>
{selection.count > 0 ? ( {selection.count > 0 ? (
<div className="bulkbar" data-active="yes"> <div className="bulkbar" data-active="yes">
<HStack justify="between" align="center" gap={2} wrap="wrap"> <HStack justify="between" align="center" gap={2} wrap="wrap">
@@ -180,6 +181,7 @@ export function ProductsPanel({
selection={selection} selection={selection}
queryKey={category} queryKey={category}
isLoading={products.isLoading} isLoading={products.isLoading}
actions={filter}
onOpen={setOpen} onOpen={setOpen}
onPrice={(product) => setPublishing([product])} onPrice={(product) => setPublishing([product])}
/> />
@@ -222,6 +224,7 @@ function ProductTable({
aisleNames, aisleNames,
selection, selection,
isLoading, isLoading,
actions,
onOpen, onOpen,
onPrice, onPrice,
queryKey, queryKey,
@@ -232,6 +235,8 @@ function ProductTable({
/** The tick state, owned by the panel so its bar can act on the choice. */ /** The tick state, owned by the panel so its bar can act on the choice. */
selection: ReturnType<typeof useSelection>; selection: ReturnType<typeof useSelection>;
isLoading: boolean; isLoading: boolean;
/** The category filter, rendered in the panel header beside the count. */
actions: ReactNode;
onOpen: (product: Product) => void; onOpen: (product: Product) => void;
onPrice: (product: Product) => void; onPrice: (product: Product) => void;
/** The category filter; changing it returns the pager to page 1. */ /** The category filter; changing it returns the pager to page 1. */
@@ -242,20 +247,18 @@ function ProductTable({
if (isLoading) { if (isLoading) {
return ( return (
<Card padding={0} elevation="low"> <Panel title="Products" actions={actions} padding="md">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
Reading your catalogue… Reading your catalogue…
</Text> </Text>
</VStack> </Panel>
</Card>
); );
} }
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<Card padding={0} elevation="low"> <Panel title="Products" count={0} countLabel="products" actions={actions} padding="md">
<VStack gap={0.5} padding={3}> <VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
Nothing here yet Nothing here yet
</Text> </Text>
@@ -265,12 +268,18 @@ function ProductTable({
were expecting rows. were expecting rows.
</Text> </Text>
</VStack> </VStack>
</Card> </Panel>
); );
} }
return ( 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"> <div className="table-scroll">
{/* `table-layout: fixed` with an explicit colgroup. Without it the {/* `table-layout: fixed` with an explicit colgroup. Without it the
browser sizes every column to its content, so Category and Status browser sizes every column to its content, so Category and Status
@@ -342,8 +351,7 @@ function ProductTable({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="products" /> </Panel>
</Card>
); );
} }
@@ -445,11 +453,9 @@ function Row({
> >
{/* The aisle id is on the row; the NAME is not — `getlocationproducts` {/* The aisle id is on the row; the NAME is not — `getlocationproducts`
selects `a.*` and joins no subcategory table — so it is looked up selects `a.*` and joins no subcategory table — so it is looked up
from the platform list the app itself groups by. */} from the platform list the app itself groups by. The drawer prints
{aisleNames.get(product.subcategoryid ?? 0) ?? the same string from the same helper, or the two disagree. */}
product.categoryname ?? {aisleLabelOf(product, aisleNames)}
product.Subcategoryname ??
'Uncategorized'}
</span> </span>
</Td> </Td>
<Td isMuted> <Td isMuted>

View File

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

View File

@@ -330,7 +330,7 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
value={ value={
<Badge <Badge
label={`${results.length - failed.length} of ${results.length}`} 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 } : {})} {...(failed.length === 0 ? { tone: 'success' as const } : {})}

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import {
aisleForCategory, aisleForCategory,
aisleIdForCategory, aisleIdForCategory,
aisleIdsFrom, aisleIdsFrom,
aisleLabelOf,
UNCATEGORISED_AISLE,
} from './appAisle'; } from './appAisle';
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory'; 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.equal(new Set(values).size, values.length);
assert.ok(values.every((id) => id > 0)); 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; if (!aisle) return 0;
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle]; 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 }; const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
return rank[a.state] - rank[b.state]; 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 ══════════════════════════════════════════════ /* ══ The drawer design system ══════════════════════════════════════════════
One shell, one header, one card, one row, one badge, one button — used by One shell, one header, one card, one row, one badge, one button, one field —
every side drawer in the console so they read as one product rather than as used by every side drawer in the console so they read as one product rather
several. Everything below is built on the tokens already in `index.css`: than as several.
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
the muted greys. No drawer defines a colour of its own.
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 ──────────────────────────────────────────────────────────────── */ /* ── Shell ──────────────────────────────────────────────────────────────── */
@@ -26,7 +87,7 @@
inset: 0; inset: 0;
border: 0; border: 0;
padding: 0; padding: 0;
background: rgb(15 23 42 / 0.32); background: rgb(15 23 42 / 0.28);
cursor: pointer; cursor: pointer;
animation: drawer-fade 180ms ease; animation: drawer-fade 180ms ease;
} }
@@ -40,7 +101,10 @@
height: 100%; height: 100%;
max-width: 92vw; max-width: 92vw;
background: var(--color-surface); 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); animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
} }
@@ -59,36 +123,26 @@
/* ── Header ───────────────────────────────────────────────────────────────── /* ── 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 It is a ROW rather than a stack: title and subtitle share a line and a half
header is 56 + 1 too: open a drawer and its header sits on exactly the line in the middle, the status badge sits beside them, and the close button ends
the nav bar sits on, edge to edge across the screen, instead of a 116px block the row. A stacked header was a third line, and a third line is what made
starting level with it and finishing 60px lower. this twice the height of the nav bar it now matches. */
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. */
.drawer-head { .drawer-head {
flex: none; flex: none;
display: flex; display: flex;
align-items: center; 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 /* 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 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. */ the two have to finish on the same pixel. */
height: 57px; height: 57px;
padding: 0 12px 0 20px; padding: 0 var(--dr-gap-md) 0 var(--dr-pad-x);
background: var(--color-brand); background: var(--color-surface);
/* A darker purple, not the grey page rule: a grey seam across a saturated border-bottom: 1px solid var(--dr-line);
ground reads as a rendering fault rather than an edge. */
border-bottom: 1px solid var(--color-brand-strong);
} }
.drawer-head-text { .drawer-head-text {
@@ -106,42 +160,44 @@
full text is on the page behind it as well as in the tooltip. */ full text is on the page behind it as well as in the tooltip. */
.drawer-title { .drawer-title {
margin: 0; margin: 0;
font: 600 15px/1.3 var(--font-sans); font: 600 var(--dr-text-lg)/1.3 var(--font-sans);
letter-spacing: -0.005em; letter-spacing: -0.006em;
color: #fff; color: var(--color-ink-1);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* An order id is a code, and a code reads better tabular. */ /* 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 { .drawer-sub {
font: 400 11.5px/1.35 var(--font-sans); font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
/* Not a grey: a grey on purple goes muddy. White at 72% keeps the same color: var(--color-ink-4);
"quieter than the title" relationship the page's ink scale gives. */
color: rgb(255 255 255 / 0.72);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Beside the title now, not under it — that stacked row is what made the /* Beside the title, not under it — that stacked row is what made the header
header three lines tall. It never wraps: a second badge shortens the title three lines tall. It never wraps: a second badge shortens the title rather
rather than pushing the row taller. */ than pushing the row taller. */
.drawer-meta { .drawer-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--dr-gap-sm);
flex: none; flex: none;
flex-wrap: nowrap; flex-wrap: nowrap;
} }
/* Capped, because the meta keeps its width and the title gives way. A branch /* 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. */ named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
.drawer-meta-text { .drawer-meta-text {
font: 400 12px/1.4 var(--font-sans); font: 400 var(--dr-text-sm)/1.4 var(--font-sans);
color: rgb(255 255 255 / 0.72); color: var(--color-ink-4);
max-width: 30ch; max-width: 24ch;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -150,18 +206,22 @@
.drawer-close { .drawer-close {
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 9px; border-radius: var(--dr-radius-sm);
border: 0; border: 1px solid transparent;
flex: none; flex: none;
display: grid; display: grid;
place-items: center; place-items: center;
background: rgb(255 255 255 / 0.14); background: transparent;
color: #fff; color: var(--color-ink-3);
cursor: pointer; 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:hover {
.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } 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 ───────────────────────────────────────────────────────────────── */ /* ── Body ───────────────────────────────────────────────────────────────── */
@@ -169,11 +229,20 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; 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; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: var(--dr-gap-xl);
} }
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */ /* Panels that lay themselves out — a gallery, a form with its own rhythm. */
.drawer-body[data-bare='true'] { padding: 0; gap: 0; } .drawer-body[data-bare='true'] { padding: 0; gap: 0; }
@@ -197,9 +266,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: var(--dr-gap-sm);
padding: 12px 24px; padding: var(--dr-gap-md) var(--dr-pad-x);
border-top: 1px solid var(--color-line); border-top: 1px solid var(--dr-line);
background: var(--color-surface); background: var(--color-surface);
} }
.drawer-foot > * { flex: 0 1 auto; } .drawer-foot > * { flex: 0 1 auto; }
@@ -212,23 +281,24 @@
/* ── Section ────────────────────────────────────────────────────────────── */ /* ── 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 { .drawer-section-title {
font: 600 11.5px/1 var(--font-sans); margin: 0;
letter-spacing: 0.08em; font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.075em;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-ink-3); color: var(--color-ink-4);
} }
/* ── Card ───────────────────────────────────────────────────────────────── */ /* ── Card ───────────────────────────────────────────────────────────────── */
/* One radius, one border, everywhere. Rows inside are separated by hairlines /* 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 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 { .drawer-card {
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
border-radius: 12px; border-radius: var(--dr-radius);
background: var(--color-surface); background: var(--card-bg);
overflow: hidden; overflow: hidden;
} }
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); } .drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
@@ -242,47 +312,53 @@
.drawer-row { .drawer-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 16px; gap: var(--dr-gap-lg);
align-items: baseline; align-items: baseline;
padding: 11px 14px; padding: var(--dr-row-pad);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); border-bottom: 1px solid var(--dr-line-soft);
} }
.drawer-row:last-child { border-bottom: 0; } .drawer-row:last-child { border-bottom: 0; }
.drawer-row-label { .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); color: var(--color-ink-3);
display: inline-flex; display: inline-flex;
align-items: center; 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. */ /* Right-aligned, tabular, so a column of values lines up on the decimal. */
.drawer-row-value { .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); color: var(--color-ink-1);
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; } .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); } .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. */ /* 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'] { grid-template-columns: 1fr; gap: var(--dr-gap-xs); }
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; } .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 ────────────────────────────────────────────────────────── */ /* ── Metric grid ────────────────────────────────────────────────────────── */
/* Three equal columns. Equal, not auto: the whole point is that the figures sit /* Equal columns, not auto: the whole point is that the figures sit on a
on a rhythm, and `auto` columns move them every time a value changes width. */ rhythm, and `auto` columns move them every time a value changes width. */
.drawer-metrics { .drawer-metrics {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px; gap: 1px;
background: var(--color-line); background: var(--dr-line);
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
border-radius: 12px; border-radius: var(--dr-radius);
overflow: hidden; overflow: hidden;
} }
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); } .drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -291,66 +367,54 @@
.drawer-metric { .drawer-metric {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--dr-gap-xs);
padding: 12px 14px; padding: var(--dr-gap-md) 14px;
background: var(--color-surface); background: var(--color-surface);
min-width: 0; min-width: 0;
} }
.drawer-metric-label { .drawer-metric-label {
font: 600 11px/1 var(--font-sans); font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.07em; letter-spacing: 0.065em;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-ink-4); color: var(--color-ink-4);
} }
.drawer-metric-value { .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); color: var(--color-ink-1);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; } .drawer-metric-value[data-size='sm'] { font-size: var(--dr-text-lg); letter-spacing: 0; }
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); } .drawer-metric-note { font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); }
/* ── Status badge ───────────────────────────────────────────────────────── */ /* ── Status badge ───────────────────────────────────────────────────────── */
/* One badge, tinted from a single colour, so every status in the console is the /* 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 { .drawer-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding: 3px 10px; padding: 3px 9px;
border-radius: 999px; 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; text-transform: capitalize;
white-space: nowrap; white-space: nowrap;
color: var(--badge); color: var(--badge);
background: color-mix(in oklab, var(--badge) 12%, transparent); background: color-mix(in oklab, var(--badge) 11%, transparent);
} border: 1px solid color-mix(in oklab, var(--badge) 24%, 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);
} }
/* ── Timeline ───────────────────────────────────────────────────────────── */ /* ── Timeline ───────────────────────────────────────────────────────────── */
.drawer-timeline { display: flex; flex-direction: column; } .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; } .drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
@@ -362,7 +426,7 @@
place-items: center; place-items: center;
flex: none; flex: none;
background: var(--color-surface-sunken); background: var(--color-surface-sunken);
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
color: var(--color-ink-4); color: var(--color-ink-4);
} }
.drawer-step-dot[data-done='true'] { .drawer-step-dot[data-done='true'] {
@@ -375,7 +439,7 @@
.drawer-step-dot[data-current='true'] { .drawer-step-dot[data-current='true'] {
background: var(--color-surface); background: var(--color-surface);
border: 2px solid var(--color-brand); 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'] { .drawer-step-dot[data-tone='danger'] {
background: color-mix(in oklab, var(--color-error) 14%, transparent); background: color-mix(in oklab, var(--color-error) 14%, transparent);
@@ -384,24 +448,38 @@
} }
/* Purple only as far as the journey has actually got. */ /* 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-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: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-done='true'] { color: var(--color-ink-1); }
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; } .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 ──────────────────────────────────────────────────────────────── */ /* ── 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 { .drawer-party-icon {
width: 30px; width: 28px;
height: 30px; height: 28px;
border-radius: 9px; border-radius: var(--dr-radius-sm);
display: grid; display: grid;
place-items: center; place-items: center;
flex: none; flex: none;
@@ -411,13 +489,21 @@
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.drawer-party-kind { .drawer-party-kind {
font: 600 10.5px/1 var(--font-sans); font: 600 var(--dr-text-xs)/1 var(--font-sans);
letter-spacing: 0.09em; letter-spacing: 0.075em;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-ink-4); 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-name {
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; } 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 /* 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. */ button — these drawers are opened on a desktop where tel: does nothing. */
@@ -425,8 +511,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 4px; margin-top: var(--dr-gap-xs);
font: 500 12.5px/1 var(--font-sans); font: 500 var(--dr-text-md)/1 var(--font-sans);
color: var(--color-brand); color: var(--color-brand);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-decoration: none; text-decoration: none;
@@ -439,8 +525,8 @@
place-items: center; place-items: center;
height: 18px; height: 18px;
color: var(--color-ink-4); color: var(--color-ink-4);
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); border-top: 1px solid var(--dr-line-soft);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); border-bottom: 1px solid var(--dr-line-soft);
background: var(--color-surface-subtle); background: var(--color-surface-subtle);
} }
@@ -450,49 +536,195 @@
.drawer-note { .drawer-note {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
gap: 10px; gap: var(--dr-gap-sm);
padding: 12px 14px; padding: var(--dr-gap-md) 14px;
border-radius: 12px; border-radius: var(--dr-radius);
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
font: 400 13px/1.5 var(--font-sans); font: 400 var(--dr-text-md)/1.55 var(--font-sans);
color: var(--color-ink-3); 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'] { .drawer-note[data-tone='brand'] {
background: var(--color-brand-tint); background: var(--color-brand-tint);
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent); 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); } .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 ────────────────────────────────────────────────────────────── */ /* ── 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 { .drawer-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 6px;
height: 42px; height: var(--dr-control);
padding: 0 18px; padding: 0 14px;
border-radius: 11px; border-radius: var(--dr-radius-sm);
border: 1px solid transparent; border: 1px solid transparent;
font: 600 14px/1 var(--font-sans); font: 600 var(--dr-text-md)/1 var(--font-sans);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease; 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: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'] { background: var(--color-brand); color: #fff; }
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); } .drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
.drawer-btn[data-variant='secondary'] { .drawer-btn[data-variant='secondary'] {
background: var(--color-surface); background: var(--color-surface);
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent); border-color: var(--dr-line);
color: var(--color-brand); 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 /* Outline, never filled: a destructive action should be reachable without
being the brightest thing in the drawer. */ being the brightest thing in the drawer. */
@@ -505,8 +737,11 @@
background: color-mix(in oklab, var(--color-error) 8%, transparent); 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'] { background: transparent; color: var(--color-ink-3); }
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); } .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 — /* A text-weight action that lives inside a section, not in the footer —
"Group with another size". */ "Group with another size". */
@@ -517,22 +752,38 @@
padding: 0; padding: 0;
border: 0; border: 0;
background: none; background: none;
font: 600 13px/1 var(--font-sans); font: 600 var(--dr-text-md)/1 var(--font-sans);
color: var(--color-brand); color: var(--color-brand);
cursor: pointer; cursor: pointer;
} }
.drawer-link-action:hover { text-decoration: underline; } .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; } .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 ─────────────────────────────────────────────────────────── */ /* ── Image area ─────────────────────────────────────────────────────────── */
.drawer-figure { .drawer-figure {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 16px; padding: var(--dr-gap-lg);
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
border-radius: 12px; border-radius: var(--dr-radius);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track /* 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 — will happily size to it and push the image straight out of the panel —
which is exactly what happened. Nothing leaves this box. */ 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'] { height: 260px; }
.drawer-figure[data-tall='true'] img { max-height: 228px; } .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 { .drawer-thumb {
width: 54px; width: 52px;
height: 54px; height: 52px;
padding: 3px; padding: 3px;
border-radius: 10px; border-radius: var(--dr-radius-sm);
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
background: var(--color-surface); background: var(--color-surface);
cursor: pointer; cursor: pointer;
display: grid; display: grid;
@@ -579,32 +830,54 @@
/* ── Highlight bullets ──────────────────────────────────────────────────── */ /* ── 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; } } @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 { .drawer-bullet {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
gap: 8px; gap: var(--dr-gap-sm);
align-items: baseline; 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); color: var(--color-ink-2);
} }
.drawer-bullet::before { .drawer-bullet::before {
content: ''; content: '';
width: 5px; width: 4px;
height: 5px; height: 4px;
border-radius: 999px; border-radius: 999px;
background: var(--color-brand); background: var(--color-brand);
transform: translateY(-2px); 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 ──────────────────────────────────────────────────────────────── */ /* ── Prose ──────────────────────────────────────────────────────────────── */
/* 15px and 1.6 — a description is the one thing in a drawer that gets read /* A description is the one thing in a drawer that gets read rather than
rather than scanned. */ scanned, so it is the one thing set above the body size. */
.drawer-prose { .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); color: var(--color-ink-2);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -612,35 +885,33 @@
.drawer-prose p:last-child { margin-bottom: 0; } .drawer-prose p:last-child { margin-bottom: 0; }
/* ── Narrower screens ───────────────────────────────────────────────────── */ /* ── 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. */ give, or a 520px layout inside a 360px panel just clips. */
@media (max-width: 600px) { @media (max-width: 600px) {
.drawer-head { padding: 16px 16px 14px; } .drawer-root { --dr-pad-x: 16px; }
.drawer-body { padding: 16px; gap: 20px; }
.drawer-foot { padding: 12px 16px; }
.drawer-title { font-size: 19px; }
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .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 ─────────────────────────────── */ /* ── Health score, inside the drawer system ─────────────────────────────── */
/* The panel's own pieces — score chip, points, tags — already sit on the brand /* 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 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 one compact card rather than as a loose stack. */
asks of it: informative, and not competing with the product it describes. */
.health-body { .health-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
padding: 14px; padding: 14px;
border: 1px solid var(--color-line); border: 1px solid var(--dr-line);
border-radius: 12px; border-radius: var(--dr-radius);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
} }
.health-per { .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); color: var(--color-ink-4);
text-align: right; 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 type { CSSProperties, ReactNode } from 'react';
import { Info } from 'lucide-react'; import { AlertTriangle, Check, ChevronDown, Info } from 'lucide-react';
import './drawer.css'; import './drawer.css';
/** /**
@@ -133,10 +133,15 @@ export function Badge({ label, colour }: { label: string; colour: string }) {
<span <span
className="drawer-badge" className="drawer-badge"
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and /* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
`background` directly, so the stylesheet can decide how to spend it. `background` directly, so the stylesheet decides how to spend it — as
An inline colour would win over every rule, and the badge has to look text, as an 11% fill and as a 24% border, from the one value.
different in the purple header than it does on a white card — see
`.drawer-head .drawer-badge`. */ 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} style={{ '--badge': colour } as CSSProperties}
> >
{label} {label}
@@ -227,3 +232,204 @@ export function Bullets({ items }: { items: readonly string[] }) {
</ul> </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> </Text>
<Card padding={0} elevation="low"> <Card padding={0}>
<div className="table-scroll"> <div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
@@ -265,7 +265,7 @@ export function CounterSalesDrawer({
</Text> </Text>
) : null} ) : null}
<Card padding={0} elevation="low"> <Card padding={0}>
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}> <div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>

View File

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

View File

@@ -1,9 +1,20 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { SearchInput } from '@/components/SearchInput';
import { useDateScope } from '@/components/shell/DateScope'; 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 { useBranchScope } from '../BranchScope';
import { useCounterLabels } from '../counterLabels'; 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 { branchLabel, count, money } from '../format';
import { shortAge } from '../posStatus'; import { shortAge } from '../posStatus';
import { useTerminalBoard } from '../useTerminalBoard'; import { useTerminalBoard } from '../useTerminalBoard';
@@ -33,19 +44,10 @@ import './counters.css';
*/ */
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const STATE_LABEL: Record<CounterState, string> = { /* The names and tones live with the rows now, in `counterRows`, so the drawer
active: 'Active', opened from this table can chip a counter the same way the row does. */
attention: 'Attention', const STATE_LABEL = COUNTER_STATE_LABEL;
never: 'Never used', const STATE_TONE = COUNTER_STATE_TONE;
offline: 'Offline',
};
const STATE_TONE: Record<CounterState, string> = {
active: 'healthy',
attention: 'attention',
never: 'neutral',
offline: 'critical',
};
export function CountersPage() { export function CountersPage() {
const { branches, scoped, isPinned } = useBranchScope(); const { branches, scoped, isPinned } = useBranchScope();
@@ -121,15 +123,13 @@ export function CountersPage() {
</div> </div>
<div className="counter-controls"> <div className="counter-controls">
<label className="search" style={{ flex: 1, minWidth: 240 }}> <SearchInput
<Search size={14} aria-hidden /> label="Search counters"
<input
value={term} value={term}
onChange={(event) => { setTerm(event.target.value); setPage(1); }} onChange={(next) => { setTerm(next); setPage(1); }}
placeholder="Search counters by code, name or branch…" placeholder="Code, name or branch…"
aria-label="Search counters" width={300}
/> />
</label>
{showBranch ? ( {showBranch ? (
<label className="picker"> <label className="picker">
@@ -251,18 +251,31 @@ export function CountersPage() {
/* ── Pieces ───────────────────────────────────────────────────────────────── */ /* ── 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({ function Kpi({
icon, label, value, note, tone, icon, label, value, note, tone,
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) { }: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
return ( return (
<article className="kpi"> <KpiCard
<span className="kpi-icon" data-tone={tone} aria-hidden>{icon}</span> icon={icon}
<div className="kpi-head"> label={label}
<span className="kpi-label">{label}</span> value={value}
<span className="kpi-value">{value}</span> note={note}
<span className="kpi-note">{note}</span> {...(tone && KPI_TONE[tone] ? { tone: KPI_TONE[tone] } : {})}
</div> />
</article>
); );
} }

View File

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

View File

@@ -6,7 +6,6 @@ import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { import {
AlertTriangle, AlertTriangle,
@@ -24,10 +23,12 @@ import {
Truck, Truck,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { stockApi } from '@/api/stock'; import { stockApi } from '@/api/stock';
import type { StockRequest } from '@/api/types'; import type { StockRequest } from '@/api/types';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks'; import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useSelection } from '@/components/useSelection'; import { useSelection } from '@/components/useSelection';
@@ -41,6 +42,7 @@ import {
type StepId, type StepId,
} from '@/features/onboarding/onboardingState'; } from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks'; import { useLocationProducts } from '@/queries/hooks';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { branchLabel, count, money } from '../format'; 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 — 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 an action for a panel you are not looking at is a misfire waiting
to happen. */ to happen. */
<HStack gap={0.5} wrap="wrap"> <TabBar>
<TabButton <Tab
label="Products" label="Products"
icon={<PackageSearch size={14} />} icon={<PackageSearch size={14} />}
isActive={tab === 'products'} isActive={tab === 'products'}
onClick={() => chooseTab('products')} onClick={() => chooseTab('products')}
/> />
<TabButton <Tab
label="Catalogue" label="Catalogue"
icon={<DownloadCloud size={14} />} icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'} isActive={tab === 'catalogue'}
onClick={() => chooseTab('catalogue')} onClick={() => chooseTab('catalogue')}
/> />
<TabButton <Tab
label="Stock requests" label="Stock requests"
icon={<ClipboardList size={14} />} icon={<ClipboardList size={14} />}
isActive={tab === 'requests'} isActive={tab === 'requests'}
onClick={() => chooseTab('requests')} onClick={() => chooseTab('requests')}
badge={pending.length || undefined} count={pending.length || undefined}
/> />
<TabButton <Tab
label="Stock" label="Stock"
icon={<Layers size={14} />} icon={<Layers size={14} />}
isActive={tab === 'stock'} isActive={tab === 'stock'}
onClick={() => chooseTab('stock')} onClick={() => chooseTab('stock')}
/> />
</HStack> </TabBar>
} }
/> />
@@ -443,17 +445,14 @@ function RequestsPanel({
</div> </div>
{/* Search box */} {/* Search box */}
<div style={{ width: 240, minWidth: 180 }}> <SearchInput
<TextInput
label="Search requests" label="Search requests"
isLabelHidden
size="sm"
value={keyword} value={keyword}
onChange={setKeyword} onChange={setKeyword}
placeholder="Search product or branch…" placeholder="Product or branch…"
width={240}
/> />
</div> </div>
</div>
{/* ── Bulk Action Bar ──────────────────────────────────────────────── */} {/* ── Bulk Action Bar ──────────────────────────────────────────────── */}
{selection.count > 0 ? ( {selection.count > 0 ? (
@@ -554,7 +553,7 @@ function RequestsPanel({
{/* ── Table Card ──────────────────────────────────────────────────── */} {/* ── Table Card ──────────────────────────────────────────────────── */}
{isLoading ? ( {isLoading ? (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack padding={3}> <VStack padding={3}>
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
Reading stock requests… Reading stock requests…
@@ -562,7 +561,7 @@ function RequestsPanel({
</VStack> </VStack>
</Card> </Card>
) : filteredRows.length === 0 ? ( ) : filteredRows.length === 0 ? (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}> <VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}>
<PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} /> <PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} />
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
@@ -578,7 +577,12 @@ function RequestsPanel({
</VStack> </VStack>
</Card> </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"> <div className="table-scroll">
<table <table
style={{ style={{
@@ -747,8 +751,7 @@ function RequestsPanel({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="stock requests" /> </Panel>
</Card>
)} )}
{/* ── Guidance Note ──────────────────────────────────────────────── */} {/* ── Guidance Note ──────────────────────────────────────────────── */}
@@ -961,22 +964,18 @@ function StockPanel() {
</select> </select>
) : null} ) : null}
<div style={{ width: 220, minWidth: 160 }}> <SearchInput
<TextInput
label="Search stock" label="Search stock"
isLabelHidden
size="sm"
value={keyword} value={keyword}
onChange={setKeyword} onChange={setKeyword}
placeholder="Search products…" placeholder="Search products…"
hasClear width={220}
/> />
</div> </div>
</div> </div>
</div>
{/* ── Table Card ──────────────────────────────────────────────────── */} {/* ── Table Card ──────────────────────────────────────────────────── */}
<Card padding={0} elevation="low"> <Card padding={0}>
{statement.isLoading ? ( {statement.isLoading ? (
<VStack padding={3}> <VStack padding={3}>
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
@@ -1220,63 +1219,7 @@ function BarAction({
); );
} }
function TabButton({ /* The page-local `TabButton` lived here — see `components/TabBar`. */
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>
);
}
function Th({ function Th({
children, children,

View File

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

View File

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

View File

@@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react'; 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 type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SearchInput } from '@/components/SearchInput';
import { Tab, TabBar } from '@/components/TabBar';
import { import {
useDeliveries, useDeliveries,
useOrders, useOrders,
@@ -37,6 +38,7 @@ import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar'; import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery'; import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection'; import { useSelection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { isCounterSale } from '../branchStats'; import { isCounterSale } from '../branchStats';
@@ -44,6 +46,21 @@ import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter'; 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. * The bill number a counter sale was imported under.
* *
@@ -287,43 +304,58 @@ export function SalesPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Sales" title="Sales"
tabs={ /* Tabs left, search right, on ONE row — see `isTabsInline`. */
<HStack gap={0.5} wrap="wrap"> isTabsInline
<TabButton actions={
label="Online Orders" <SearchInput
icon={<ShoppingCart size={14} />} label="Search sales"
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined} value={keyword}
isActive={tab !== 'counter'} onChange={setKeyword}
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)} placeholder={SEARCH_HINT[tab]}
/> />
}
tabs={
/* THREE views, not four.
{tab !== 'counter' ? ( 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
<TabButton 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" label="Orders"
icon={<ShoppingCart size={14} />} icon={<ShoppingCart size={14} />}
badge={allOrders.length || undefined} count={allOrders.length || undefined}
isActive={tab === 'orders'} isActive={tab === 'orders'}
onClick={() => setTab('orders')} onClick={() => setTab('orders')}
/> />
<TabButton <Tab
label="Deliveries" label="Deliveries"
icon={<Truck size={14} />} icon={<Truck size={14} />}
badge={allDeliveries.length || undefined} count={allDeliveries.length || undefined}
isActive={tab === 'deliveries'} isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')} onClick={() => setTab('deliveries')}
/> />
</> <Tab
) : null}
<TabButton
label="Counter sales" label="Counter sales"
icon={<Receipt size={14} />} icon={<Receipt size={14} />}
badge={billRows.length || undefined} count={billRows.length || undefined}
isActive={tab === 'counter'} isActive={tab === 'counter'}
onClick={() => setTab('counter')} onClick={() => setTab('counter')}
isGroupStart
/> />
</HStack> </TabBar>
} }
/> />
@@ -367,9 +399,6 @@ export function SalesPage() {
status={status} status={status}
counts={tabCounts} counts={tabCounts}
onStatus={setStatus} onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
}
/> />
{/* Above the table, not below it and not in a drawer. {/* Above the table, not below it and not in a drawer.
Assigning is something you do WHILE reading the day's orders — Assigning is something you do WHILE reading the day's orders —
@@ -451,9 +480,6 @@ export function SalesPage() {
status={status} status={status}
counts={tabCounts} counts={tabCounts}
onStatus={setStatus} onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
}
/> />
<DeliveriesTable <DeliveriesTable
rows={deliveryRows} rows={deliveryRows}
@@ -514,11 +540,9 @@ export function SalesPage() {
</div> </div>
<VStack gap={1.5}> <VStack gap={1.5}>
{/* No status strip here: a counter bill is rung and done, so it {/* No status strip and no search row: a counter bill is rung and
has no lifecycle to filter by. The search keeps the row. */} done, so it has no lifecycle to filter by, and the search is in
<HStack justify="end" wrap="wrap"> the page header with the tabs — one box for all three views. */}
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
</HStack>
<BillsTable <BillsTable
rows={billRows} rows={billRows}
queryKey={queryKey} queryKey={queryKey}
@@ -580,35 +604,46 @@ function freshRow(
* in view, the filters decide what is in view, and the table shows it. Reading * in view, the filters decide what is in view, and the table shows it. Reading
* downward now follows that order. * 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({ function FilterRow({
tabs, tabs,
status, status,
counts, counts,
onStatus, onStatus,
search,
}: { }: {
/** Which ladder to show — orders and deliveries do not share one. */ /** Which ladder to show — orders and deliveries do not share one. */
tabs: readonly { readonly key: StatusKey; readonly label: string }[]; tabs: readonly { readonly key: StatusKey; readonly label: string }[];
status: StatusKey; status: StatusKey;
counts: Record<StatusKey, number>; counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void; onStatus: (key: StatusKey) => void;
search: React.ReactNode;
}) { }) {
return ( return (
<HStack justify="between" align="center" gap={2} wrap="wrap"> <TabBar size="sm" aria-label="Filter by status">
<HStack gap={0.5} wrap="wrap">
{tabs.map((entry) => ( {tabs.map((entry) => (
<StatusTab <Tab
key={entry.key} key={entry.key}
label={entry.label} label={entry.label}
n={counts[entry.key]} count={counts[entry.key]}
isActive={status === entry.key} isActive={status === entry.key}
onClick={() => onStatus(entry.key)} onClick={() => onStatus(entry.key)}
/> />
))} ))}
</HStack> </TabBar>
{search}
</HStack>
); );
} }
@@ -649,7 +684,12 @@ function OrdersTable({
} }
return ( return (
<Card padding={0} elevation="low"> <Panel
title="Orders"
count={paged.total}
countLabel="orders"
footer={<TablePager paged={paged} label="orders" />}
>
<div className="table-scroll"> <div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
@@ -773,8 +813,7 @@ function OrdersTable({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="orders" /> </Panel>
</Card>
); );
} }
@@ -807,7 +846,12 @@ function DeliveriesTable({
} }
return ( return (
<Card padding={0} elevation="low"> <Panel
title="Deliveries"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<div className="table-scroll"> <div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
@@ -948,8 +992,7 @@ function DeliveriesTable({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="deliveries" /> </Panel>
</Card>
); );
} }
@@ -980,7 +1023,12 @@ function BillsTable({
} }
return ( 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"> <div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
@@ -1062,8 +1110,7 @@ function BillsTable({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="bills" /> </Panel>
</Card>
); );
} }
@@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string {
}); });
} }
function Search({ /* The page-local search box lived here. It is `components/SearchInput`
value, now, in the header beside the tabs, and there is one of it. */
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>
);
}
/** A status chip in the lifecycle's own colour. */ /** A status chip in the lifecycle's own colour. */
function Chip({ label, color }: { label: string | undefined; color: string }) { function Chip({ label, color }: { label: string | undefined; color: string }) {
@@ -1222,117 +1248,15 @@ function TotalsBar({
); );
} }
function StatusTab({ /* The page-local `StatusTab` lived here — it is the `sm` size of
label, `components/TabBar` now. */
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>
);
}
function TabButton({ /* The page-local `TabButton` lived here. It is `components/TabBar` now —
label, one control for the six pages that had each grown their own. */
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>
);
}
function Loading({ what }: { what: string }) { function Loading({ what }: { what: string }) {
return ( return (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack padding={3}> <VStack padding={3}>
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
Reading {what}… Reading {what}…
@@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) {
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return ( return (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack gap={1} padding={4} align="center"> <VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span> <span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold"> <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 { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems'; import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard'; import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; 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 * 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 * `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. * slabs — this page went flat first, and the rest of the console has since
* Defined once so the edges cannot drift apart the next time one of these is * followed: the card tokens in `index.css` are now that treatment, applied
* edited. * everywhere. What is left here is the shared card minus the fill, so the
* page's own tint reads through.
*/ */
const panelStyle: React.CSSProperties = { const panelStyle: React.CSSProperties = {
borderRadius: 14, borderRadius: 'var(--card-radius)',
border: '1px solid var(--color-line)', border: 'var(--card-border)',
}; };
/* ── Groups ──────────────────────────────────────────────────────────────── */ /* ── Groups ──────────────────────────────────────────────────────────────── */
@@ -662,8 +664,12 @@ function FineTable({
</Card> </Card>
) : null} ) : null}
<Card padding={0} variant="transparent"> <Panel
<div style={{ ...panelStyle, overflow: 'hidden' }}> title="All terminals"
count={paged.total}
countLabel="terminals"
footer={<TablePager paged={paged} label="terminals" />}
>
<Table<FineRowData> <Table<FineRowData>
data={paged.rows} data={paged.rows}
columns={columns} columns={columns}
@@ -672,9 +678,7 @@ function FineTable({
hasHover hasHover
dividers="rows" dividers="rows"
/> />
</div> </Panel>
<TablePager paged={paged} label="terminals" />
</Card>
</VStack> </VStack>
); );
} }

View File

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

View File

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

View File

@@ -2,23 +2,40 @@
Curated palette with deep purple/indigo accents (#662582), glassmorphic elements, Curated palette with deep purple/indigo accents (#662582), glassmorphic elements,
live telemetry cards and interactive map timelines. */ 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 { :root {
--dispatch-bg: #ffffff; --dispatch-bg: var(--color-surface);
--dispatch-bg-sub: #f8fafc; --dispatch-bg-sub: var(--color-surface-subtle);
--dispatch-card-bg: #ffffff; --dispatch-card-bg: var(--card-bg);
--dispatch-border: #e2e8f0; --dispatch-border: var(--color-line);
--dispatch-border-active: #662582; --dispatch-border-active: var(--color-brand);
--dispatch-text: #1e293b; --dispatch-text: var(--color-ink-1);
--dispatch-text-muted: #64748b; --dispatch-text-muted: var(--color-ink-3);
--dispatch-accent: #662582; --dispatch-accent: var(--color-brand);
--dispatch-accent-light: #9255ab; --dispatch-accent-light: #9255ab;
--dispatch-accent-soft: rgba(102, 37, 130, 0.08); --dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent);
--dispatch-success: #10b981; --dispatch-success: var(--color-success, #1f9d55);
--dispatch-warning: #f59e0b; --dispatch-warning: var(--color-warning, #b7860b);
--dispatch-error: #ef4444; --dispatch-error: var(--color-error, #d64545);
--dispatch-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06); --dispatch-shadow-sm: var(--card-shadow);
--dispatch-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08); --dispatch-shadow-md: var(--card-shadow);
--dispatch-shadow-lg: 0 12px 30px rgba(102, 37, 130, 0.12);
} }
.dispatch-container { .dispatch-container {
@@ -30,221 +47,28 @@
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
} }
/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── */ /* ── 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;
}
.logo-section { GONE, because it was never rendered.
display: flex;
align-items: center;
gap: 10px;
}
.logo-badge { This file arrived as a port of another product, and it brought that
width: 28px; product's whole operations header with it: `.dispatch-header`, a logo lockup,
height: 28px; a city picker, a demo/live toggle, a live-GPS chip, a running clock, and
border-radius: 8px; `.date-chip` — a pill with prev/next arrows for stepping through days.
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);
}
.logo-name { Not one of those class names appears in any markup in this repository. The
font-size: 15px; Dispatch page reads the date from `useDateScope()`, which is the range
font-weight: 800; picker in the app's own top bar; two comments in `DispatchPage.tsx` already
color: var(--dispatch-text); tell the reader to "change the date range in the top bar". So the header was
letter-spacing: -0.02em; 215 lines of CSS for a component that does not exist, and `.date-chip` was a
} date filter nobody can see.
.logo-name em { If the ported header is wanted later it is in the git history, but it would
color: var(--dispatch-accent); need rebuilding against this console's own controls rather than restoring:
font-style: normal; the date range, the branch scope and the account menu are all in the app
margin-left: 2px; 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) ───────────────────── */ /* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
.strat-row { .strat-row {
@@ -256,7 +80,7 @@
min-height: 42px; min-height: 42px;
background: var(--dispatch-card-bg); background: var(--dispatch-card-bg);
border: 1px solid var(--dispatch-border); border: 1px solid var(--dispatch-border);
border-radius: 10px; border-radius: var(--card-radius);
box-shadow: var(--dispatch-shadow-sm); box-shadow: var(--dispatch-shadow-sm);
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -454,23 +278,21 @@
.dispatch-container .rcard, .dispatch-container .rcard,
.rcard { .rcard {
background: #ffffff; background: var(--card-bg);
border: 1px solid var(--dispatch-border); border: 1px solid var(--dispatch-border);
border-radius: 14px; border-radius: var(--card-radius);
padding: 14px 16px; padding: 14px 16px;
cursor: pointer; cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 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; position: relative;
text-align: left; text-align: left;
user-select: none; user-select: none;
} }
/* Hover is the line, not a lift -- see the note on `.pcard` in index.css. */
.dispatch-container .rcard:hover, .dispatch-container .rcard:hover,
.rcard:hover { .rcard:hover {
transform: translateY(-2px); border-color: var(--dispatch-accent);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
border-color: rgba(102, 37, 130, 0.35);
} }
.dispatch-container .rcard.is-focused, .dispatch-container .rcard.is-focused,
@@ -645,10 +467,10 @@
.dispatch-card-elevated, .dispatch-card-elevated,
.dispatch-focus-card, .dispatch-focus-card,
.dispatch-map-card { .dispatch-map-card {
background: #ffffff !important; background: var(--card-bg) !important;
border: 1px solid var(--dispatch-border) !important; border: var(--card-border) !important;
border-radius: 16px !important; border-radius: var(--card-radius) !important;
box-shadow: var(--dispatch-shadow-md) !important; box-shadow: var(--card-shadow) !important;
overflow: hidden; overflow: hidden;
} }
@@ -707,7 +529,7 @@
padding: 10px 14px; padding: 10px 14px;
background: var(--dispatch-bg-sub); background: var(--dispatch-bg-sub);
border: 1px solid var(--dispatch-border); border: 1px solid var(--dispatch-border);
border-radius: 12px; border-radius: var(--card-radius);
} }
.rl-item { .rl-item {
@@ -774,19 +596,16 @@
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: var(--card-radius);
border: 1px solid var(--dispatch-border); border: 1px solid var(--dispatch-border);
background: #ffffff; background: var(--card-bg);
cursor: pointer; cursor: pointer;
transition: all 0.18s ease; transition: border-color 0.18s ease, background 0.18s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
} }
.timeline-step-card:hover, .timeline-step-card:hover,
.stop-timeline-card:hover { .stop-timeline-card:hover {
border-color: var(--dispatch-accent); border-color: var(--dispatch-accent);
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(102, 37, 130, 0.12);
} }
.timeline-step-card.is-current { .timeline-step-card.is-current {
@@ -981,7 +800,7 @@
flex-wrap: wrap; flex-wrap: wrap;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid rgba(102, 37, 130, 0.25); 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)); 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 { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
@@ -9,7 +8,7 @@ import { errorMessage } from '@/api/client';
import { stockApi } from '@/api/stock'; import { stockApi } from '@/api/stock';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer'; 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'; import { queryKeys } from '@/queries/keys';
/** /**
@@ -178,13 +177,7 @@ export function BulkRequestDialog({
))} ))}
</div> </div>
{problem ? ( {problem ? <Alert>{problem}</Alert> : null}
<Card padding={2} variant="transparent">
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
</Card>
) : null}
</VStack> </VStack>
</Drawer> </Drawer>

View File

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

View File

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

View File

@@ -5,12 +5,13 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react'; import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { stockApi } from '@/api/stock'; import { stockApi } from '@/api/stock';
import type { Product, StockRequest, StockStatementRow } from '@/api/types'; import type { Product, StockRequest, StockStatementRow } from '@/api/types';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { import {
useAppAisles, useAppAisles,
@@ -34,7 +35,8 @@ import {
canAskAgain, requestState, canAskAgain, requestState,
stockHealth, stockHealth,
} from '../requestState'; } 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 { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
@@ -155,41 +157,37 @@ export function StoreProductsPage() {
title="Products" title="Products"
actions={ actions={
tab === 'requests' ? null : ( tab === 'requests' ? null : (
<div style={{ width: 230 }}> <SearchInput
<TextInput
label="Search products" label="Search products"
isLabelHidden
size="sm"
value={keyword} value={keyword}
onChange={setKeyword} onChange={setKeyword}
placeholder="Search products…" placeholder="Search products…"
hasClear width={240}
/> />
</div>
) )
} }
tabs={ tabs={
<HStack gap={0.5} wrap="wrap"> <TabBar>
<TabButton <Tab
label="Catalogue" label="Catalogue"
icon={<PackageSearch size={14} />} icon={<PackageSearch size={14} />}
isActive={tab === 'catalogue'} isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')} onClick={() => setTab('catalogue')}
/> />
<TabButton <Tab
label="My stock" label="My stock"
icon={<Boxes size={14} />} icon={<Boxes size={14} />}
isActive={tab === 'stock'} isActive={tab === 'stock'}
onClick={() => setTab('stock')} onClick={() => setTab('stock')}
/> />
<TabButton <Tab
label="My requests" label="My requests"
icon={<ClipboardList size={14} />} icon={<ClipboardList size={14} />}
isActive={tab === 'requests'} isActive={tab === 'requests'}
onClick={() => setTab('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} /> <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 ? ( {askingMany && askingMany.length > 0 && tenantid && locationid ? (
<BulkRequestDialog <BulkRequestDialog
@@ -349,7 +356,12 @@ function CataloguePanel({
</div> </div>
) : null} ) : null}
<Card padding={0} elevation="low"> <Panel
title="Catalogue"
count={paged.total}
countLabel="products"
footer={<TablePager paged={paged} label="products" />}
>
<div className="table-scroll"> <div className="table-scroll">
<table <table
style={{ style={{
@@ -398,8 +410,7 @@ function CataloguePanel({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="products" /> </Panel>
</Card>
</VStack> </VStack>
)} )}
</VStack> </VStack>
@@ -482,8 +493,14 @@ function CatalogueRow({
</HStack> </HStack>
</Td> </Td>
<Td isMuted>{unit || '—'}</Td> <Td isMuted>{unit || '—'}</Td>
<Td isStrong> {/* An em dash for an unpriced product, not "₹0". Zero is a price a till
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span> 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>
<Td isMuted={stock === 0}> <Td isMuted={stock === 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span> <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 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. received today counts under <strong>In</strong> and becomes tomorrow’s opening.
</Text> </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"> <div className="table-scroll">
<table <table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }} style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
@@ -622,8 +644,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="stock rows" /> </Panel>
</Card>
</VStack> </VStack>
); );
} }
@@ -674,7 +695,12 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
return ( return (
<VStack gap={1.5}> <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"> <div className="table-scroll">
<table <table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }} style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
@@ -743,8 +769,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="requests" /> </Panel>
</Card>
</VStack> </VStack>
); );
} }
@@ -760,7 +785,7 @@ function stamp(value: string | undefined): string {
function Note({ title, body }: { title: string; body?: string }) { function Note({ title, body }: { title: string; body?: string }) {
return ( return (
<Card padding={0} elevation="low"> <Card padding={0}>
<VStack gap={0.5} padding={3}> <VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
{title} {title}

View File

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

View File

@@ -11,64 +11,8 @@ import { ImageOff } from 'lucide-react';
* merchant pages already went through. * merchant pages already went through.
*/ */
/** A page tab, as used in the Store Admin's PageHeader tab row. */ /* The sixth and last copy of `TabButton` lived here, exported for the Store
export function TabButton({ user pages. Every one of them now imports `components/TabBar`. */
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>
);
}
/** A header action drawn like the tabs, so the two rows read as one bar. */ /** A header action drawn like the tabs, so the two rows read as one bar. */
export function BarAction({ export function BarAction({

View File

@@ -5,7 +5,6 @@ import {
CircleDot, CircleDot,
Circle, Circle,
Download, Download,
FileSpreadsheet,
Info, Info,
PackageCheck, PackageCheck,
} from 'lucide-react'; } from 'lucide-react';
@@ -79,7 +78,28 @@ export function UploadDetailDrawer({
return ( return (
<Drawer <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} width={540}
onClose={onClose} onClose={onClose}
isFooterFilled={products.length === 0} 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 ───────────────────────────────────────────────────────── */} {/* ── Summary ───────────────────────────────────────────────────────── */}
<Metrics> <Metrics>
<Metric label="Total rows" value={totals.rows || '—'} /> <Metric label="Total rows" value={totals.rows || '—'} />
@@ -251,6 +244,11 @@ export function UploadDetailDrawer({
<Section title="File details"> <Section title="File details">
<DrawerCard> <DrawerCard>
<Row label="File name" value={receipt.filename || '—'} /> <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="Size" value={fileSize(file?.size_bytes)} />
<Row label="Rows" value={totals.rows || '—'} /> <Row label="Rows" value={totals.rows || '—'} />
<Row label="Added" value={totals.added} /> <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 { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { FileSpreadsheet, UploadCloud } from 'lucide-react'; import { FileSpreadsheet, UploadCloud } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { import {
fetchBatch, fetchBatch,
@@ -53,6 +54,7 @@ import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet'; import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve'; import { shelveBatch } from '@/features/nearle-admin/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone'; import { SheetDropzone } from '@/components/SheetDropzone';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { UploadDetailDrawer } from './UploadDetailDrawer'; import { UploadDetailDrawer } from './UploadDetailDrawer';
@@ -350,13 +352,12 @@ export function UploadsPanel({
<div className="up-toolbar-spacer" /> <div className="up-toolbar-spacer" />
<input <SearchInput
className="up-search" label="Search by file name"
type="search"
value={search} value={search}
placeholder="Search by file name…" onChange={setSearch}
aria-label="Search by file name" placeholder="File name…"
onChange={(event) => setSearch(event.target.value)} width={230}
/> />
{/* "Check again" stood here. It is gone because the page reads the {/* "Check again" stood here. It is gone because the page reads the
moving rows by itself now — every fifteen seconds while something is moving rows by itself now — every fifteen seconds while something is
@@ -404,7 +405,12 @@ export function UploadsPanel({
</VStack> </VStack>
</Card> </Card>
) : ( ) : (
<Card padding={0} elevation="low"> <Panel
title="Uploads"
count={paged.total}
countLabel="uploads"
footer={<TablePager paged={paged} label="uploads" />}
>
<div className="table-scroll"> <div className="table-scroll">
<table className="up-table"> <table className="up-table">
<thead> <thead>
@@ -484,8 +490,7 @@ export function UploadsPanel({
</tbody> </tbody>
</table> </table>
</div> </div>
<TablePager paged={paged} label="uploads" /> </Panel>
</Card>
)} )}
{open ? ( {open ? (

View File

@@ -53,19 +53,29 @@
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; } .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-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; height: 38px;
padding: 0 12px; padding: 0 12px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 11px; border-radius: var(--card-radius-sm);
background: var(--color-surface); background: var(--card-bg);
font: 400 13.5px/1 var(--font-sans); box-shadow: var(--card-shadow);
color: var(--color-ink-1); 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 ──────────────────────────────────────────────────────────────── */ /* ── Table ──────────────────────────────────────────────────────────────── */
@@ -114,32 +124,10 @@
white-space: nowrap; white-space: nowrap;
} }
/* ── Drawer: the file header ────────────────────────────────────────────── */ /* The drawer's file header used to live here — an icon, the filename and the
/* This is what makes it an Upload Details workspace rather than the generic status chip, drawn as a second header inside the body because the real one
drawer: the file itself is the subject, so it gets an icon and its own block only said "Upload details". The bar names the file now, so these rules have
above everything. */ no markup left to style. */
.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; }
/* ── Drawer: the journey ────────────────────────────────────────────────── */ /* ── Drawer: the journey ────────────────────────────────────────────────── */
@@ -238,7 +226,7 @@
has to go and fix a spreadsheet. */ has to go and fix a spreadsheet. */
.up-reject { .up-reject {
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent); 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); background: color-mix(in oklab, var(--color-error) 5%, transparent);
overflow: hidden; overflow: hidden;
} }

View File

@@ -50,6 +50,56 @@
--color-surface-sunken: #f2f4f7; --color-surface-sunken: #f2f4f7;
--color-line: #e0e4ea; --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. */ /* Neutral ramp — the ink scale every surface and label reads from. */
--color-slate-50: #f7f7f7; --color-slate-50: #f7f7f7;
--color-slate-100: #eff1f4; --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 /* Numeric columns line up only with tabular figures. Used by every KPI value
and every money/count cell in a table. */ and every money/count cell in a table. */
.tabular { .tabular {
@@ -681,9 +836,9 @@ main {
} }
.catalogue-rail { .catalogue-rail {
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
position: sticky; position: sticky;
top: 72px; top: 72px;
/* The clip lives here, on the element that owns the radius. */ /* The clip lives here, on the element that owns the radius. */
@@ -694,10 +849,10 @@ main {
padding: 6px; padding: 6px;
max-height: calc(100dvh - 98px); max-height: calc(100dvh - 98px);
overflow-y: auto; overflow-y: auto;
/* A thin, quiet scrollbar. The default is wide enough to read as a second /* The thin, quiet scrollbar this rail used to declare for itself is now the
column beside a 216px rail. */ whole console's — both scrollbar properties are inherited, so the pair on
scrollbar-width: thin; `html` reaches here. This rail was the only place that had been fixed, and
scrollbar-color: var(--color-line) transparent; it is why every other panel's bar looked like a different product's. */
} }
.rail-scroll::-webkit-scrollbar { .rail-scroll::-webkit-scrollbar {
@@ -810,16 +965,18 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
overflow: hidden; 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 { .pcard:hover {
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent); border-color: var(--color-brand);
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
} }
.pcard-media { .pcard-media {
@@ -1278,9 +1435,9 @@ main {
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: 20px; padding: 20px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
cursor: pointer; cursor: pointer;
transition: border-color .18s, background .18s, transform .18s; transition: border-color .18s, background .18s, transform .18s;
} }
@@ -1300,8 +1457,8 @@ main {
read. */ read. */
.ob-drop { .ob-drop {
border: 1.5px dashed var(--color-slate-300); border: 1.5px dashed var(--color-slate-300);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
padding: 32px 20px; padding: 32px 20px;
text-align: center; text-align: center;
transition: border-color .18s, background .18s; transition: border-color .18s, background .18s;
@@ -1359,9 +1516,9 @@ main {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: var(--card-radius-sm);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
border: 1px solid var(--color-line); border: var(--card-border);
} }
.ob-flow-icon { .ob-flow-icon {
/* Sized for a 17px glyph rather than the two digits that used to sit here. */ /* 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 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. */ separated by rules rather than gaps so the card reads as one object. */
.ob-frame { .ob-frame {
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 18px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
overflow: hidden; overflow: hidden;
} }
@@ -1465,7 +1622,7 @@ main {
.ob-returnbar { .ob-returnbar {
padding: 12px 16px; padding: 12px 16px;
border: 1px solid var(--color-brand); border: 1px solid var(--color-brand);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-brand-tint); background: var(--color-brand-tint);
} }
@@ -1499,9 +1656,9 @@ main {
gap: 12px; gap: 12px;
align-items: start; align-items: start;
padding: 14px 16px; padding: 14px 16px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
} }
/* Text renders inline, so the title and its detail ran together on one line /* Text renders inline, so the title and its detail ran together on one line
@@ -1540,7 +1697,7 @@ main {
gap: 10px; gap: 10px;
align-items: flex-start; align-items: flex-start;
padding: 12px 14px; padding: 12px 14px;
border-radius: 12px; border-radius: var(--card-radius);
border: 1px solid var(--color-warning-line, #e8d9a8); border: 1px solid var(--color-warning-line, #e8d9a8);
background: var(--color-warning-tint, #fdf7e6); background: var(--color-warning-tint, #fdf7e6);
color: var(--color-ink-1); color: var(--color-ink-1);
@@ -1593,9 +1750,9 @@ main {
something is picked, then takes the brand tint and shows its actions. */ something is picked, then takes the brand tint and shows its actions. */
.bulkbar { .bulkbar {
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 12px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
transition: background .15s ease, border-color .15s ease; transition: background .15s ease, border-color .15s ease;
} }
@@ -1685,9 +1842,9 @@ main {
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 10px; border-radius: var(--card-radius-sm);
background: var(--color-surface); background: var(--card-bg);
} }
.qty-step { .qty-step {
@@ -1737,9 +1894,9 @@ main {
gap: 12px; gap: 12px;
align-items: start; align-items: start;
padding: 14px 16px; padding: 14px 16px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 12px; border-radius: var(--card-radius);
background: var(--color-surface-subtle); background: var(--card-bg-subtle);
} }
.ob-guide[data-tone='warn'] { .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 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. */ to be touched, and a background is not worth the risk of moving it. */
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff); 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; padding: 18px;
} }
@media (max-width: 720px) { .sp-page { padding: 12px; } } @media (max-width: 720px) { .sp-page { padding: 12px; } }
@@ -1827,10 +1987,9 @@ main {
gap: 16px; gap: 16px;
align-items: center; align-items: center;
padding: 18px 20px; padding: 18px 20px;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 16px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
} }
/* The store, not the person. A shop mark rather than an avatar — this record /* 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); } } @media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
.sp-card { .sp-card {
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 14px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
overflow: hidden; overflow: hidden;
} }
.sp-card[data-span='full'] { grid-column: 1 / -1; } .sp-card[data-span='full'] { grid-column: 1 / -1; }
@@ -2078,9 +2236,9 @@ main {
.district-list { .district-list {
max-height: 208px; max-height: 208px;
overflow-y: auto; overflow-y: auto;
border: 1px solid var(--color-line); border: var(--card-border);
border-radius: 12px; border-radius: var(--card-radius);
background: var(--color-surface); background: var(--card-bg);
} }
.district-row { .district-row {

View File

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

View File

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

View File

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

View File

@@ -50,16 +50,33 @@ export const nearleTheme = defineTheme({
}, },
tokens: { tokens: {
// ── Radius, matched to KROW ────────────────────────────────────────── // ── Radius ───────────────────────────────────────────────────────────
// KROW uses exactly three radii: 16px on cards, 12px on controls, full on // ONE card radius for the whole console, and this is where it is set:
// pills. Astryx defaults to 8px on controls and 12px on cards, with // `--radius-container` is what Astryx's `Card` rounds by, so every one of
// page/chat at 28px — that 28px is where the odd oversized surfaces in the // the 110 cards in the app takes its corner from this line. The
// old console came from. `--radius-inner` (4px) is left alone: KROW does // hand-rolled surfaces read the same number through `--card-radius` in
// use a 4px radius on its smallest inline markers. // `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-element': '12px',
'--radius-container': '16px', '--radius-container': '14px',
'--radius-page': '16px', '--radius-page': '14px',
'--radius-chat': '16px', '--radius-chat': '14px',
// Control heights are NOT overridden: Astryx's `--size-element-md` is // Control heights are NOT overridden: Astryx's `--size-element-md` is
// already 32px, which is exactly KROW's control height. // already 32px, which is exactly KROW's control height.