ui improvement
This commit is contained in:
@@ -75,7 +75,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
margin: '48px auto',
|
||||
maxWidth: 620,
|
||||
padding: '28px 32px',
|
||||
borderRadius: 16,
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: '1px solid #F1D3D3',
|
||||
background: '#FFFBFB',
|
||||
fontFamily: 'inherit',
|
||||
|
||||
@@ -1,99 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import './kpiCard.css';
|
||||
|
||||
/**
|
||||
* A KPI tile — the console's headline figure.
|
||||
*
|
||||
* ── There were two of these, and neither worked ─────────────────────────────
|
||||
*
|
||||
* This component put an icon inline with the label and CENTRED the value
|
||||
* underneath it, which is why a row of tiles never lined up: a centred number
|
||||
* sits in a different place in every tile depending on how long it is.
|
||||
* `console.css` had a second, unrelated implementation — `.kpi` — with a
|
||||
* brand-tinted icon tile on the left and the text stacked beside it. The two
|
||||
* appeared on the same screen. The second was the better design and this is
|
||||
* now that design, for both.
|
||||
*
|
||||
* ── `note` is rendered again, and that is the real change ───────────────────
|
||||
*
|
||||
* The line under the figure — "142 app · 38 counter", "12% of app orders" —
|
||||
* was removed from the tile at some point but never removed from the CALLERS:
|
||||
* 86 of them still compute and pass it, and it was being thrown away. That is
|
||||
* the half of a KPI that carries the meaning. A bare `0` under "Unsynced
|
||||
* bills" reads as "no data" when it means everything is in the books, and `42`
|
||||
* under "Total orders" says nothing about which channel they came through.
|
||||
*
|
||||
* ── `tone` does something now ───────────────────────────────────────────────
|
||||
*
|
||||
* All five tones used to map to `var(--color-brand)`, so the prop was
|
||||
* decorative — every tile was purple whether it reported takings or a failure.
|
||||
* The tone now colours the icon tile only: the figure itself stays in ink,
|
||||
* because a number that changes colour with its own value is hard to compare
|
||||
* against the tile beside it. Brand remains the default, so a tile that is
|
||||
* merely reporting is not shouting.
|
||||
*/
|
||||
|
||||
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
|
||||
|
||||
const TONE_COLOR: Record<KpiTone, string> = {
|
||||
accent: 'var(--color-brand)',
|
||||
success: 'var(--color-brand)',
|
||||
warning: 'var(--color-brand)',
|
||||
error: 'var(--color-brand)',
|
||||
neutral: 'var(--color-brand)',
|
||||
};
|
||||
|
||||
export interface KpiCardProps {
|
||||
/** Small-caps label. Say what it is, not what it means. */
|
||||
label: string;
|
||||
value: string;
|
||||
/**
|
||||
* Accepted and ignored, like `fill` below.
|
||||
*
|
||||
* This was the sub-pill — where the number got its meaning, since "1 of 84
|
||||
* orders" says something "1.2%" does not. The pill was removed from the tile
|
||||
* deliberately; 87 call sites across 21 files still pass the text, so the
|
||||
* prop stays declared to keep them compiling and is not read.
|
||||
*
|
||||
* That means every one of those 87 strings is now written and never shown.
|
||||
* Either drop them from the callers or bring the pill back — right now it is
|
||||
* neither, and this comment is here so that is visible rather than a puzzle
|
||||
* for whoever next wonders where "84 orders" went.
|
||||
* The line under the figure — what the number is made of, or what it is a
|
||||
* share of. This is where a KPI gets its meaning; see the note above.
|
||||
*/
|
||||
note?: string;
|
||||
/**
|
||||
* Colours the icon tile. `neutral` and `accent` are both the brand — a tile
|
||||
* reports by default and only says more when something is actually wrong.
|
||||
*/
|
||||
tone?: KpiTone;
|
||||
icon?: ReactNode;
|
||||
/**
|
||||
* Accepted and ignored.
|
||||
*
|
||||
* The underline bar this used to drive was removed from the tile. Six call
|
||||
* sites still pass it, so the prop stays declared to keep them compiling —
|
||||
* it is not read. Either drop it from the callers or restore the bar; right
|
||||
* now it is neither, and this comment is here so that is visible.
|
||||
* The 2px underline bar this drove was removed from the tile deliberately —
|
||||
* a proportion bar under five tiles turned the strip into a chart. Six call
|
||||
* sites still pass it, so the prop stays declared to keep them compiling.
|
||||
* Drop it from those callers and this can go.
|
||||
*/
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A KPI tile.
|
||||
*
|
||||
* Label and icon, then a large tabular value. The sub-pill and the 2px tone
|
||||
* underline were both removed from the tile; `note` and `fill` still arrive
|
||||
* from the call sites and are ignored — see the props above. Sized to sit 4–6
|
||||
* across rather than 4 — a console is glanced at all day, not read once.
|
||||
*/
|
||||
export function KpiCard({ label, value, tone = 'neutral', icon }: KpiCardProps) {
|
||||
const color = TONE_COLOR[tone];
|
||||
|
||||
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
|
||||
{/* Icon first, then the label; the pill sits opposite it. Both are
|
||||
fixed to this row so tiles line up whether or not they carry one. */}
|
||||
{/* A fixed height, so a label that wraps to two lines (a long note
|
||||
squeezes it) does not push that tile's value below its neighbours'. */}
|
||||
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
|
||||
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
||||
<div className="kpi-card" data-tone={tone}>
|
||||
{/* Label and icon share the top row; the figure gets the full width of
|
||||
the card underneath them.
|
||||
|
||||
The icon used to sit in a column of its own to the LEFT, and at the
|
||||
width these tiles actually get — five across a 1,224px column, less
|
||||
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
|
||||
left about 134px for the number. "₹1,24,500" does not fit in 134px at
|
||||
24px bold, so it wrapped, and it wrapped mid-figure: the first tile
|
||||
on the Console read "₹1,24,50" on one line and "0" on the next. */}
|
||||
<div className="kpi-card-head">
|
||||
<span className="kpi-card-label">{label}</span>
|
||||
{icon ? (
|
||||
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
|
||||
<span className="kpi-card-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Note pill removed as requested */}
|
||||
</HStack>
|
||||
|
||||
<HStack justify="center" align="center" style={{ flex: 1 }}>
|
||||
<Text
|
||||
as="div"
|
||||
hasTabularNumbers
|
||||
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
</Card>
|
||||
<span className="kpi-card-value">{value}</span>
|
||||
{note ? <span className="kpi-card-note">{note}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
139
src/components/Panel.tsx
Normal file
139
src/components/Panel.tsx
Normal 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;
|
||||
}
|
||||
70
src/components/SearchInput.tsx
Normal file
70
src/components/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -66,8 +66,8 @@ export function SheetDropzone({
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: 'var(--card-border)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
@@ -153,7 +153,7 @@ export function SheetDropzone({
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 13,
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: `1.5px dashed ${
|
||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||
}`,
|
||||
|
||||
88
src/components/TabBar.tsx
Normal file
88
src/components/TabBar.tsx
Normal 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
140
src/components/kpiCard.css
Normal 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
141
src/components/panel.css
Normal 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%; }
|
||||
}
|
||||
82
src/components/searchInput.css
Normal file
82
src/components/searchInput.css
Normal 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; }
|
||||
}
|
||||
@@ -105,6 +105,29 @@ export function AppShell({
|
||||
const isMobile = useIsMobile();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/*
|
||||
A new page starts at the top.
|
||||
|
||||
A single-page app does not reload, so the document keeps whatever scroll
|
||||
offset the last page was left at: read Sales down to row 40, click Inventory,
|
||||
and Inventory opens 2,000px down — usually past everything it has, so it
|
||||
looks empty. Nothing in the app was resetting this.
|
||||
|
||||
INSTANT, deliberately, and it is the one place in the console that opts out
|
||||
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
|
||||
animate those 2,000px on every navigation, which means a second of the new
|
||||
page flying past before it settles — the page arriving late, rather than the
|
||||
page arriving. Smooth is for a move you asked for within a page; a route
|
||||
change is not one.
|
||||
|
||||
Keyed on `pathname` alone, not on the whole location: the branch scope and
|
||||
the date range travel in the query string, and re-scoping a table you are
|
||||
halfway down should leave you where you were.
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
}, [pathname]);
|
||||
|
||||
// Escape closes the account menu. A menu that can only be dismissed by
|
||||
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||
// sits over the page rather than beside it.
|
||||
|
||||
143
src/components/tabBar.css
Normal file
143
src/components/tabBar.css
Normal 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; }
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
@@ -84,9 +84,11 @@
|
||||
|
||||
.trail-map .leaflet-popup-content-wrapper {
|
||||
padding: 0 !important;
|
||||
border-radius: 14px !important;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
||||
border-radius: var(--card-radius) !important;
|
||||
background: var(--card-bg) !important;
|
||||
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
|
||||
genuinely floats, over a map, which is what a shadow is for. */
|
||||
border: var(--card-border) !important;
|
||||
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -69,6 +69,40 @@ export function LoginPage() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
/*
|
||||
Each step brings its own panel into view.
|
||||
|
||||
The three steps are one screen, and on a phone the brand panel sits ABOVE the
|
||||
form — so the card is taller than the viewport and answering the email step
|
||||
replaces the panel below the fold. Without this the page does not move: you
|
||||
press Continue, something changes off screen, and the password field you are
|
||||
meant to type into is never shown to you.
|
||||
|
||||
Smooth rather than a jump, because unlike a route change this is a move
|
||||
WITHIN a screen the reader is already looking at, and seeing the page travel
|
||||
is what connects the button they pressed to the field that arrived. It is
|
||||
skipped on the first render — arriving at a login already scrolled to the
|
||||
form would hide the brand and the "which account is this" context above it —
|
||||
and a reader who has asked for reduced motion gets the instant jump, via the
|
||||
global rule in `index.css`.
|
||||
|
||||
DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic:
|
||||
hooks must run in the same order on every render, and this component returns
|
||||
early the moment a session exists. Put below that line, these two refs and
|
||||
the effect would simply stop being called on the render that signs somebody
|
||||
in — which is React's "rendered fewer hooks than expected" crash, on the
|
||||
happy path.
|
||||
*/
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const isFirstStep = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstStep.current) {
|
||||
isFirstStep.current = false;
|
||||
return;
|
||||
}
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [step]);
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
/** Step one: which door does this email need? */
|
||||
@@ -168,17 +202,31 @@ export function LoginPage() {
|
||||
>
|
||||
<div
|
||||
className="login-split"
|
||||
/* The sign-in card is the console's card, at the console's corner.
|
||||
|
||||
It was the largest radius in the product at 28px, carrying a
|
||||
two-layer drop shadow, and it was the FIRST surface anybody saw —
|
||||
so it set an expectation the rest of the app then did not meet. It
|
||||
is now the same flat hairline card as every other surface, just
|
||||
bigger. */
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 1024,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 28,
|
||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
||||
background: 'var(--card-bg)',
|
||||
border: 'var(--card-border)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
{/* The ref lives on a wrapper rather than inside the two panels, so
|
||||
neither has to know it is being scrolled to. `display: grid` keeps
|
||||
it out of the way: as a grid item the wrapper stretches to the row,
|
||||
and the panel inside stretches with it, which is what centres the
|
||||
form vertically beside the brand. A plain block here would collapse
|
||||
to its content's height and un-centre it. */}
|
||||
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
|
||||
{step !== 'setup' ? (
|
||||
<FormPanel
|
||||
step={step}
|
||||
@@ -212,6 +260,7 @@ export function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Token } from '@astryxdesign/core/Token';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
||||
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||
import { APP_BROWSE_CATEGORY } from './tenantCategories';
|
||||
@@ -391,16 +391,12 @@ export function CatalogueBrowser({
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search the catalogue"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
width={220}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
startIcon={<Search size={14} />}
|
||||
hasClear
|
||||
width={220}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
Bullets,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Metric,
|
||||
Metrics,
|
||||
Mono,
|
||||
Row,
|
||||
Section,
|
||||
} from '@/features/store-admin/drawerKit';
|
||||
@@ -102,7 +105,7 @@ export function CatalogueDetailDrawer({
|
||||
subtitle={`${brand} · catalogue id ${product.id}`}
|
||||
width={540}
|
||||
onClose={onClose}
|
||||
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
|
||||
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
|
||||
{...(!isImported && !blockedReason && onImport
|
||||
? {
|
||||
isFooterFilled: true,
|
||||
@@ -162,7 +165,7 @@ export function CatalogueDetailDrawer({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
|
||||
<span className="drawer-metric-note">
|
||||
{images.length} photos — only the first is imported
|
||||
</span>
|
||||
</>
|
||||
@@ -171,31 +174,13 @@ export function CatalogueDetailDrawer({
|
||||
|
||||
{/* A RANGE, not a price. What the shop charges is set after the import,
|
||||
and conflating the two is how a catalogue figure ends up on a shelf. */}
|
||||
<DrawerCard tone="brand">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '14px 16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span className="drawer-metric-label">Market price range</span>
|
||||
<span
|
||||
style={{
|
||||
font: '600 22px/1.2 var(--font-sans)',
|
||||
color: 'var(--color-ink-1)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{product.price_range ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
|
||||
</div>
|
||||
</DrawerCard>
|
||||
{/* The kit's own metric, not a 22px figure typed here. It was the largest
|
||||
type in any drawer in the console and it sat on a catalogue REFERENCE
|
||||
— louder than the selling price in the product drawer next to it. */}
|
||||
<Metrics cols={2}>
|
||||
<Metric label="Market price range" value={product.price_range ?? '—'} />
|
||||
<Metric label="Pack size" value={product.size || '—'} isSmall />
|
||||
</Metrics>
|
||||
|
||||
{product.description ? (
|
||||
<Section title="Description">
|
||||
@@ -243,7 +228,7 @@ export function CatalogueDetailDrawer({
|
||||
label={fact.label}
|
||||
value={
|
||||
fact.isMono ? (
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
|
||||
<Mono>{fact.value}</Mono>
|
||||
) : (
|
||||
fact.value
|
||||
)
|
||||
@@ -282,21 +267,21 @@ export function CatalogueDetailDrawer({
|
||||
label="Shown in the app under"
|
||||
value={
|
||||
aisle ? (
|
||||
<Badge label={aisle} colour="#0f8a5f" />
|
||||
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
<span className="drawer-caption">
|
||||
{suggested.rule === 'catalogue'
|
||||
? `The catalogue's own category for this product.`
|
||||
: product.category
|
||||
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
|
||||
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
|
||||
</span>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
<span className="drawer-caption">
|
||||
{aisle
|
||||
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
|
||||
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
|
||||
|
||||
@@ -63,40 +63,84 @@
|
||||
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
||||
}
|
||||
|
||||
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
|
||||
row and a sparkline; with those gone it is a label and a number, and the
|
||||
layout should not still be shaped around what it no longer holds. */
|
||||
.kpi {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface); padding: 16px;
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
|
||||
}
|
||||
.kpi-icon {
|
||||
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
|
||||
background: var(--color-brand-tint); color: var(--color-brand);
|
||||
}
|
||||
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
|
||||
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
|
||||
has stopped doing its job. The min-height keeps all five value rows level
|
||||
whether the label took one line or two. */
|
||||
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
|
||||
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
||||
/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
|
||||
`.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
|
||||
same object that `components/KpiCard.tsx` renders, with a different icon
|
||||
radius, a different value size and a different gap. Both appeared on the
|
||||
same screen. The component won, this strip now renders it, and the classes
|
||||
are gone rather than left as a near-copy for someone to edit by mistake. */
|
||||
|
||||
|
||||
/* ── Panels ─────────────────────────────────────────────────────────────── */
|
||||
/* ── Panels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
The Console's panels are the SAME object as `components/Panel.tsx` — a
|
||||
framed card with a header band, a body and a footer band. They stay as CSS
|
||||
classes rather than becoming that component because these six sections each
|
||||
lay their own body out (a chart, a donut, a map, a list of alerts) and have
|
||||
no table or pager to hand it; the component's job is the table case.
|
||||
|
||||
What matters is that they are the same DESIGN. They were not: this file had
|
||||
a white header with a 15.5px title and a sub-line, the component has a
|
||||
tinted band with an 11px eyebrow, and the two sat on the same screen. The
|
||||
band and the type below are now the component's, restated here.
|
||||
|
||||
The panel no longer pads itself — the bands and the body pad separately, or
|
||||
a header band cannot reach the panel's edges. */
|
||||
.panel {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface); padding: 18px;
|
||||
display: flex; flex-direction: column; gap: 14px; min-width: 0;
|
||||
border: var(--card-border); border-radius: var(--card-radius);
|
||||
background: var(--card-bg); box-shadow: var(--card-shadow);
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
/* The clip belongs on the element that owns the radius, or the header
|
||||
band's tint squares off the top two corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The body. Every direct child of `.panel` that is not a band is content, so
|
||||
the padding and the stack live on a wrapper the sections already render. */
|
||||
.panel > :not(.panel-head):not(.panel-foot) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
/* Consecutive content blocks should not double their gutter. */
|
||||
.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 14px; flex-wrap: wrap;
|
||||
min-height: 48px; padding: 8px 18px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-bottom: var(--card-border);
|
||||
}
|
||||
/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a
|
||||
card competes with the page's own title above it. */
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font: 600 11px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
/* Title and sub-line sit on ONE row, the way the table panel's title and count
|
||||
do. Every section wraps the pair in a plain `div`, which stacks them by
|
||||
default — and a stacked pair is the two-line header this band replaced. */
|
||||
.panel-head > div:first-child {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-sub {
|
||||
margin: 0;
|
||||
font: 400 12px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
|
||||
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
|
||||
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
.panel-foot {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
|
||||
padding: 8px 18px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-top: var(--card-border);
|
||||
font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3);
|
||||
}
|
||||
.panel-empty {
|
||||
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
|
||||
@@ -198,8 +242,8 @@
|
||||
.share {
|
||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-line); border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg-subtle);
|
||||
}
|
||||
.share .donut-key { align-self: center; }
|
||||
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||
@@ -236,8 +280,8 @@
|
||||
.quick-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.quick {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
|
||||
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
|
||||
text-decoration: none; background: var(--color-surface);
|
||||
padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||
text-decoration: none; background: var(--card-bg);
|
||||
}
|
||||
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||
@@ -277,13 +321,8 @@
|
||||
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
|
||||
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
.search {
|
||||
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
|
||||
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
|
||||
background: var(--color-surface); color: var(--color-ink-4);
|
||||
}
|
||||
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
|
||||
.search:focus-within { border-color: var(--color-brand); }
|
||||
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
|
||||
input version of the search box. Both use `components/SearchInput` now. */
|
||||
|
||||
/* ── Segmented control ──────────────────────────────────────────────────── */
|
||||
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
|
||||
@@ -303,7 +342,7 @@
|
||||
|
||||
.attention-card {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
|
||||
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
|
||||
align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius);
|
||||
}
|
||||
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
|
||||
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
|
||||
@@ -324,8 +363,8 @@
|
||||
/* ── Loading ────────────────────────────────────────────────────────────── */
|
||||
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
|
||||
.sk-card, .sk-chart {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
||||
border: var(--card-border); border-radius: var(--card-radius);
|
||||
background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.sk-card { height: 104px; }
|
||||
.sk-chart { height: 300px; }
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface BranchOverviewProps {
|
||||
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No branches are set up yet.
|
||||
</Text>
|
||||
@@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<table className="branch-table">
|
||||
<thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { count, money } from '@/features/store-admin/format';
|
||||
import type { ConsoleTotals } from '../consoleModel';
|
||||
|
||||
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
|
||||
return (
|
||||
<div className="kpi-strip">
|
||||
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||
<Kpi
|
||||
<KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||
<KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||
<KpiCard
|
||||
icon={<ListChecks size={17} />}
|
||||
label="Total Orders"
|
||||
value={count(totals.totalOrders)}
|
||||
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
|
||||
/>
|
||||
<Kpi
|
||||
<KpiCard
|
||||
icon={<Ban size={17} />}
|
||||
label="Cancelled Orders"
|
||||
value={count(totals.cancelled)}
|
||||
@@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
||||
: {})}
|
||||
/>
|
||||
<Kpi
|
||||
<KpiCard
|
||||
icon={<CloudUpload size={17} />}
|
||||
label="Unsynced Bills"
|
||||
value={count(totals.pendingBills)}
|
||||
@@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
icon, label, value, note,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
note?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="kpi">
|
||||
<span className="kpi-icon" aria-hidden>{icon}</span>
|
||||
<div className="kpi-head">
|
||||
<span className="kpi-label">{label}</span>
|
||||
<span className="kpi-value">{value}</span>
|
||||
{note ? <span className="kpi-note">{note}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface ShopSummaryProps {
|
||||
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||
if (!row) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No branch is selected.
|
||||
</Text>
|
||||
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||
const total = row.onlineRevenue + row.counterRevenue;
|
||||
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} align="start" wrap="wrap">
|
||||
<span className="shop-mark" aria-hidden>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { branchLabel, count, money } from '@/features/store-admin/format';
|
||||
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
|
||||
import type { BranchRow, HealthTone } from '../consoleModel';
|
||||
@@ -111,15 +111,13 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="search">
|
||||
<Search size={14} aria-hidden />
|
||||
<input
|
||||
<SearchInput
|
||||
label="Search tills"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
|
||||
aria-label="Search tills"
|
||||
onChange={setTerm}
|
||||
placeholder={showBranch ? 'Till ID, branch…' : 'Till ID…'}
|
||||
width={220}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Check, Truck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
@@ -8,7 +7,7 @@ import { tenantsApi } from '@/api/tenants';
|
||||
import { useAllPartners, useAppRegions } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
import { Alert, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
|
||||
/**
|
||||
* Which delivery partner supplies a merchant's riders.
|
||||
@@ -89,11 +88,7 @@ export function PartnerAssignDrawer({
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? <Alert>{error}</Alert> : null}
|
||||
|
||||
<Note icon={<Bike size={15} />}>
|
||||
A partner supplies riders to this shop. With one set, the assign screen offers the
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Plus } from 'lucide-react';
|
||||
import type { Partner, RiderRosterRow } from '@/api/deliveries';
|
||||
import { usePartnerRiders } from '@/queries/hooks';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
import {
|
||||
Badge,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
LinkAction,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
} from '@/features/store-admin/drawerKit';
|
||||
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
|
||||
|
||||
/**
|
||||
@@ -59,7 +64,11 @@ export function PartnerRidersDrawer({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
{/* No `VStack` around this. The drawer body IS a column with the
|
||||
system's own gap; a second stack inside it set a second, different
|
||||
one, which is why this drawer's sections sat closer together than
|
||||
every other drawer's. */}
|
||||
<>
|
||||
<Note icon={<Bike size={15} />}>
|
||||
These riders deliver for every merchant this partner supplies. A rider hired here does
|
||||
not appear in the on-duty fleet until they open the rider app and start a shift — that
|
||||
@@ -67,9 +76,7 @@ export function PartnerRidersDrawer({
|
||||
</Note>
|
||||
|
||||
{riders.isLoading ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading riders…
|
||||
</Text>
|
||||
<Note>Reading riders…</Note>
|
||||
) : rows.length === 0 ? (
|
||||
<Section title="No riders yet">
|
||||
<DrawerCard>
|
||||
@@ -88,17 +95,17 @@ export function PartnerRidersDrawer({
|
||||
key={rider.userid}
|
||||
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
|
||||
value={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
|
||||
<Badge
|
||||
label={rider.isonduty ? 'On duty' : 'Off shift'}
|
||||
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
|
||||
/>
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(rider)}
|
||||
/* The success token, not a hex green typed here. The
|
||||
#0f8a5f this used was a fourth green in a console
|
||||
that has one. */
|
||||
colour={
|
||||
rider.isonduty ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-4)'
|
||||
}
|
||||
/>
|
||||
<LinkAction label="Edit" onClick={() => setEditing(rider)} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -106,7 +113,7 @@ export function PartnerRidersDrawer({
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
)}
|
||||
</VStack>
|
||||
</>
|
||||
</Drawer>
|
||||
|
||||
{editing ? (
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
||||
</div>
|
||||
|
||||
<div className="fleet-body">
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Where the fleet is registered
|
||||
@@ -162,7 +162,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
The day, rider by rider
|
||||
@@ -202,7 +202,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack gap={1} align="center">
|
||||
<span className="fleet-note-icon">
|
||||
|
||||
@@ -138,7 +138,7 @@ export function OnboardTenantPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card padding={4} elevation="low">
|
||||
<Card padding={4}>
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
@@ -153,7 +153,7 @@ export function OnboardTenantPage() {
|
||||
</Text>
|
||||
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<Card padding={3} variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
@@ -241,7 +241,7 @@ export function OnboardTenantPage() {
|
||||
</div>
|
||||
|
||||
{/* Section 1: Business Details Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<div
|
||||
style={{
|
||||
@@ -318,7 +318,7 @@ export function OnboardTenantPage() {
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Head Office Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
@@ -28,6 +27,8 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import {
|
||||
@@ -256,7 +257,11 @@ export function PartnersPage() {
|
||||
/>
|
||||
|
||||
{/* Partners Table Card */}
|
||||
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
|
||||
<Panel
|
||||
count={paged.total}
|
||||
countLabel="partners"
|
||||
footer={<TablePager paged={paged} label="partners" />}
|
||||
>
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
@@ -274,9 +279,8 @@ export function PartnersPage() {
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</Panel>
|
||||
|
||||
{/* Drawers */}
|
||||
{ridersFor ? (
|
||||
@@ -553,14 +557,15 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
District
|
||||
</Text>
|
||||
<TextInput
|
||||
{/* Full width, not a fixed one: this sits above the 38-row district
|
||||
list it filters, inside a drawer, and a 260px box over a list that
|
||||
fills the panel reads as unrelated to it. */}
|
||||
<SearchInput
|
||||
label="Search districts"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={districtSearch}
|
||||
onChange={setDistrictSearch}
|
||||
placeholder="Search all 38 districts…"
|
||||
hasClear
|
||||
width="full"
|
||||
/>
|
||||
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||
{shown.map((option) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
@@ -12,10 +11,10 @@ import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
|
||||
import type { TenantInfo, TenantLocation } from '@/api/types';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
@@ -279,12 +278,13 @@ export function StoreDetailPage() {
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
<Panel
|
||||
title="Branches"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
count={paged.total}
|
||||
countLabel="branches"
|
||||
actions={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
footer={<TablePager paged={paged} label="branches" />}
|
||||
>
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
@@ -307,9 +307,8 @@ export function StoreDetailPage() {
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="branches" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
|
||||
{isPartnerOpen && tenantid ? (
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
@@ -240,43 +241,66 @@ export function StoresPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
<TabBar>
|
||||
<Tab
|
||||
label="Directory"
|
||||
isActive={tab === 'directory'}
|
||||
onClick={() => setTab('directory')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Awaiting approval"
|
||||
isActive={tab === 'pending'}
|
||||
badge={pending.data?.length || undefined}
|
||||
count={pending.data?.length || undefined}
|
||||
onClick={() => setTab('pending')}
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
|
||||
{tab === 'pending' ? (
|
||||
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
/* The title, the count and the search are the panel's header band now.
|
||||
The search filters THIS table; floating above the card it read as a
|
||||
page-level control that happened to sit nearby, and the count was a
|
||||
note beside a heading rather than the figure you check a filter
|
||||
against. */
|
||||
<Panel
|
||||
footer={
|
||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Page {page}
|
||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Previous"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={page === 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
}
|
||||
title="Tenant directory"
|
||||
note={`${rows.length} shown`}
|
||||
action={
|
||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||
<TextInput
|
||||
count={rows.length}
|
||||
countLabel="tenants"
|
||||
actions={
|
||||
<SearchInput
|
||||
label="Search tenants"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, company or city…"
|
||||
hasClear
|
||||
size="sm"
|
||||
placeholder="Name, company or city…"
|
||||
width={260}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
>
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
@@ -306,31 +330,7 @@ export function StoresPage() {
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
|
||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Page {page}
|
||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Previous"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={page === 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Panel>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
@@ -348,7 +348,7 @@ export function StoresPage() {
|
||||
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the approval queue…
|
||||
@@ -360,7 +360,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
@@ -379,7 +379,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
||||
These merchants exist but are not approved, so they do not appear in the directory.
|
||||
Approving is a database change today — the backend has no endpoint for it.
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -409,59 +409,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||
|
||||
function Th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -2,20 +2,37 @@
|
||||
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
|
||||
*/
|
||||
|
||||
/* The console's small control: a branch picker, a rider-partner picker and the
|
||||
Shifts day input all wear this.
|
||||
|
||||
Its own shape before was 32px at a 7px corner — a seventh radius in an app
|
||||
that has one. It takes the search box's measurements now, which matters most
|
||||
on Inventory, where a `.fleet-select` branch picker sits immediately beside a
|
||||
`SearchInput`: the two were a different height AND a different corner, six
|
||||
pixels apart. */
|
||||
.fleet-select {
|
||||
height: 32px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-1);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.fleet-select:hover { border-color: var(--color-ink-4); }
|
||||
|
||||
/* The same focus treatment as the search box — a brand border and a 3px tint
|
||||
ring, not the global 2px outline, so two controls sitting side by side do
|
||||
not light up in different ways. */
|
||||
.fleet-select:focus,
|
||||
.fleet-select:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* ── The recommendation banner ───────────────────────────────────────────── */
|
||||
@@ -30,8 +47,8 @@
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left-width: 3px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.fleet-banner > svg {
|
||||
|
||||
@@ -231,6 +231,23 @@ export function OnboardingPage() {
|
||||
persist(completeStep(state, step, nextOf(step)));
|
||||
}
|
||||
|
||||
/*
|
||||
Each step of the wizard starts at the top of itself.
|
||||
|
||||
The steps are STATE, not routes, so the shell's own reset — which keys on the
|
||||
pathname — never fires here: the URL is `/admin/onboarding` for all seven of
|
||||
them. Finishing the inventory step from the bottom of a long list and
|
||||
pressing Save & continue therefore opened the next step already scrolled past
|
||||
its heading and its explanation, which is exactly the part a person setting
|
||||
up a shop for the first time needs to read.
|
||||
|
||||
Instant, for the same reason the shell's is: this replaces the whole page
|
||||
body, so there is nothing continuous for a smooth travel to connect.
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
}, [step]);
|
||||
|
||||
const heading = HEADINGS[step];
|
||||
const isFirst = step === 'welcome';
|
||||
const isLast = step === 'done';
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
|
||||
return (
|
||||
<VStack gap={3} className="ob-panel">
|
||||
{productCount > 0 ? (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
You already have {productCount} product{productCount === 1 ? '' : 's'}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
|
||||
{/* Only when it applies. Nothing below exists for a collection-only shop,
|
||||
and rendering it disabled would be showing work that is not theirs. */}
|
||||
{value.offersDelivery === true ? (
|
||||
<Card padding={3} elevation="low" className="ob-panel">
|
||||
<Card padding={3} className="ob-panel">
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -53,7 +53,7 @@ const TIPS = [
|
||||
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
|
||||
return (
|
||||
<VStack gap={3} className="ob-panel">
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
||||
</Card>
|
||||
|
||||
<div className="ob-two">
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
||||
<VStack gap={0.5}>
|
||||
@@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={1.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Tips
|
||||
|
||||
@@ -158,7 +158,7 @@ function Group({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -113,7 +113,7 @@ export function WelcomeStep({
|
||||
|
||||
<div className="ob-benefits">
|
||||
{benefits.map(({ icon: Icon, title, body }) => (
|
||||
<Card key={title} padding={3} elevation="low">
|
||||
<Card key={title} padding={3}>
|
||||
<VStack gap={1}>
|
||||
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -110,9 +110,9 @@ export function StoreQrPanel({
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 16,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
background: 'var(--card-bg)',
|
||||
border: 'var(--card-border)',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -164,7 +164,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
||||
const rider = entry.live;
|
||||
const worries = concerns(rider);
|
||||
return (
|
||||
<Card key={rider.userid} padding={0} elevation="low">
|
||||
<Card key={rider.userid} padding={0}>
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<span className="active-name">
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { EyeOff } from 'lucide-react';
|
||||
import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
Badge,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Field,
|
||||
Metric,
|
||||
Metrics,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
TextField,
|
||||
} from './drawerKit';
|
||||
import type { CounterLabels } from './counterLabels';
|
||||
import type { CounterRow } from './counterRows';
|
||||
import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows';
|
||||
import { money } from './format';
|
||||
import { shortAge } from './posStatus';
|
||||
|
||||
@@ -21,6 +28,19 @@ import { shortAge } from './posStatus';
|
||||
* Rename and hide live here rather than on the row. They are rare actions, and
|
||||
* a control on every row of a fifteen-row table is fifteen chances to hide a
|
||||
* counter by mistake.
|
||||
*
|
||||
* ── What this used to get wrong ─────────────────────────────────────────────
|
||||
*
|
||||
* The table's Status column — Active, Attention, Offline, Never used — was the
|
||||
* one reading this drawer did not repeat, so the chip that made somebody open a
|
||||
* counter vanished the moment they did. It is in the header now, from the same
|
||||
* `state` the row chips from, and the named problem behind an "Attention" is
|
||||
* stated rather than left as a colour.
|
||||
*
|
||||
* The readings were also drawn by a `.reading-list` belonging to the counters
|
||||
* page stylesheet, which is why this drawer had a different row height, label
|
||||
* weight and hairline from every other drawer in the console. It is built from
|
||||
* the kit now, like the rest.
|
||||
*/
|
||||
export interface CounterDrawerProps {
|
||||
row: CounterRow;
|
||||
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
|
||||
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
|
||||
);
|
||||
const status = row.status;
|
||||
|
||||
const readings: [string, string][] = [
|
||||
['Counter code', row.terminalId],
|
||||
['Branch', row.branchName],
|
||||
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
|
||||
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
|
||||
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
|
||||
// The number that explains a revenue figure reading low, so it is stated
|
||||
// even when it is zero rather than hidden as "nothing to report".
|
||||
readings.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.reason) readings.push(['Reported', status.reason]);
|
||||
} else {
|
||||
readings.push(['Status', 'This counter has never reported in.']);
|
||||
}
|
||||
const isNamed = labels.isNamed(row.terminalId);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
|
||||
title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||
{...(isNamed ? {} : { isTitleMono: true })}
|
||||
subtitle={isNamed ? row.terminalId : row.branchName}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<div className="reading-list">
|
||||
{readings.map(([label, value]) => (
|
||||
<div key={label} className="reading">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={row.terminalId}
|
||||
description="Shown instead of the code. The code stays on the receipts."
|
||||
/>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button
|
||||
/* The same chip the row wears, so the reading that made somebody open
|
||||
this counter is still on screen once they have. */
|
||||
meta={
|
||||
<>
|
||||
<Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
|
||||
{isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
|
||||
</>
|
||||
}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton
|
||||
label="Hide this counter"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<EyeOff size={14} />}
|
||||
onClick={() => {
|
||||
labels.hide(row.terminalId);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
<DrawerButton
|
||||
label="Save name"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
|
||||
isDisabled={draft.trim() === '' && !isNamed}
|
||||
onClick={() => {
|
||||
labels.rename(row.terminalId, draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till
|
||||
that has been retired.
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* ── The period, in the figures the table totals ──────────────────── */}
|
||||
<Metrics cols={2}>
|
||||
<Metric
|
||||
label="Sold in this period"
|
||||
value={money(row.periodAmount)}
|
||||
note={`${row.periodBills} bill${row.periodBills === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<Metric
|
||||
label="Today on the till"
|
||||
value={status ? money(status.todayAmount) : '—'}
|
||||
note={status ? `${status.todayBills} bill${status.todayBills === 1 ? '' : 's'}` : 'never reported'}
|
||||
isSmall
|
||||
/>
|
||||
</Metrics>
|
||||
|
||||
{/* The named problem behind an "Attention" or an "Offline". The board
|
||||
already worked out what is wrong and how long it has been wrong; the
|
||||
row could only show the colour. */}
|
||||
{row.card ? (
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
<strong>{row.card.problem.headline}</strong> {row.card.problem.detail}
|
||||
{row.card.problem.action ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong>{row.card.problem.action}</strong>
|
||||
</>
|
||||
) : null}
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
<Section title="Readings">
|
||||
<DrawerCard>
|
||||
<Row label="Counter code" value={row.terminalId} icon={<Hash size={14} />} />
|
||||
<Row label="Branch" value={row.branchName} icon={<MapPin size={14} />} />
|
||||
{status ? (
|
||||
<>
|
||||
<Row
|
||||
label="Last sale"
|
||||
value={
|
||||
status.lastBillAt
|
||||
? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago`
|
||||
: 'never'
|
||||
}
|
||||
icon={<Receipt size={14} />}
|
||||
{...(status.lastBillAt ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
<Row
|
||||
label="Last heard from"
|
||||
value={status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`}
|
||||
icon={<Monitor size={14} />}
|
||||
{...(status.silentForMs == null ? { tone: 'muted' as const } : {})}
|
||||
/>
|
||||
{/* Stated even at zero. It is the number that explains a revenue
|
||||
figure reading low, and hiding it as "nothing to report" is
|
||||
what leaves somebody hunting for the missing money. */}
|
||||
<Row
|
||||
label="Waiting to send"
|
||||
value={String(status.pendingBills)}
|
||||
{...(status.pendingBills > 0 ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
{status.reason ? <Row label="Reported" value={status.reason} isStacked /> : null}
|
||||
</>
|
||||
) : (
|
||||
<Row label="Status" value="This counter has never reported in." isStacked />
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
<Section title="Name and visibility">
|
||||
<Field
|
||||
label="Name this counter"
|
||||
description="Shown instead of the code. The code stays on the receipts."
|
||||
>
|
||||
<TextField value={draft} onChange={setDraft} placeholder={row.terminalId} />
|
||||
</Field>
|
||||
<Note>
|
||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till that
|
||||
has been retired.
|
||||
</Note>
|
||||
</Section>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import './dateRangePicker.css';
|
||||
|
||||
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
||||
|
||||
@@ -64,7 +65,7 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: 180 }}>
|
||||
<div className="datefilter" style={{ width: 180 }}>
|
||||
<DateRangeInput
|
||||
width={180}
|
||||
size="sm"
|
||||
|
||||
@@ -19,6 +19,15 @@ import './drawer.css';
|
||||
* else. Content comes from `drawerKit`. A drawer that styles its own card or
|
||||
* badge is the beginning of the drift this component exists to end.
|
||||
*/
|
||||
/**
|
||||
* How many drawers are open right now.
|
||||
*
|
||||
* Module scope rather than component state, because the count has to be shared
|
||||
* between separate instances that know nothing about each other — see the
|
||||
* scroll lock in the component below.
|
||||
*/
|
||||
let openDrawers = 0;
|
||||
|
||||
export interface DrawerProps {
|
||||
title: string;
|
||||
/** One line under the title — a brand, an SKU, a store name. */
|
||||
@@ -78,6 +87,31 @@ export function Drawer({
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
/*
|
||||
The page behind stops scrolling while this is open.
|
||||
|
||||
`overscroll-behavior: contain` on the body already stops a scroll that
|
||||
reaches the end of the drawer from continuing into the page underneath. What
|
||||
it does not stop is a wheel over the SCRIM, which is most of the screen — so
|
||||
the list behind would scroll away under a drawer that stayed put, and closing
|
||||
it left the operator somewhere they had not chosen to be.
|
||||
|
||||
No padding is added to compensate for the vanished scrollbar: `html` carries
|
||||
`scrollbar-gutter: stable`, so the gutter is reserved whether or not a bar is
|
||||
drawn there and the page does not jump sideways as this opens.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/* Counted, not a boolean. Drawers stack — the partner's rider list opens a
|
||||
rider drawer on top of itself — and closing the inner one must not
|
||||
unlock the page while the outer one is still covering it. */
|
||||
openDrawers += 1;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
openDrawers -= 1;
|
||||
if (openDrawers === 0) document.body.style.overflow = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
|
||||
@@ -105,7 +139,10 @@ export function Drawer({
|
||||
|
||||
{/* Beside the title, not beneath it. Stacked under the subtitle it was
|
||||
a third line, and a third line is what made this header twice the
|
||||
height of the nav bar it now matches. */}
|
||||
height of the nav bar it now matches.
|
||||
|
||||
The badge here is the same badge the table row wears, in the same
|
||||
colour — see the note on `Badge` in `drawerKit`. */}
|
||||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||||
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, Copy } from 'lucide-react';
|
||||
import { Copy, MapPin } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
Alert,
|
||||
Choice,
|
||||
ChoiceGroup,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Field,
|
||||
FieldRow,
|
||||
Mono,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
SelectField,
|
||||
TextField,
|
||||
} from './drawerKit';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||
@@ -21,6 +30,16 @@ import { branchLabel } from './format';
|
||||
*
|
||||
* Right-hand rather than centred, and Escape closes it — the same contract as
|
||||
* the order drawer, so the two do not behave differently for no reason.
|
||||
*
|
||||
* ── Why this file stopped styling itself ────────────────────────────────────
|
||||
*
|
||||
* It used to build its own error box, its own Active/Inactive pair and its own
|
||||
* PIN panel out of `HStack`s and inline styles, and pick Astryx's `TextInput`
|
||||
* and `Selector` for the fields. None of those matched the drawers on either
|
||||
* side of them: a different control height, a different label weight, a
|
||||
* different radius, a third spelling of an error. The kit now carries a field,
|
||||
* an input, a picker, a choice and an alert, so there is nothing left here to
|
||||
* drift.
|
||||
*/
|
||||
function Sheet({
|
||||
title,
|
||||
@@ -38,7 +57,7 @@ function Sheet({
|
||||
return (
|
||||
<Drawer
|
||||
title={title}
|
||||
width={440}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
isFooterSpread
|
||||
@@ -49,28 +68,6 @@ function Sheet({
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ message }: { message: string }) {
|
||||
return (
|
||||
<HStack
|
||||
gap={1}
|
||||
align="start"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
|
||||
/>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Back-office person ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -81,22 +78,16 @@ function Problem({ message }: { message: string }) {
|
||||
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
||||
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
||||
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
||||
*/
|
||||
/**
|
||||
* The back-office roles, and what each one can actually reach.
|
||||
*
|
||||
* The labels used to be bare — Administrator, Manager, Staff — and the
|
||||
* difference between them is not a matter of seniority, it is which workspace
|
||||
* the account lands in. `resolveRole` sends roleids 1 and 3 to the merchant
|
||||
* console; everything else becomes a branch user, pinned to one shop.
|
||||
* ── The labels name the consequence, not the seniority ──────────────────────
|
||||
*
|
||||
* Somebody choosing "Staff" for a person they meant to be a second
|
||||
* administrator gets an account that signs in to the wrong workspace, and
|
||||
* nothing on the way there says so. Naming the consequence in the option is the
|
||||
* cheapest possible fix.
|
||||
*/
|
||||
/*
|
||||
* Administrator (roleid 3) is NOT offered here, and its absence is the point.
|
||||
* The difference between these is not rank, it is which workspace the account
|
||||
* lands in. `resolveRole` sends roleids 1 and 3 to the merchant console;
|
||||
* everything else becomes a branch user, pinned to one shop. Somebody choosing
|
||||
* "Staff" for a person they meant to be a second administrator gets an account
|
||||
* that signs in to the wrong workspace, and nothing on the way there says so.
|
||||
*
|
||||
* ── Administrator (roleid 3) is NOT offered, and its absence is the point ───
|
||||
*
|
||||
* An administrator is provisioned by Nearle Admin when the business is created
|
||||
* — `createtenantuser` writes that account and forces the role — so offering it
|
||||
@@ -106,10 +97,16 @@ function Problem({ message }: { message: string }) {
|
||||
* Editing an existing administrator is unaffected: `roleOptions` below appends
|
||||
* whatever role the person already holds when this list does not know it, which
|
||||
* exists precisely so editing a phone number cannot silently reassign someone.
|
||||
* Removing the option from CREATION therefore costs nothing on the edit path.
|
||||
*/
|
||||
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
|
||||
|
||||
/* The default for a NEW person. Read from the list rather than typed twice —
|
||||
this was `STAFF_ROLES[1]`, an index that has not existed since the list was
|
||||
cut to one entry, and it only kept working because the `?? 4` behind it
|
||||
happens to be the same role. Adding a second entry would have silently made
|
||||
it the default. */
|
||||
const DEFAULT_STAFF_ROLE = STAFF_ROLES[0]?.id ?? 4;
|
||||
|
||||
export function PersonDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
@@ -128,7 +125,7 @@ export function PersonDrawer({
|
||||
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
||||
const [email, setEmail] = useState(row?.email ?? '');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
|
||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? DEFAULT_STAFF_ROLE));
|
||||
/**
|
||||
* No branch by default, rather than the first one.
|
||||
*
|
||||
@@ -179,8 +176,8 @@ export function PersonDrawer({
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
function submit(event?: FormEvent) {
|
||||
event?.preventDefault();
|
||||
setProblem(null);
|
||||
if (!firstname.trim()) return setProblem('A first name is required.');
|
||||
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
||||
@@ -203,46 +200,54 @@ export function PersonDrawer({
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
onClick={() => submit()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
{problem ? <Alert>{problem}</Alert> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
|
||||
<TextInput label="Last name" value={lastname} onChange={setLastname} />
|
||||
</div>
|
||||
<Section title="Who they are">
|
||||
<FieldRow>
|
||||
<Field label="First name" isRequired>
|
||||
<TextField value={firstname} onChange={setFirstname} />
|
||||
</Field>
|
||||
<Field label="Last name">
|
||||
<TextField value={lastname} onChange={setLastname} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
|
||||
<TextInput
|
||||
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
description="What they sign in with."
|
||||
/>
|
||||
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
|
||||
<Field label="Email" isRequired description="What they sign in with.">
|
||||
<TextField type="email" value={email} onChange={setEmail} />
|
||||
</Field>
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||
<Selector
|
||||
<Field label="Mobile">
|
||||
<TextField type="tel" value={contactno} onChange={setContactno} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Where they work">
|
||||
<Field label="Role">
|
||||
<SelectField value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Branch"
|
||||
description="Leave this if their shop does not exist yet — place them when it opens."
|
||||
>
|
||||
<SelectField
|
||||
value={locationid}
|
||||
onChange={setLocationid}
|
||||
placeholder="Not at a shop yet"
|
||||
description="Leave this if their shop does not exist yet — place them when it opens."
|
||||
options={[
|
||||
/* An offered choice, not an absence. Somebody hired before
|
||||
their outlet opens sits here, signs in, and is met by the
|
||||
"No store assigned" screen until a branch is ready for
|
||||
them — which is what makes hiring before opening possible. */
|
||||
/* An offered choice, not an absence. Somebody hired before their
|
||||
outlet opens sits here, signs in, and is met by the "No store
|
||||
assigned" screen until a branch is ready for them — which is
|
||||
what makes hiring before opening possible. */
|
||||
{ value: '0', label: 'Not at a shop yet' },
|
||||
...branches.map((branch) => ({
|
||||
value: String(branch.locationid),
|
||||
@@ -250,21 +255,30 @@ export function PersonDrawer({
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can sign in"
|
||||
inactiveNote="Cannot sign in. History is kept."
|
||||
<Section title="Access">
|
||||
<ChoiceGroup>
|
||||
<Choice
|
||||
label="Active"
|
||||
note="Can sign in"
|
||||
isSelected={isActive}
|
||||
onSelect={() => setIsActive(true)}
|
||||
/>
|
||||
<Choice
|
||||
label="Inactive"
|
||||
note="Cannot sign in. History is kept."
|
||||
isSelected={!isActive}
|
||||
onSelect={() => setIsActive(false)}
|
||||
/>
|
||||
</ChoiceGroup>
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<Note>
|
||||
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
||||
backend job until that changes.
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Note>
|
||||
</Section>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +289,7 @@ export function TillDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
locationid,
|
||||
branchName,
|
||||
roles,
|
||||
shifts,
|
||||
cashierOnly = false,
|
||||
@@ -283,6 +298,15 @@ export function TillDrawer({
|
||||
row: PosUser | null;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
/**
|
||||
* The outlet this till account belongs to.
|
||||
*
|
||||
* Shown, not asked: a till account is created against the branch whose row
|
||||
* was clicked and cannot be moved from here. It is stated because the table
|
||||
* carries a Branch column, and a drawer that silently drops a column the row
|
||||
* shows leaves the reader unsure which shop they are editing.
|
||||
*/
|
||||
branchName?: string | undefined;
|
||||
roles: PosRole[];
|
||||
shifts: StaffShift[];
|
||||
/**
|
||||
@@ -346,8 +370,8 @@ export function TillDrawer({
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
function submit(event?: FormEvent) {
|
||||
event?.preventDefault();
|
||||
setProblem(null);
|
||||
if (!fullName.trim()) return setProblem('A name is required.');
|
||||
const mobile = normaliseMobile(contactno);
|
||||
@@ -366,42 +390,30 @@ export function TillDrawer({
|
||||
title="Account created"
|
||||
subtitle={fullName}
|
||||
onClose={onClose}
|
||||
footer={<Button label="Done" variant="primary" onClick={onClose} />}
|
||||
footer={<DrawerButton label="Done" variant="primary" onClick={onClose} />}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Text type="body" size="sm">
|
||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
|
||||
is lost, reissue rather than look it up.
|
||||
</Text>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
}}
|
||||
>
|
||||
{issued}
|
||||
</span>
|
||||
<Button
|
||||
<Note tone="brand">
|
||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it is
|
||||
lost, reissue rather than look it up.
|
||||
</Note>
|
||||
|
||||
<DrawerCard tone="subtle">
|
||||
<Row
|
||||
label="PIN"
|
||||
value={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
|
||||
<Mono isLarge>{issued}</Mono>
|
||||
<DrawerButton
|
||||
label="Copy"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Copy size={13} />}
|
||||
onClick={() => void navigator.clipboard?.writeText(issued)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</span>
|
||||
}
|
||||
isStacked
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -427,70 +439,73 @@ export function TillDrawer({
|
||||
: 'Add a terminal account'
|
||||
: (row.full_name ?? 'Edit terminal account')
|
||||
}
|
||||
subtitle={
|
||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||
}
|
||||
subtitle={isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
onClick={() => submit()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
{problem ? <Alert>{problem}</Alert> : null}
|
||||
|
||||
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
|
||||
{branchName ? (
|
||||
<DrawerCard>
|
||||
<Row label="Branch" value={branchLabel(branchName)} icon={<MapPin size={14} />} />
|
||||
</DrawerCard>
|
||||
) : null}
|
||||
|
||||
{/* The role is shown either way — a form that silently decides a
|
||||
field leaves the reader wondering what was decided. A shop just
|
||||
cannot change it: the merchant issues supervisors, and the backend
|
||||
only lets roleid 7 manage till users at all. */}
|
||||
<Section title="Who they are">
|
||||
<Field label="Full name" isRequired>
|
||||
<TextField value={fullName} onChange={setFullName} />
|
||||
</Field>
|
||||
|
||||
{/* The role is shown either way — a form that silently decides a field
|
||||
leaves the reader wondering what was decided. A shop just cannot
|
||||
change it: the merchant issues supervisors, and the backend only
|
||||
lets roleid 7 manage till users at all. */}
|
||||
{cashierOnly ? (
|
||||
<Selector
|
||||
label="Role"
|
||||
<Field label="Role" description="Supervisors are issued by your store administrator.">
|
||||
<SelectField
|
||||
value="cashier"
|
||||
onChange={() => undefined}
|
||||
options={[{ value: 'cashier', label: 'Cashier' }]}
|
||||
isDisabled
|
||||
description="Supervisors are issued by your store administrator."
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
|
||||
{chosenRole?.description ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{chosenRole.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
<Field label="Role" {...(chosenRole?.description ? { description: chosenRole.description } : {})}>
|
||||
<SelectField value={role} onChange={setRole} options={roleOptions} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={contactno}
|
||||
onChange={setContactno}
|
||||
<Field
|
||||
label="Mobile"
|
||||
isRequired
|
||||
description="Ten digits. This is what they sign in with at the till."
|
||||
/>
|
||||
>
|
||||
<TextField type="tel" value={contactno} onChange={setContactno} />
|
||||
</Field>
|
||||
|
||||
{isNew ? (
|
||||
<TextInput
|
||||
<Field
|
||||
label="PIN"
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
||||
/>
|
||||
>
|
||||
<TextField value={pin} onChange={setPin} />
|
||||
</Field>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Selector
|
||||
label="Shift"
|
||||
<Section title="When they work">
|
||||
<Field label="Shift" description="Informational. Nothing refuses a bill rung outside it.">
|
||||
<SelectField
|
||||
value={shiftId}
|
||||
onChange={setShiftId}
|
||||
options={[
|
||||
@@ -500,112 +515,26 @@ export function TillDrawer({
|
||||
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
||||
})),
|
||||
]}
|
||||
description="Informational. Nothing refuses a bill rung outside it."
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can open a till"
|
||||
inactiveNote="Cannot sign in at a till. Their bills are kept."
|
||||
/>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Active / inactive, as a pair of buttons rather than a checkbox.
|
||||
*
|
||||
* Deactivating is the closest thing this console has to a delete, so it says
|
||||
* what it does in words on the control itself instead of leaving the
|
||||
* consequence to a checkbox label the eye skips.
|
||||
*/
|
||||
function ActiveToggle({
|
||||
isActive,
|
||||
onChange,
|
||||
activeNote,
|
||||
inactiveNote,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
activeNote: string;
|
||||
inactiveNote: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
Access
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Section title="Access">
|
||||
<ChoiceGroup>
|
||||
<Choice
|
||||
label="Active"
|
||||
note={activeNote}
|
||||
note="Can open a till"
|
||||
isSelected={isActive}
|
||||
onSelect={() => onChange(true)}
|
||||
onSelect={() => setIsActive(true)}
|
||||
/>
|
||||
<Choice
|
||||
label="Inactive"
|
||||
note={inactiveNote}
|
||||
note="Cannot sign in at a till. Their bills are kept."
|
||||
isSelected={!isActive}
|
||||
onSelect={() => onChange(false)}
|
||||
onSelect={() => setIsActive(false)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isSelected}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
||||
transition: 'border-color .15s, background .15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{isSelected ? <Check size={12} /> : null}
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
||||
{note}
|
||||
</span>
|
||||
</button>
|
||||
</ChoiceGroup>
|
||||
</Section>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Info } from 'lucide-react';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
|
||||
@@ -63,7 +64,7 @@ export function PlanVsActualPanel({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the day…
|
||||
@@ -75,7 +76,7 @@ export function PlanVsActualPanel({
|
||||
|
||||
if (finished.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing finished in this range
|
||||
@@ -94,7 +95,7 @@ export function PlanVsActualPanel({
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
{/* ── The promise ────────────────────────────────────────────────── */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Against the promise
|
||||
@@ -134,7 +135,7 @@ export function PlanVsActualPanel({
|
||||
</Card>
|
||||
|
||||
{/* ── Where the time goes ────────────────────────────────────────── */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -191,7 +192,7 @@ export function PlanVsActualPanel({
|
||||
</Card>
|
||||
|
||||
{/* ── Distance ───────────────────────────────────────────────────── */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Distance
|
||||
@@ -222,7 +223,12 @@ export function PlanVsActualPanel({
|
||||
</Card>
|
||||
|
||||
{/* ── The deliveries themselves ──────────────────────────────────── */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Planned vs actual"
|
||||
count={paged.total}
|
||||
countLabel="deliveries"
|
||||
footer={<TablePager paged={paged} label="deliveries" />}
|
||||
>
|
||||
<VStack gap={1} padding={2} align="stretch">
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -298,8 +304,7 @@ export function PlanVsActualPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="deliveries" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||
import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { CatalogueProduct, Product } from '@/api/types';
|
||||
import type { CatalogueProduct, Product, StockRequest } from '@/api/types';
|
||||
/* A pure reading of a `StockRequest`, not a Store user component — the same
|
||||
three functions the Store user's table chips from, so the row and this
|
||||
drawer cannot label one request two ways. */
|
||||
import { REQUEST_COLOR, REQUEST_LABEL, requestState } from '@/features/store-user/requestState';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
||||
import { aisleLabelOf, UNCATEGORISED_AISLE } from './appAisle';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
@@ -57,9 +62,23 @@ import {
|
||||
export function ProductDrawer({
|
||||
product,
|
||||
canManage = true,
|
||||
request,
|
||||
onClose,
|
||||
}: {
|
||||
product: Product;
|
||||
/**
|
||||
* The branch's latest stock request for this product, where there is one.
|
||||
*
|
||||
* Passed in rather than fetched: the Store user's catalogue already holds
|
||||
* every request for the page — `latestByProduct` — and a second read here
|
||||
* would be a request per drawer open for data already in hand. It is absent
|
||||
* in the merchant's console, which has no "ask for stock" at all.
|
||||
*
|
||||
* It is here because the row carries a Stock request column, and a drawer
|
||||
* that drops a column the row shows makes the reader go back to the list to
|
||||
* find out what they were just looking at.
|
||||
*/
|
||||
request?: StockRequest | undefined;
|
||||
/**
|
||||
* False in the Store user workspace.
|
||||
*
|
||||
@@ -77,11 +96,20 @@ export function ProductDrawer({
|
||||
const [shot, setShot] = useState(0);
|
||||
|
||||
/* The aisle name for this product's subcategoryid, from the same list the
|
||||
customer app groups by. */
|
||||
customer app groups by — and through the same helper the products table
|
||||
prints with, because the two used to disagree: the row fell back to the
|
||||
product's own `categoryname` when the platform list had no entry, and this
|
||||
drawer did not, so one product read "Dairy" in the list and
|
||||
"Uncategorized" in the sheet opened from it. */
|
||||
const aisles = useAppAisles(tenantid || undefined);
|
||||
const aisleName =
|
||||
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
|
||||
null;
|
||||
const aisleNames = useMemo(() => {
|
||||
const out = new Map<number, string>();
|
||||
for (const row of aisles.data ?? []) {
|
||||
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
|
||||
}
|
||||
return out;
|
||||
}, [aisles.data]);
|
||||
const aisle = aisleLabelOf(product, aisleNames);
|
||||
|
||||
/*
|
||||
What the catalogue said, preferred from the product's own copy.
|
||||
@@ -115,6 +143,18 @@ export function ProductDrawer({
|
||||
});
|
||||
|
||||
|
||||
/* The row's chip, rebuilt from the same reading of the request the table
|
||||
uses. Quantity is stated because "Asked for" without a number is the half
|
||||
of the fact nobody needs. */
|
||||
const requestStateValue = requestState(request);
|
||||
const requestChip = requestStateValue
|
||||
? {
|
||||
label: REQUEST_LABEL[requestStateValue],
|
||||
colour: REQUEST_COLOR[requestStateValue],
|
||||
detail: `${count(request?.qty ?? 0)} ${[product.unitvalue, product.productunit].filter(Boolean).join(' ') || 'units'}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
const price = effectivePrice(product);
|
||||
const cost = product.productcost ?? 0;
|
||||
/* Margin is derived, not stored. Shown only when both halves are real — a
|
||||
@@ -238,15 +278,32 @@ export function ProductDrawer({
|
||||
cosmetic default here: it is the heading the app will print. */}
|
||||
<Row
|
||||
label="Shown in the app under"
|
||||
value={aisleName ?? 'Uncategorized'}
|
||||
value={aisle}
|
||||
icon={<Tag size={14} />}
|
||||
{...(aisleName ? {} : { tone: 'muted' as const })}
|
||||
{...(aisle === UNCATEGORISED_AISLE ? { tone: 'muted' as const } : {})}
|
||||
/>
|
||||
<Row
|
||||
label="Unit"
|
||||
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
||||
icon={<Ruler size={14} />}
|
||||
/>
|
||||
{/* The row's own Stock request column, repeated here rather than
|
||||
dropped. Same chip, same wording, same quantity — it is read from
|
||||
the same `requestState` the table reads. */}
|
||||
{requestChip ? (
|
||||
<Row
|
||||
label="Stock request"
|
||||
value={
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
<Badge label={requestChip.label} colour={requestChip.colour} />
|
||||
{requestChip.detail}
|
||||
</span>
|
||||
}
|
||||
icon={<ClipboardList size={14} />}
|
||||
/>
|
||||
) : null}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
import type { Product } from '@/api/types';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
||||
import { aisleLabelOf } from './appAisle';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { count, money } from './format';
|
||||
import { ProductDrawer } from './ProductDrawer';
|
||||
@@ -133,11 +133,11 @@ export function ProductsPanel({
|
||||
[aisles.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
action={
|
||||
/* The panel's own header owns the category filter now, rather than a
|
||||
`SectionHeader` floating above the card. It filters THIS table; sitting
|
||||
outside the panel it read as a page-level control that happened to be
|
||||
nearby. */
|
||||
const filter = (
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Category"
|
||||
@@ -149,9 +149,10 @@ export function ProductsPanel({
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
{selection.count > 0 ? (
|
||||
<div className="bulkbar" data-active="yes">
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
@@ -180,6 +181,7 @@ export function ProductsPanel({
|
||||
selection={selection}
|
||||
queryKey={category}
|
||||
isLoading={products.isLoading}
|
||||
actions={filter}
|
||||
onOpen={setOpen}
|
||||
onPrice={(product) => setPublishing([product])}
|
||||
/>
|
||||
@@ -222,6 +224,7 @@ function ProductTable({
|
||||
aisleNames,
|
||||
selection,
|
||||
isLoading,
|
||||
actions,
|
||||
onOpen,
|
||||
onPrice,
|
||||
queryKey,
|
||||
@@ -232,6 +235,8 @@ function ProductTable({
|
||||
/** The tick state, owned by the panel so its bar can act on the choice. */
|
||||
selection: ReturnType<typeof useSelection>;
|
||||
isLoading: boolean;
|
||||
/** The category filter, rendered in the panel header beside the count. */
|
||||
actions: ReactNode;
|
||||
onOpen: (product: Product) => void;
|
||||
onPrice: (product: Product) => void;
|
||||
/** The category filter; changing it returns the pager to page 1. */
|
||||
@@ -242,20 +247,18 @@ function ProductTable({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Panel title="Products" actions={actions} padding="md">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Reading your catalogue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Panel title="Products" count={0} countLabel="products" actions={actions} padding="md">
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing here yet
|
||||
</Text>
|
||||
@@ -265,12 +268,18 @@ function ProductTable({
|
||||
were expecting rows.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Products"
|
||||
count={rows.length}
|
||||
countLabel="products"
|
||||
actions={actions}
|
||||
footer={<TablePager paged={paged} label="products" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
{/* `table-layout: fixed` with an explicit colgroup. Without it the
|
||||
browser sizes every column to its content, so Category and Status
|
||||
@@ -342,8 +351,7 @@ function ProductTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="products" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -445,11 +453,9 @@ function Row({
|
||||
>
|
||||
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
|
||||
selects `a.*` and joins no subcategory table — so it is looked up
|
||||
from the platform list the app itself groups by. */}
|
||||
{aisleNames.get(product.subcategoryid ?? 0) ??
|
||||
product.categoryname ??
|
||||
product.Subcategoryname ??
|
||||
'Uncategorized'}
|
||||
from the platform list the app itself groups by. The drawer prints
|
||||
the same string from the same helper, or the two disagree. */}
|
||||
{aisleLabelOf(product, aisleNames)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { coverage, profitability } from './profitability';
|
||||
@@ -48,7 +49,7 @@ export function ProfitPanel({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the day…
|
||||
@@ -60,7 +61,7 @@ export function ProfitPanel({
|
||||
|
||||
if (result.deliveries === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing finished in this range
|
||||
@@ -76,7 +77,7 @@ export function ProfitPanel({
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -129,7 +130,7 @@ export function ProfitPanel({
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
By rider
|
||||
@@ -167,7 +168,12 @@ export function ProfitPanel({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Delivery profit"
|
||||
count={paged.total}
|
||||
countLabel="deliveries"
|
||||
footer={<TablePager paged={paged} label="deliveries" />}
|
||||
>
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivery by delivery
|
||||
@@ -211,8 +217,7 @@ export function ProfitPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="deliveries" />
|
||||
</Card>
|
||||
</Panel>
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
|
||||
@@ -330,7 +330,7 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
||||
value={
|
||||
<Badge
|
||||
label={`${results.length - failed.length} of ${results.length}`}
|
||||
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
|
||||
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : 'var(--color-success, #1f9d55)'}
|
||||
/>
|
||||
}
|
||||
{...(failed.length === 0 ? { tone: 'success' as const } : {})}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Info } from 'lucide-react';
|
||||
import { Bike, Building2, CircleDot } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import { useRiderShifts } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { DrawerButton } from './drawerKit';
|
||||
import './pages/deliveries.css';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Field,
|
||||
FieldRow,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
SelectField,
|
||||
TextField,
|
||||
} from './drawerKit';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/**
|
||||
* Hiring a rider, or editing one.
|
||||
@@ -30,6 +37,15 @@ import './pages/deliveries.css';
|
||||
* and start a shift, because that list requires a clock-in stamped today. That
|
||||
* is correct behaviour and it looks exactly like a failed save, so the drawer
|
||||
* says it before you press the button rather than leaving you to wonder.
|
||||
*
|
||||
* ── What it used to leave out ───────────────────────────────────────────────
|
||||
*
|
||||
* The roster table carries Partner and On shift, and this drawer carried
|
||||
* neither — so opening a rider to check who they ride for lost the answer the
|
||||
* row had just given. Both are FACTS rather than fields: whose rider this is
|
||||
* comes from the scope the drawer was opened in, and being on shift means a
|
||||
* clock-in stamped today, which the console cannot set. They are stated at the
|
||||
* top, above the things that can actually be typed.
|
||||
*/
|
||||
/**
|
||||
* Whose rider this is.
|
||||
@@ -140,12 +156,34 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
||||
applocationid > 0 &&
|
||||
!save.isPending;
|
||||
|
||||
/* Whose rider this is, in the words the roster's Partner column uses. An own
|
||||
rider reads "Own rider" there and here, rather than an empty cell in one
|
||||
place and nothing at all in the other. */
|
||||
const partnerName =
|
||||
owner.kind === 'partner' ? owner.partnername : (row?.partnername ?? 'Own rider');
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={isNew ? 'Add a rider' : row.fullname || 'Rider'}
|
||||
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
|
||||
width={480}
|
||||
onClose={onClose}
|
||||
/* The roster's Status column, beside the title — the same chip, and the
|
||||
reason somebody opened this rider in the first place. */
|
||||
{...(isNew
|
||||
? {}
|
||||
: {
|
||||
meta: (
|
||||
<Badge
|
||||
label={row.status || 'Active'}
|
||||
colour={
|
||||
(row.status ?? 'Active') === 'Active'
|
||||
? 'var(--color-success, #1f9d55)'
|
||||
: 'var(--color-warning, #b7860b)'
|
||||
}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
@@ -160,46 +198,61 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{problem ? <Alert>{problem}</Alert> : null}
|
||||
|
||||
{/* The region is stated, not offered. If the branch has none, saying so
|
||||
here beats a 400 after the form is filled in. */}
|
||||
{applocationid === 0 ? (
|
||||
<div className="rider-note" data-tone="stop">
|
||||
<Info size={15} />
|
||||
<Text type="body" size="sm">
|
||||
This branch has no delivery region set, so a rider added to it would not appear
|
||||
anywhere. Set the region on the branch first.
|
||||
</Text>
|
||||
</div>
|
||||
<Alert tone="warning">
|
||||
This branch has no delivery region set, so a rider added to it would not appear anywhere.
|
||||
Set the region on the branch first.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<TextInput label="First name" value={form.firstname} onChange={set('firstname')} />
|
||||
<TextInput label="Last name" value={form.lastname} onChange={set('lastname')} />
|
||||
</HStack>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form.contactno}
|
||||
onChange={set('contactno')}
|
||||
description="How the shop reaches them, and how the app signs them in."
|
||||
{/* ── What the roster row said ───────────────────────────────────────
|
||||
Read-only, because neither can be typed: ownership comes from the
|
||||
scope this drawer was opened in, and being on shift means a clock-in
|
||||
stamped today, which only the rider app writes. */}
|
||||
{isNew ? null : (
|
||||
<DrawerCard>
|
||||
<Row label="Partner" value={partnerName} icon={<Building2 size={14} />} />
|
||||
<Row
|
||||
label="On shift"
|
||||
value={row.isonduty ? 'On shift' : 'Off shift'}
|
||||
icon={<CircleDot size={14} />}
|
||||
{...(row.isonduty ? { tone: 'success' as const } : { tone: 'muted' as const })}
|
||||
/>
|
||||
<TextInput label="Email" value={form.email} onChange={set('email')} />
|
||||
</VStack>
|
||||
</DrawerCard>
|
||||
)}
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
Shift and partner
|
||||
</Text>
|
||||
<Selector
|
||||
label="Shift"
|
||||
size="sm"
|
||||
<Section title="Who they are">
|
||||
<FieldRow>
|
||||
<Field label="First name" isRequired>
|
||||
<TextField value={form.firstname} onChange={set('firstname')} />
|
||||
</Field>
|
||||
<Field label="Last name">
|
||||
<TextField value={form.lastname} onChange={set('lastname')} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
|
||||
<Field
|
||||
label="Phone"
|
||||
isRequired
|
||||
description="How the shop reaches them, and how the app signs them in."
|
||||
>
|
||||
<TextField type="tel" value={form.contactno} onChange={set('contactno')} />
|
||||
</Field>
|
||||
|
||||
<Field label="Email">
|
||||
<TextField type="email" value={form.email} onChange={set('email')} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Shift and branch">
|
||||
<Field label="Shift" isRequired>
|
||||
<SelectField
|
||||
value={form.shiftid}
|
||||
onChange={(value) => set('shiftid')(String(value))}
|
||||
options={(shifts.data ?? []).map((shift) => ({
|
||||
value: String(shift.shiftid),
|
||||
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
|
||||
}))}
|
||||
onChange={set('shiftid')}
|
||||
placeholder={
|
||||
shifts.isLoading
|
||||
? 'Loading shifts…'
|
||||
@@ -207,94 +260,89 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
||||
? 'Choose a shift'
|
||||
: 'No shifts set up for this region'
|
||||
}
|
||||
options={(shifts.data ?? []).map((shift) => ({
|
||||
value: String(shift.shiftid),
|
||||
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* A merchant's rider works out of one of that merchant's branches.
|
||||
A partner's does not — a partner supplies several merchants and is
|
||||
tied to none of their outlets — so the question is only asked
|
||||
where it has an answer. */}
|
||||
{owner.kind === 'tenant' ? (
|
||||
<Selector
|
||||
<Field
|
||||
label="Branch"
|
||||
size="sm"
|
||||
description="Which of your outlets this rider works out of. It can be changed later."
|
||||
>
|
||||
<SelectField
|
||||
value={form.locationid}
|
||||
onChange={(value) => set('locationid')(String(value))}
|
||||
onChange={set('locationid')}
|
||||
placeholder="Choose the branch they ride from"
|
||||
options={owner.branches.map((entry) => ({
|
||||
value: String(entry.locationid),
|
||||
label: entry.locationname || `Branch ${entry.locationid}`,
|
||||
label: branchLabel(entry.locationname) || `Branch ${entry.locationid}`,
|
||||
}))}
|
||||
placeholder="Choose the branch they ride from"
|
||||
description="Which of your outlets this rider works out of. It can be changed later."
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rider-note">
|
||||
<Info size={14} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
<Note>
|
||||
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
|
||||
partner supplies in this region.
|
||||
</Text>
|
||||
</div>
|
||||
</Note>
|
||||
)}
|
||||
</VStack>
|
||||
</Section>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
Vehicle and papers
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<TextInput
|
||||
label="Vehicle"
|
||||
<Section title="Vehicle and papers">
|
||||
<FieldRow>
|
||||
<Field label="Vehicle">
|
||||
<TextField
|
||||
value={form.vehiclename}
|
||||
onChange={set('vehiclename')}
|
||||
placeholder="Bike, Scooter…"
|
||||
/>
|
||||
<TextInput label="Number plate" value={form.vehicleno} onChange={set('vehicleno')} />
|
||||
</HStack>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<TextInput label="Licence no." value={form.licenseno} onChange={set('licenseno')} />
|
||||
<TextInput
|
||||
label="Registration no."
|
||||
value={form.registrationno}
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
</HStack>
|
||||
<TextInput
|
||||
label="ID number"
|
||||
value={form.identificationno}
|
||||
onChange={set('identificationno')}
|
||||
/>
|
||||
</VStack>
|
||||
</Field>
|
||||
<Field label="Number plate">
|
||||
<TextField value={form.vehicleno} onChange={set('vehicleno')} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label="Licence no.">
|
||||
<TextField value={form.licenseno} onChange={set('licenseno')} />
|
||||
</Field>
|
||||
<Field label="Registration no.">
|
||||
<TextField value={form.registrationno} onChange={set('registrationno')} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label="ID number">
|
||||
<TextField value={form.identificationno} onChange={set('identificationno')} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{!isNew ? (
|
||||
<Selector
|
||||
label="Status"
|
||||
size="sm"
|
||||
<Section title="Status">
|
||||
<Field label="Status">
|
||||
<SelectField
|
||||
value={form.status}
|
||||
onChange={(value) => set('status')(String(value))}
|
||||
onChange={set('status')}
|
||||
options={[
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'InActive', label: 'Inactive — cannot be given work' },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{problem ? (
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
</Field>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* Said before the button, not after. This is the behaviour most likely
|
||||
to be read as a failed save. */}
|
||||
{isNew ? (
|
||||
<div className="rider-note">
|
||||
<Info size={15} />
|
||||
<Text type="body" size="xsm">
|
||||
They will show here straight away, but not in the fleet you assign deliveries from
|
||||
until they sign in to the rider app and start a shift.
|
||||
</Text>
|
||||
</div>
|
||||
<Note>
|
||||
They will show here straight away, but not in the fleet you assign deliveries from until
|
||||
they sign in to the rider app and start a shift.
|
||||
</Note>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser';
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { DrawerButton } from './drawerKit';
|
||||
import { Alert, DrawerButton } from './drawerKit';
|
||||
import { buildDeliveries, riderName } from './assignDelivery';
|
||||
import { money } from './format';
|
||||
import { orderValue } from './orderStatus';
|
||||
@@ -271,11 +271,7 @@ export function RoutePlanDrawer({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{problem ? (
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
) : null}
|
||||
{problem ? <Alert>{problem}</Alert> : null}
|
||||
|
||||
{outcome ? (
|
||||
<Text type="body" size="sm" role="status">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
aisleForCategory,
|
||||
aisleIdForCategory,
|
||||
aisleIdsFrom,
|
||||
aisleLabelOf,
|
||||
UNCATEGORISED_AISLE,
|
||||
} from './appAisle';
|
||||
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
|
||||
|
||||
@@ -89,3 +91,36 @@ test('every aisle has an id, and no two share one', () => {
|
||||
assert.equal(new Set(values).size, values.length);
|
||||
assert.ok(values.every((id) => id > 0));
|
||||
});
|
||||
|
||||
/* ── The label a row and a drawer must agree on ───────────────────────────── */
|
||||
|
||||
// The bug this replaces: the table fell back to `categoryname`, the drawer did
|
||||
// not, so one product read "Dairy" in the list and "Uncategorized" in the sheet
|
||||
// opened from it.
|
||||
test('the printed aisle prefers the platform list, then the row own names', () => {
|
||||
const names = new Map([[15, 'Dairy, Deli & Egg']]);
|
||||
assert.equal(aisleLabelOf({ subcategoryid: 15 }, names), 'Dairy, Deli & Egg');
|
||||
assert.equal(
|
||||
aisleLabelOf({ subcategoryid: 99, categoryname: 'Dairy' }, names),
|
||||
'Dairy',
|
||||
'a subcategoryid the platform list does not know still has the row own name',
|
||||
);
|
||||
assert.equal(
|
||||
aisleLabelOf({ subcategoryid: 99, Subcategoryname: 'Snacks' }, names),
|
||||
'Snacks',
|
||||
'the misspelt wire field is read too',
|
||||
);
|
||||
});
|
||||
|
||||
// `categoryname` comes back as '' on every row `getlocationproducts` returns,
|
||||
// and `??` does not skip an empty string — it printed a blank cell.
|
||||
test('a blank name is absent, not a label', () => {
|
||||
const names = new Map<number, string>();
|
||||
assert.equal(aisleLabelOf({ subcategoryid: 0, categoryname: '' }, names), 'Uncategorized');
|
||||
assert.equal(aisleLabelOf({ categoryname: ' ' }, names), 'Uncategorized');
|
||||
assert.equal(aisleLabelOf({}, names), 'Uncategorized');
|
||||
});
|
||||
|
||||
test('"Uncategorized" is the app own heading, spelt the app own way', () => {
|
||||
assert.equal(UNCATEGORISED_AISLE, 'Uncategorized');
|
||||
});
|
||||
|
||||
@@ -196,3 +196,44 @@ export function aisleIdForCategory(
|
||||
if (!aisle) return 0;
|
||||
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
|
||||
}
|
||||
|
||||
/**
|
||||
* The aisle name to PRINT for a product, on a row or in a drawer.
|
||||
*
|
||||
* This exists because the table and the drawer each grew their own version of
|
||||
* it and they disagreed: the row fell back to `categoryname` when the platform
|
||||
* list had no entry for the product's `subcategoryid`, and the drawer did not —
|
||||
* so the same product read "Dairy" in the list and "Uncategorized" the moment
|
||||
* you opened it. There is one answer to "where does the app show this", and it
|
||||
* is here.
|
||||
*
|
||||
* Blank is treated as absent, which `??` alone does not do. `categoryname` is
|
||||
* empty on every row `getlocationproducts` returns — the app's category 2 has
|
||||
* no row in `productcategories` — and falling back to an empty string printed a
|
||||
* blank cell where "Uncategorized" is the truth: it is the heading the customer
|
||||
* app will actually render for a product with no aisle.
|
||||
*/
|
||||
export function aisleLabelOf(
|
||||
product: {
|
||||
subcategoryid?: number | undefined;
|
||||
categoryname?: string | undefined;
|
||||
Subcategoryname?: string | undefined;
|
||||
},
|
||||
aisleNames: ReadonlyMap<number, string>,
|
||||
): string {
|
||||
const named = aisleNames.get(product.subcategoryid ?? 0);
|
||||
return (
|
||||
firstFilled(named, product.categoryname, product.Subcategoryname) ?? UNCATEGORISED_AISLE
|
||||
);
|
||||
}
|
||||
|
||||
/** The heading the customer app prints for anything with no aisle. */
|
||||
export const UNCATEGORISED_AISLE = 'Uncategorized';
|
||||
|
||||
function firstFilled(...values: (string | undefined)[]): string | undefined {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -124,3 +124,42 @@ export function byUrgency(a: CounterRow, b: CounterRow): number {
|
||||
const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
|
||||
return rank[a.state] - rank[b.state];
|
||||
}
|
||||
|
||||
/* ── How a counter's state is named and coloured ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* The four states, in the words and colours the whole console uses for them.
|
||||
*
|
||||
* These lived as two private maps inside `CountersPage`, which is why the
|
||||
* drawer opened from that table showed no status at all: the vocabulary was not
|
||||
* reachable from it. A state has one name and one colour wherever it is
|
||||
* printed — the table's pill, the attention card, the drawer's header badge.
|
||||
*/
|
||||
export const COUNTER_STATE_LABEL: Record<CounterState, string> = {
|
||||
active: 'Active',
|
||||
attention: 'Attention',
|
||||
never: 'Never used',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
/** The page's own pill tone, for `data-tone` in `counters.css`. */
|
||||
export const COUNTER_STATE_TONE: Record<CounterState, string> = {
|
||||
active: 'healthy',
|
||||
attention: 'attention',
|
||||
never: 'neutral',
|
||||
offline: 'critical',
|
||||
};
|
||||
|
||||
/**
|
||||
* The same four states as a colour, for the drawer badge.
|
||||
*
|
||||
* The badge takes a colour rather than a tone name because `drawerKit`'s
|
||||
* `Badge` is fed one by every other drawer — statuses in this console come from
|
||||
* several vocabularies and the shared badge deliberately knows none of them.
|
||||
*/
|
||||
export const COUNTER_STATE_COLOUR: Record<CounterState, string> = {
|
||||
active: 'var(--color-success, #1f9d55)',
|
||||
attention: 'var(--color-warning, #b7860b)',
|
||||
never: 'var(--color-ink-4)',
|
||||
offline: 'var(--color-error, #d64545)',
|
||||
};
|
||||
|
||||
38
src/features/store-admin/dateRangePicker.css
Normal file
38
src/features/store-admin/dateRangePicker.css
Normal 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%; }
|
||||
}
|
||||
@@ -1,11 +1,72 @@
|
||||
/* ══ The drawer design system ══════════════════════════════════════════════
|
||||
One shell, one header, one card, one row, one badge, one button — used by
|
||||
every side drawer in the console so they read as one product rather than as
|
||||
several. Everything below is built on the tokens already in `index.css`:
|
||||
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
|
||||
the muted greys. No drawer defines a colour of its own.
|
||||
One shell, one header, one card, one row, one badge, one button, one field —
|
||||
used by every side drawer in the console so they read as one product rather
|
||||
than as several.
|
||||
|
||||
Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */
|
||||
── What changed in this pass, and why ──────────────────────────────────────
|
||||
|
||||
1. EVERY SIZE IS A TOKEN NOW. The old sheet typed its own numbers at each
|
||||
use — 13.5px here, 14.5px there, 11.5px somewhere else, four radii, three
|
||||
control heights. Half-pixel type is what made the drawers look hand-made
|
||||
beside the tables they open from: no two labels sat on the same baseline
|
||||
and no two boxes shared a corner. There is one ramp below and nothing
|
||||
outside it.
|
||||
|
||||
2. THE HEADER IS THE SURFACE, NOT THE BRAND. It used to be a bar of
|
||||
saturated purple. That cost real information: a status badge cannot be
|
||||
read in its own colour on a purple ground, so the badge had to be
|
||||
re-invented there as a white chip with a coloured dot — the one badge in
|
||||
the console that did not match the chip in the row it was opened from. A
|
||||
white header with a hairline gives the badge its colour back, lets the
|
||||
title sit in the same ink as the page, and stops the drawer announcing
|
||||
itself louder than the data it carries. The brand still has the primary
|
||||
button, the icons and the timeline.
|
||||
|
||||
The header keeps its 57px, which is not cosmetic: `AppShell`'s nav bar is
|
||||
a 56px row plus a 1px rule, so an open drawer's header finishes on exactly
|
||||
the line the nav bar finishes on, edge to edge across the screen.
|
||||
|
||||
3. THE KIT COVERS FORMS. Seven drawers are forms and the kit had nothing for
|
||||
them, so each reached for Astryx's inputs and its own inline styles. The
|
||||
field, input, select, choice and alert rules below are that missing half.
|
||||
|
||||
Everything reads from the brand tokens in `index.css`. No rule here invents
|
||||
a colour. */
|
||||
|
||||
.drawer-root {
|
||||
/* ── The ramp ──────────────────────────────────────────────────────────
|
||||
Whole pixels, and a real ratio between the steps. Anything that needs a
|
||||
size takes one of these. */
|
||||
--dr-text-xs: 11px; /* eyebrow caps: section titles, metric labels */
|
||||
--dr-text-sm: 12px; /* secondary: subtitles, notes under a value */
|
||||
--dr-text-md: 13px; /* body: row labels, help text, buttons */
|
||||
--dr-text-lg: 14px; /* emphasis: row values, field labels, titles */
|
||||
--dr-text-xl: 18px; /* the one figure a drawer is opened to read */
|
||||
|
||||
/* ── Spacing, on a 4px grid ─────────────────────────────────────────── */
|
||||
--dr-gap-xs: 4px;
|
||||
--dr-gap-sm: 8px;
|
||||
--dr-gap-md: 12px;
|
||||
--dr-gap-lg: 16px;
|
||||
--dr-gap-xl: 20px;
|
||||
|
||||
/* ── Shape ────────────────────────────────────────────────────────────
|
||||
The card radius is the CONSOLE's card radius, not one of the drawer's
|
||||
own. A card in a drawer and a card on the page behind it are the same
|
||||
object seen in two places, and this file had them at 10 and 16.
|
||||
|
||||
`--dr-radius-sm` stays a local number because it is a CONTROL radius —
|
||||
the close button, an input, a photo thumbnail — not a small card. */
|
||||
--dr-radius: var(--card-radius);
|
||||
--dr-radius-sm: 8px;
|
||||
--dr-control: 36px; /* every button and input is this tall */
|
||||
--dr-pad-x: 20px; /* the panel's own gutter, used by head/body/foot */
|
||||
--dr-row-pad: 11px 14px;
|
||||
|
||||
/* ── Lines ──────────────────────────────────────────────────────────── */
|
||||
--dr-line: var(--color-line);
|
||||
--dr-line-soft: color-mix(in oklab, var(--color-line) 55%, transparent);
|
||||
}
|
||||
|
||||
/* ── Shell ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -26,7 +87,7 @@
|
||||
inset: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgb(15 23 42 / 0.32);
|
||||
background: rgb(15 23 42 / 0.28);
|
||||
cursor: pointer;
|
||||
animation: drawer-fade 180ms ease;
|
||||
}
|
||||
@@ -40,7 +101,10 @@
|
||||
height: 100%;
|
||||
max-width: 92vw;
|
||||
background: var(--color-surface);
|
||||
box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18);
|
||||
/* A hairline as well as the shadow. With a white header the panel needs an
|
||||
edge of its own, or it bleeds into a white table behind it. */
|
||||
border-left: 1px solid var(--dr-line);
|
||||
box-shadow: -32px 0 64px -24px rgb(15 23 42 / 0.22);
|
||||
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@@ -59,36 +123,26 @@
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────────
|
||||
|
||||
ONE BAR HIGH, and the same bar the page wears.
|
||||
ONE BAR HIGH, and the same bar the page wears — see the note at the top of
|
||||
this file for the 57 and for why it is no longer purple.
|
||||
|
||||
`AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's
|
||||
header is 56 + 1 too: open a drawer and its header sits on exactly the line
|
||||
the nav bar sits on, edge to edge across the screen, instead of a 116px block
|
||||
starting level with it and finishing 60px lower.
|
||||
|
||||
Getting there meant the header becoming a ROW rather than a stack: title and
|
||||
subtitle share a line-and-a-half in the middle, the status badge sits beside
|
||||
them, and the close button ends the row. Nothing was dropped to make it fit —
|
||||
the same four things are in it.
|
||||
|
||||
It is painted in the brand purple. The one place a drawer needs to announce
|
||||
itself is the moment it slides in over the page, and the purple does that
|
||||
without the panel needing a border or a heavier shadow. */
|
||||
It is a ROW rather than a stack: title and subtitle share a line and a half
|
||||
in the middle, the status badge sits beside them, and the close button ends
|
||||
the row. A stacked header was a third line, and a third line is what made
|
||||
this twice the height of the nav bar it now matches. */
|
||||
|
||||
.drawer-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--dr-gap-md);
|
||||
/* 57, not 56: box-sizing is border-box across the app, so the 1px rule
|
||||
below eats into the height. The nav bar is a 56px row PLUS its rule, and
|
||||
the two have to finish on the same pixel. */
|
||||
height: 57px;
|
||||
padding: 0 12px 0 20px;
|
||||
background: var(--color-brand);
|
||||
/* A darker purple, not the grey page rule: a grey seam across a saturated
|
||||
ground reads as a rendering fault rather than an edge. */
|
||||
border-bottom: 1px solid var(--color-brand-strong);
|
||||
padding: 0 var(--dr-gap-md) 0 var(--dr-pad-x);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--dr-line);
|
||||
}
|
||||
|
||||
.drawer-head-text {
|
||||
@@ -106,42 +160,44 @@
|
||||
full text is on the page behind it as well as in the tooltip. */
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font: 600 15px/1.3 var(--font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
color: #fff;
|
||||
font: 600 var(--dr-text-lg)/1.3 var(--font-sans);
|
||||
letter-spacing: -0.006em;
|
||||
color: var(--color-ink-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* An order id is a code, and a code reads better tabular. */
|
||||
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; }
|
||||
.drawer-title[data-mono='true'] {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--dr-text-md);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.drawer-sub {
|
||||
font: 400 11.5px/1.35 var(--font-sans);
|
||||
/* Not a grey: a grey on purple goes muddy. White at 72% keeps the same
|
||||
"quieter than the title" relationship the page's ink scale gives. */
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Beside the title now, not under it — that stacked row is what made the
|
||||
header three lines tall. It never wraps: a second badge shortens the title
|
||||
rather than pushing the row taller. */
|
||||
/* Beside the title, not under it — that stacked row is what made the header
|
||||
three lines tall. It never wraps: a second badge shortens the title rather
|
||||
than pushing the row taller. */
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--dr-gap-sm);
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
/* Capped, because the meta keeps its width and the title gives way. A branch
|
||||
named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
|
||||
.drawer-meta-text {
|
||||
font: 400 12px/1.4 var(--font-sans);
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
max-width: 30ch;
|
||||
font: 400 var(--dr-text-sm)/1.4 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
max-width: 24ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -150,18 +206,22 @@
|
||||
.drawer-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
border: 0;
|
||||
border-radius: var(--dr-radius-sm);
|
||||
border: 1px solid transparent;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
color: var(--color-ink-3);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease;
|
||||
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
.drawer-close:hover { background: rgb(255 255 255 / 0.26); }
|
||||
.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
||||
.drawer-close:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
border-color: var(--dr-line);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -169,11 +229,20 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Stops a scroll that reaches the end of the drawer from continuing into the
|
||||
page underneath. The page is also locked outright while a drawer is open —
|
||||
see `Drawer.tsx` — because this does nothing about a wheel over the scrim,
|
||||
which is most of the screen. */
|
||||
overscroll-behavior: contain;
|
||||
padding: 20px 24px 24px;
|
||||
/* Animates the moves the drawer makes for you: the uploads drawer's jump to
|
||||
its rejection panel, and the browser's own scroll when you tab to a field
|
||||
below the fold in a long form. The wheel is untouched. The global
|
||||
reduced-motion reset in `index.css` turns this back to `auto`. */
|
||||
scroll-behavior: smooth;
|
||||
padding: var(--dr-pad-x) var(--dr-pad-x) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: var(--dr-gap-xl);
|
||||
}
|
||||
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
|
||||
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
|
||||
@@ -197,9 +266,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
gap: var(--dr-gap-sm);
|
||||
padding: var(--dr-gap-md) var(--dr-pad-x);
|
||||
border-top: 1px solid var(--dr-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.drawer-foot > * { flex: 0 1 auto; }
|
||||
@@ -212,23 +281,24 @@
|
||||
|
||||
/* ── Section ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.drawer-section { display: flex; flex-direction: column; gap: var(--dr-gap-md); }
|
||||
|
||||
.drawer-section-title {
|
||||
font: 600 11.5px/1 var(--font-sans);
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||
letter-spacing: 0.075em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ── Card ───────────────────────────────────────────────────────────────── */
|
||||
/* One radius, one border, everywhere. Rows inside are separated by hairlines
|
||||
rather than by giving each its own card — a stack of cards for four labelled
|
||||
values is the clutter this redesign exists to remove. */
|
||||
values is the clutter this system exists to remove. */
|
||||
.drawer-card {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--dr-line);
|
||||
border-radius: var(--dr-radius);
|
||||
background: var(--card-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
|
||||
@@ -242,47 +312,53 @@
|
||||
.drawer-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
gap: var(--dr-gap-lg);
|
||||
align-items: baseline;
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
padding: var(--dr-row-pad);
|
||||
border-bottom: 1px solid var(--dr-line-soft);
|
||||
}
|
||||
.drawer-row:last-child { border-bottom: 0; }
|
||||
|
||||
.drawer-row-label {
|
||||
font: 400 13.5px/1.45 var(--font-sans);
|
||||
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--dr-gap-sm);
|
||||
}
|
||||
.drawer-row-label svg { color: var(--color-brand); flex: none; }
|
||||
.drawer-row-label svg { color: var(--color-ink-4); flex: none; }
|
||||
|
||||
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
|
||||
.drawer-row-value {
|
||||
font: 500 14px/1.45 var(--font-sans);
|
||||
font: 500 var(--dr-text-lg)/1.45 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; }
|
||||
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); }
|
||||
.drawer-row-value[data-tone='success'] { color: var(--color-success, #1f9d55); font-weight: 600; }
|
||||
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); font-weight: 400; }
|
||||
|
||||
/* A row that wraps its value under the label — for prose, not for figures. */
|
||||
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; }
|
||||
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; }
|
||||
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: var(--dr-gap-xs); }
|
||||
.drawer-row[data-stacked='true'] .drawer-row-value {
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
font-size: var(--dr-text-md);
|
||||
line-height: 1.55;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
/* ── Metric grid ────────────────────────────────────────────────────────── */
|
||||
/* Three equal columns. Equal, not auto: the whole point is that the figures sit
|
||||
on a rhythm, and `auto` columns move them every time a value changes width. */
|
||||
/* Equal columns, not auto: the whole point is that the figures sit on a
|
||||
rhythm, and `auto` columns move them every time a value changes width. */
|
||||
.drawer-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--color-line);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--dr-line);
|
||||
border: 1px solid var(--dr-line);
|
||||
border-radius: var(--dr-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -291,66 +367,54 @@
|
||||
.drawer-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
gap: var(--dr-gap-xs);
|
||||
padding: var(--dr-gap-md) 14px;
|
||||
background: var(--color-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.drawer-metric-label {
|
||||
font: 600 11px/1 var(--font-sans);
|
||||
letter-spacing: 0.07em;
|
||||
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||
letter-spacing: 0.065em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-metric-value {
|
||||
font: 600 17px/1.2 var(--font-sans);
|
||||
font: 600 var(--dr-text-xl)/1.2 var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; }
|
||||
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); }
|
||||
.drawer-metric-value[data-size='sm'] { font-size: var(--dr-text-lg); letter-spacing: 0; }
|
||||
.drawer-metric-note { font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); }
|
||||
|
||||
/* ── Status badge ───────────────────────────────────────────────────────── */
|
||||
/* One badge, tinted from a single colour, so every status in the console is the
|
||||
same shape and only the hue carries meaning. */
|
||||
same shape and only the hue carries meaning.
|
||||
|
||||
It looks the same in the header as it does on a card. That is the point of
|
||||
the header no longer being purple: the chip beside the title is now exactly
|
||||
the chip in the row the drawer was opened from, rather than a white-on-purple
|
||||
translation of it with the colour demoted to a dot. */
|
||||
.drawer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font: 600 12px/1.5 var(--font-sans);
|
||||
font: 600 var(--dr-text-sm)/1.5 var(--font-sans);
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
color: var(--badge);
|
||||
background: color-mix(in oklab, var(--badge) 12%, transparent);
|
||||
}
|
||||
|
||||
/* In the purple header the status colour cannot be the text colour — a 12%
|
||||
tint of green on purple is unreadable, and several statuses are darker than
|
||||
the ground. The label goes white on a translucent chip and the colour moves
|
||||
to a dot, lightened so it still separates from the purple. The status is
|
||||
still distinguishable at a glance, which is the badge's whole job. */
|
||||
.drawer-head .drawer-badge {
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 0.16);
|
||||
padding-left: 8px;
|
||||
}
|
||||
.drawer-head .drawer-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
background: color-mix(in oklab, var(--badge) 55%, white);
|
||||
background: color-mix(in oklab, var(--badge) 11%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--badge) 24%, transparent);
|
||||
}
|
||||
|
||||
/* ── Timeline ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-timeline { display: flex; flex-direction: column; }
|
||||
|
||||
.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; }
|
||||
.drawer-step { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: var(--dr-gap-md); }
|
||||
|
||||
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||
|
||||
@@ -362,7 +426,7 @@
|
||||
place-items: center;
|
||||
flex: none;
|
||||
background: var(--color-surface-sunken);
|
||||
border: 1px solid var(--color-line);
|
||||
border: 1px solid var(--dr-line);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-step-dot[data-done='true'] {
|
||||
@@ -375,7 +439,7 @@
|
||||
.drawer-step-dot[data-current='true'] {
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-brand);
|
||||
box-shadow: 0 0 0 4px var(--color-brand-tint);
|
||||
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||
}
|
||||
.drawer-step-dot[data-tone='danger'] {
|
||||
background: color-mix(in oklab, var(--color-error) 14%, transparent);
|
||||
@@ -384,24 +448,38 @@
|
||||
}
|
||||
|
||||
/* Purple only as far as the journey has actually got. */
|
||||
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); }
|
||||
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 18px; background: var(--dr-line); }
|
||||
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
|
||||
|
||||
.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.drawer-step-body {
|
||||
padding-bottom: var(--dr-gap-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
|
||||
.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
.drawer-step-label { font: 500 var(--dr-text-md)/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
|
||||
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
|
||||
.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; }
|
||||
.drawer-step-at {
|
||||
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Route ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; }
|
||||
.drawer-party {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: var(--dr-gap-md);
|
||||
padding: var(--dr-gap-md) 14px;
|
||||
}
|
||||
|
||||
.drawer-party-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--dr-radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
@@ -411,13 +489,21 @@
|
||||
|
||||
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.drawer-party-kind {
|
||||
font: 600 10.5px/1 var(--font-sans);
|
||||
letter-spacing: 0.09em;
|
||||
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||
letter-spacing: 0.075em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; }
|
||||
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; }
|
||||
.drawer-party-name {
|
||||
font: 600 var(--dr-text-lg)/1.35 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-party-detail {
|
||||
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The phone reads as something you could act on, without pretending to be a
|
||||
button — these drawers are opened on a desktop where tel: does nothing. */
|
||||
@@ -425,8 +511,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font: 500 12.5px/1 var(--font-sans);
|
||||
margin-top: var(--dr-gap-xs);
|
||||
font: 500 var(--dr-text-md)/1 var(--font-sans);
|
||||
color: var(--color-brand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-decoration: none;
|
||||
@@ -439,8 +525,8 @@
|
||||
place-items: center;
|
||||
height: 18px;
|
||||
color: var(--color-ink-4);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
border-top: 1px solid var(--dr-line-soft);
|
||||
border-bottom: 1px solid var(--dr-line-soft);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
@@ -450,49 +536,195 @@
|
||||
.drawer-note {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface-subtle);
|
||||
font: 400 13px/1.5 var(--font-sans);
|
||||
gap: var(--dr-gap-sm);
|
||||
padding: var(--dr-gap-md) 14px;
|
||||
border-radius: var(--dr-radius);
|
||||
border: 1px solid var(--dr-line);
|
||||
background: var(--card-bg-subtle);
|
||||
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; }
|
||||
.drawer-note svg { color: var(--color-ink-4); margin-top: 2px; flex: none; }
|
||||
.drawer-note strong { color: var(--color-ink-1); font-weight: 600; }
|
||||
.drawer-note[data-tone='brand'] {
|
||||
background: var(--color-brand-tint);
|
||||
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
|
||||
|
||||
/* ── Alert ──────────────────────────────────────────────────────────────── */
|
||||
/* The loud sibling of the note: a save that failed, a precondition that is not
|
||||
met. Three drawers each wrote their own version of this box; there is one. */
|
||||
.drawer-alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--dr-gap-sm);
|
||||
padding: var(--dr-gap-md) 14px;
|
||||
border-radius: var(--dr-radius);
|
||||
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
|
||||
}
|
||||
.drawer-alert svg { margin-top: 2px; flex: none; }
|
||||
.drawer-alert[data-tone='error'] {
|
||||
color: var(--color-error, #d64545);
|
||||
background: color-mix(in oklab, var(--color-error, #d64545) 7%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-error, #d64545) 22%, transparent);
|
||||
}
|
||||
.drawer-alert[data-tone='warning'] {
|
||||
color: var(--color-warning, #b7860b);
|
||||
background: color-mix(in oklab, var(--color-warning, #b7860b) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-warning, #b7860b) 24%, transparent);
|
||||
}
|
||||
|
||||
/* ── Fields ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
|
||||
.drawer-field-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.drawer-field-req { color: var(--color-error, #d64545); }
|
||||
|
||||
.drawer-field-help {
|
||||
font: 400 var(--dr-text-sm)/1.45 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-field-error {
|
||||
font: 500 var(--dr-text-sm)/1.45 var(--font-sans);
|
||||
color: var(--color-error, #d64545);
|
||||
}
|
||||
|
||||
/* Two fields on a line, stacking rather than squeezing. 200px is a first name
|
||||
and a last name side by side at 480px, and one above the other below it. */
|
||||
.drawer-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: var(--dr-gap-md);
|
||||
}
|
||||
|
||||
.drawer-input {
|
||||
width: 100%;
|
||||
height: var(--dr-control);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--dr-radius-sm);
|
||||
border: 1px solid var(--dr-line);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink-1);
|
||||
font: 400 var(--dr-text-lg)/1 var(--font-sans);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
/* Safari gives a text input its own inner shadow and a rounded-rect it will
|
||||
not let go of; the whole point of this rule is that the field matches the
|
||||
button beside it. */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.drawer-input::placeholder { color: var(--color-ink-4); }
|
||||
.drawer-input:hover:not(:disabled) { border-color: var(--color-ink-4); }
|
||||
.drawer-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||
}
|
||||
.drawer-input[aria-invalid='true'] {
|
||||
border-color: var(--color-error, #d64545);
|
||||
}
|
||||
.drawer-input[aria-invalid='true']:focus {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-error, #d64545) 14%, transparent);
|
||||
}
|
||||
.drawer-input:disabled {
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* A native select with the platform arrow suppressed and ours drawn on top, so
|
||||
a picker and a text field are the same object at a glance. */
|
||||
.drawer-select { position: relative; display: block; min-width: 0; }
|
||||
.drawer-select select { padding-right: 34px; cursor: pointer; }
|
||||
.drawer-select svg {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ── Choice cards ───────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-choices { display: flex; gap: var(--dr-gap-sm); }
|
||||
|
||||
.drawer-choice {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--dr-radius-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--dr-line);
|
||||
background: var(--color-surface);
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.drawer-choice:hover { border-color: var(--color-ink-4); }
|
||||
.drawer-choice[data-selected='true'] {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
.drawer-choice:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
.drawer-choice-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
.drawer-choice[data-selected='true'] .drawer-choice-label { color: var(--color-brand); }
|
||||
.drawer-choice-note {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
/* One height, one radius, one type ramp for every button in every drawer. */
|
||||
/* One height, one radius, one type ramp for every button in every drawer — and
|
||||
the same height as an input, so a form does not step up and down its own
|
||||
right-hand edge. */
|
||||
.drawer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
gap: 6px;
|
||||
height: var(--dr-control);
|
||||
padding: 0 14px;
|
||||
border-radius: var(--dr-radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font: 600 14px/1 var(--font-sans);
|
||||
font: 600 var(--dr-text-md)/1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.drawer-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.drawer-btn svg { flex: none; }
|
||||
|
||||
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
|
||||
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
|
||||
|
||||
.drawer-btn[data-variant='secondary'] {
|
||||
background: var(--color-surface);
|
||||
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent);
|
||||
color: var(--color-brand);
|
||||
border-color: var(--dr-line);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
.drawer-btn[data-variant='secondary']:hover:not(:disabled) {
|
||||
background: var(--color-surface-sunken);
|
||||
border-color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); }
|
||||
|
||||
/* Outline, never filled: a destructive action should be reachable without
|
||||
being the brightest thing in the drawer. */
|
||||
@@ -505,8 +737,11 @@
|
||||
background: color-mix(in oklab, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); }
|
||||
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
||||
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-3); }
|
||||
.drawer-btn[data-variant='ghost']:hover:not(:disabled) {
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
/* A text-weight action that lives inside a section, not in the footer —
|
||||
"Group with another size". */
|
||||
@@ -517,22 +752,38 @@
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: 600 13px/1 var(--font-sans);
|
||||
font: 600 var(--dr-text-md)/1 var(--font-sans);
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drawer-link-action:hover { text-decoration: underline; }
|
||||
.drawer-link-action:disabled { opacity: 0.45; cursor: not-allowed; text-decoration: none; }
|
||||
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
|
||||
|
||||
/* ── Codes ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--dr-text-md);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* A PIN shown once, meant to be read across a desk and typed in. */
|
||||
.drawer-mono[data-size='lg'] {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
/* ── Image area ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-figure {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
padding: var(--dr-gap-lg);
|
||||
border: 1px solid var(--dr-line);
|
||||
border-radius: var(--dr-radius);
|
||||
background: var(--card-bg-subtle);
|
||||
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
|
||||
will happily size to it and push the image straight out of the panel —
|
||||
which is exactly what happened. Nothing leaves this box. */
|
||||
@@ -557,13 +808,13 @@
|
||||
.drawer-figure[data-tall='true'] { height: 260px; }
|
||||
.drawer-figure[data-tall='true'] img { max-height: 228px; }
|
||||
|
||||
.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.drawer-thumbs { display: flex; gap: var(--dr-gap-sm); flex-wrap: wrap; }
|
||||
.drawer-thumb {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--dr-radius-sm);
|
||||
border: 1px solid var(--dr-line);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
@@ -579,32 +830,54 @@
|
||||
|
||||
/* ── Highlight bullets ──────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.drawer-bullets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--dr-gap-lg);
|
||||
}
|
||||
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
|
||||
|
||||
.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.drawer-bullet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.drawer-bullet {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
gap: var(--dr-gap-sm);
|
||||
align-items: baseline;
|
||||
font: 400 13.5px/1.45 var(--font-sans);
|
||||
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.drawer-bullet::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Caption ────────────────────────────────────────────────────────────── */
|
||||
/* An explanatory line inside a card — why a product was filed where it was,
|
||||
what an import will and will not do. Quieter than prose and not a note: it
|
||||
has no box of its own because it is already inside one. */
|
||||
.drawer-caption {
|
||||
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
.drawer-caption strong { color: var(--color-ink-1); font-weight: 600; }
|
||||
|
||||
/* ── Prose ──────────────────────────────────────────────────────────────── */
|
||||
/* 15px and 1.6 — a description is the one thing in a drawer that gets read
|
||||
rather than scanned. */
|
||||
/* A description is the one thing in a drawer that gets read rather than
|
||||
scanned, so it is the one thing set above the body size. */
|
||||
.drawer-prose {
|
||||
font: 400 14.5px/1.6 var(--font-sans);
|
||||
margin: 0;
|
||||
font: 400 var(--dr-text-lg)/1.6 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -612,35 +885,33 @@
|
||||
.drawer-prose p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ── Narrower screens ───────────────────────────────────────────────────── */
|
||||
/* The panel is already capped at 92vw; below that the padding is what has to
|
||||
/* The panel is already capped at 92vw; below that the gutter is what has to
|
||||
give, or a 520px layout inside a 360px panel just clips. */
|
||||
@media (max-width: 600px) {
|
||||
.drawer-head { padding: 16px 16px 14px; }
|
||||
.drawer-body { padding: 16px; gap: 20px; }
|
||||
.drawer-foot { padding: 12px 16px; }
|
||||
.drawer-title { font-size: 19px; }
|
||||
.drawer-root { --dr-pad-x: 16px; }
|
||||
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.drawer-metrics[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.drawer-choices { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Health score, inside the drawer system ─────────────────────────────── */
|
||||
/* The panel's own pieces — score chip, points, tags — already sit on the brand
|
||||
palette and are kept. What is added here is the wrapper that makes it read as
|
||||
one compact card rather than as a loose stack, which is what the redesign
|
||||
asks of it: informative, and not competing with the product it describes. */
|
||||
one compact card rather than as a loose stack. */
|
||||
.health-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--dr-line);
|
||||
border-radius: var(--dr-radius);
|
||||
background: var(--card-bg-subtle);
|
||||
}
|
||||
|
||||
.health-per {
|
||||
font: 400 12px/1.3 var(--font-sans);
|
||||
font: 400 var(--dr-text-sm)/1.3 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); }
|
||||
.health-caveat { font: 400 var(--dr-text-md)/1.55 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { AlertTriangle, Check, ChevronDown, Info } from 'lucide-react';
|
||||
import './drawer.css';
|
||||
|
||||
/**
|
||||
@@ -133,10 +133,15 @@ export function Badge({ label, colour }: { label: string; colour: string }) {
|
||||
<span
|
||||
className="drawer-badge"
|
||||
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
|
||||
`background` directly, so the stylesheet can decide how to spend it.
|
||||
An inline colour would win over every rule, and the badge has to look
|
||||
different in the purple header than it does on a white card — see
|
||||
`.drawer-head .drawer-badge`. */
|
||||
`background` directly, so the stylesheet decides how to spend it — as
|
||||
text, as an 11% fill and as a 24% border, from the one value.
|
||||
|
||||
It is now the same badge in the header as on a card. It was not while
|
||||
the header was painted brand purple: a tinted chip is unreadable on a
|
||||
saturated ground, so the header had its own white-chip-with-a-dot
|
||||
variant, and the status beside a drawer's title did not match the chip
|
||||
in the row it was opened from. Dropping the purple dropped the
|
||||
variant. */
|
||||
style={{ '--badge': colour } as CSSProperties}
|
||||
>
|
||||
{label}
|
||||
@@ -227,3 +232,204 @@ export function Bullets({ items }: { items: readonly string[] }) {
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Form pieces ─────────────────────────────────────────────────────────────
|
||||
|
||||
Seven of the console's drawers are FORMS — add a rider, edit a person, issue
|
||||
a till account, name a counter — and until now the kit had nothing for them.
|
||||
So each one reached for Astryx's `TextInput`, `Selector` and `VStack`, laid
|
||||
them out with its own inline styles, and wrote its own error box. That is why
|
||||
the edit drawers and the detail drawers did not look like the same product:
|
||||
different label case, different field gap, different control height, three
|
||||
spellings of an error.
|
||||
|
||||
These are that missing half. They are deliberately plain — a label, a
|
||||
control, a line of help, an error — and they carry no layout of their own
|
||||
beyond the field: `Section` still supplies the grouping, the same as it does
|
||||
for a read-only drawer. */
|
||||
|
||||
/**
|
||||
* A labelled control.
|
||||
*
|
||||
* The label is a real `<label>` wrapping its control, so clicking it focuses
|
||||
* the field and a screen reader names it — which the hand-rolled versions of
|
||||
* this got wrong in three drawers by rendering the label as a `Text`.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
isRequired,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
/** One line under the control — what it is for, or what it will do. */
|
||||
description?: ReactNode;
|
||||
/** Shown in place of the description, in the error colour. */
|
||||
error?: string | undefined;
|
||||
isRequired?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="drawer-field">
|
||||
<span className="drawer-field-label">
|
||||
{label}
|
||||
{isRequired ? (
|
||||
<span className="drawer-field-req" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{children}
|
||||
{error ? (
|
||||
<span className="drawer-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : description ? (
|
||||
<span className="drawer-field-help">{description}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two fields on one line, stacking on a narrow panel. */
|
||||
export function FieldRow({ children }: { children: ReactNode }) {
|
||||
return <div className="drawer-field-row">{children}</div>;
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'tel' | 'number';
|
||||
isDisabled?: boolean;
|
||||
isInvalid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="drawer-input"
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={isDisabled}
|
||||
{...(isInvalid ? { 'aria-invalid': true } : {})}
|
||||
{...(placeholder ? { placeholder } : {})}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
isDisabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
options: readonly { value: string; label: string }[];
|
||||
/** The empty first option. Absent when every choice is a real one. */
|
||||
placeholder?: string;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="drawer-select">
|
||||
<select
|
||||
className="drawer-input"
|
||||
value={value}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={15} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Something that went wrong, or something the reader must know before acting.
|
||||
*
|
||||
* Distinct from `Note`, which is quiet by design and explains an absence. This
|
||||
* one is the save that failed and the precondition that is not met, and it is
|
||||
* the piece three drawers each wrote for themselves.
|
||||
*/
|
||||
export function Alert({
|
||||
tone = 'error',
|
||||
children,
|
||||
}: {
|
||||
tone?: 'error' | 'warning';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer-alert" data-tone={tone} role="alert">
|
||||
<AlertTriangle size={15} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One choice out of two or three, as cards rather than as a checkbox.
|
||||
*
|
||||
* Deactivating somebody is the closest thing this console has to a delete, so
|
||||
* the consequence is written on the control itself instead of being left to a
|
||||
* label the eye skips.
|
||||
*/
|
||||
export function ChoiceGroup({ label, children }: { label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="drawer-field">
|
||||
{label ? <span className="drawer-field-label">{label}</span> : null}
|
||||
<div className="drawer-choices">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Choice({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note?: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="drawer-choice"
|
||||
data-selected={isSelected}
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="drawer-choice-label">
|
||||
{isSelected ? <Check size={12} /> : null}
|
||||
{label}
|
||||
</span>
|
||||
{note ? <span className="drawer-choice-note">{note}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A code — an id, a PIN, a batch — in the mono face. */
|
||||
export function Mono({ children, isLarge }: { children: ReactNode; isLarge?: boolean }) {
|
||||
return (
|
||||
<span className="drawer-mono" {...(isLarge ? { 'data-size': 'lg' } : {})}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function CounterSalesDrawer({
|
||||
: ''}
|
||||
</Text>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -265,7 +265,7 @@ export function CounterSalesDrawer({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
|
||||
@@ -192,7 +192,7 @@ export function ConsolePage() {
|
||||
Loading branches…
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No branches yet
|
||||
@@ -215,7 +215,7 @@ export function ConsolePage() {
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Comparison" />
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<ComparisonTable rows={rows} />
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@ export function ConsolePage() {
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<TillTable rows={rows} />
|
||||
</div>
|
||||
@@ -244,7 +244,7 @@ export function ConsolePage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Needs you" />
|
||||
{alerts.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
@@ -285,7 +285,7 @@ function BranchCard({ row }: { row: Row }) {
|
||||
const isActive = branch.status?.toLowerCase() === 'active';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="start" gap={1}>
|
||||
<VStack gap={0}>
|
||||
@@ -700,7 +700,7 @@ function AlertRow({ alert }: { alert: Alert }) {
|
||||
: 'var(--color-brand)';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, IndianRupee, Monitor } from 'lucide-react';
|
||||
import { KpiCard, type KpiTone } from '@/components/KpiCard';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { useCounterLabels } from '../counterLabels';
|
||||
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
||||
import {
|
||||
byUrgency,
|
||||
countsOf,
|
||||
filterRows,
|
||||
rowsOf,
|
||||
COUNTER_STATE_LABEL,
|
||||
COUNTER_STATE_TONE,
|
||||
type CounterRow,
|
||||
type CounterState,
|
||||
} from '../counterRows';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
import { shortAge } from '../posStatus';
|
||||
import { useTerminalBoard } from '../useTerminalBoard';
|
||||
@@ -33,19 +44,10 @@ import './counters.css';
|
||||
*/
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
const STATE_LABEL: Record<CounterState, string> = {
|
||||
active: 'Active',
|
||||
attention: 'Attention',
|
||||
never: 'Never used',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
const STATE_TONE: Record<CounterState, string> = {
|
||||
active: 'healthy',
|
||||
attention: 'attention',
|
||||
never: 'neutral',
|
||||
offline: 'critical',
|
||||
};
|
||||
/* The names and tones live with the rows now, in `counterRows`, so the drawer
|
||||
opened from this table can chip a counter the same way the row does. */
|
||||
const STATE_LABEL = COUNTER_STATE_LABEL;
|
||||
const STATE_TONE = COUNTER_STATE_TONE;
|
||||
|
||||
export function CountersPage() {
|
||||
const { branches, scoped, isPinned } = useBranchScope();
|
||||
@@ -121,15 +123,13 @@ export function CountersPage() {
|
||||
</div>
|
||||
|
||||
<div className="counter-controls">
|
||||
<label className="search" style={{ flex: 1, minWidth: 240 }}>
|
||||
<Search size={14} aria-hidden />
|
||||
<input
|
||||
<SearchInput
|
||||
label="Search counters"
|
||||
value={term}
|
||||
onChange={(event) => { setTerm(event.target.value); setPage(1); }}
|
||||
placeholder="Search counters by code, name or branch…"
|
||||
aria-label="Search counters"
|
||||
onChange={(next) => { setTerm(next); setPage(1); }}
|
||||
placeholder="Code, name or branch…"
|
||||
width={300}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{showBranch ? (
|
||||
<label className="picker">
|
||||
@@ -251,18 +251,31 @@ export function CountersPage() {
|
||||
|
||||
/* ── Pieces ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* This page's KPI tile was a THIRD copy of the same object, drawn on
|
||||
* `.kpi` classes that no longer exist. It is the shared component now; only
|
||||
* the tone words differ, and they are translated here rather than teaching the
|
||||
* component a second vocabulary — this page says "healthy" and "attention",
|
||||
* the component says "success" and "warning", and the counters board is the
|
||||
* only screen using the first pair.
|
||||
*/
|
||||
const KPI_TONE: Record<string, KpiTone> = {
|
||||
healthy: 'success',
|
||||
attention: 'warning',
|
||||
critical: 'error',
|
||||
};
|
||||
|
||||
function Kpi({
|
||||
icon, label, value, note, tone,
|
||||
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
|
||||
return (
|
||||
<article className="kpi">
|
||||
<span className="kpi-icon" data-tone={tone} aria-hidden>{icon}</span>
|
||||
<div className="kpi-head">
|
||||
<span className="kpi-label">{label}</span>
|
||||
<span className="kpi-value">{value}</span>
|
||||
<span className="kpi-note">{note}</span>
|
||||
</div>
|
||||
</article>
|
||||
<KpiCard
|
||||
icon={icon}
|
||||
label={label}
|
||||
value={value}
|
||||
note={note}
|
||||
{...(tone && KPI_TONE[tone] ? { tone: KPI_TONE[tone] } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { useSelection, type Selection } from '@/components/useSelection';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
@@ -461,7 +462,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
: {})}
|
||||
/>
|
||||
) : mode === 'riders' || mode === 'stores' ? (
|
||||
<Card padding={0} elevation="low" className="dispatch-card-elevated">
|
||||
<Card padding={0} className="dispatch-card-elevated">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
{/* No banner over the map.
|
||||
A title, a stop count, a payload total and a pulsing
|
||||
@@ -516,7 +517,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>
|
||||
<MapPin size={28} />
|
||||
@@ -785,7 +786,7 @@ function EnhancedGroupDetail({
|
||||
{assignBar}
|
||||
|
||||
{showMap ? (
|
||||
<Card padding={0} elevation="low" className="dispatch-map-card">
|
||||
<Card padding={0} className="dispatch-map-card">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<GroupMap stops={group.stops} groupName={group.name} locations={locations} />
|
||||
|
||||
@@ -836,7 +837,12 @@ function EnhancedGroupDetail({
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Stops"
|
||||
count={paged.total}
|
||||
countLabel="stops"
|
||||
footer={<TablePager paged={paged} label="stops" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table className="stops-table">
|
||||
<thead>
|
||||
@@ -921,8 +927,7 @@ function EnhancedGroupDetail({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="stops" />
|
||||
</Card>
|
||||
</Panel>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -24,10 +23,12 @@ import {
|
||||
Truck,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { StockRequest } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type StepId,
|
||||
} from '@/features/onboarding/onboardingState';
|
||||
import { useLocationProducts } from '@/queries/hooks';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
@@ -155,33 +157,33 @@ export function InventoryPage() {
|
||||
giving it a row of its own below. It only appears on that tab —
|
||||
an action for a panel you are not looking at is a misfire waiting
|
||||
to happen. */
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
<TabBar>
|
||||
<Tab
|
||||
label="Products"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'products'}
|
||||
onClick={() => chooseTab('products')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Catalogue"
|
||||
icon={<DownloadCloud size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => chooseTab('catalogue')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
isActive={tab === 'requests'}
|
||||
onClick={() => chooseTab('requests')}
|
||||
badge={pending.length || undefined}
|
||||
count={pending.length || undefined}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Stock"
|
||||
icon={<Layers size={14} />}
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => chooseTab('stock')}
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -443,17 +445,14 @@ function RequestsPanel({
|
||||
</div>
|
||||
|
||||
{/* Search box */}
|
||||
<div style={{ width: 240, minWidth: 180 }}>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search requests"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search product or branch…"
|
||||
placeholder="Product or branch…"
|
||||
width={240}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bulk Action Bar ──────────────────────────────────────────────── */}
|
||||
{selection.count > 0 ? (
|
||||
@@ -554,7 +553,7 @@ function RequestsPanel({
|
||||
|
||||
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading stock requests…
|
||||
@@ -562,7 +561,7 @@ function RequestsPanel({
|
||||
</VStack>
|
||||
</Card>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}>
|
||||
<PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} />
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -578,7 +577,12 @@ function RequestsPanel({
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Stock requests"
|
||||
count={paged.total}
|
||||
countLabel="stock requests"
|
||||
footer={<TablePager paged={paged} label="stock requests" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{
|
||||
@@ -747,8 +751,7 @@ function RequestsPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="stock requests" />
|
||||
</Card>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Guidance Note ──────────────────────────────────────────────── */}
|
||||
@@ -961,22 +964,18 @@ function StockPanel() {
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
<div style={{ width: 220, minWidth: 160 }}>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search stock"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
hasClear
|
||||
width={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
{statement.isLoading ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
@@ -1220,63 +1219,7 @@ function BarAction({
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||
|
||||
function Th({
|
||||
children,
|
||||
|
||||
@@ -248,7 +248,7 @@ export function OnboardBranchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card padding={4} elevation="low">
|
||||
<Card padding={4}>
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
@@ -263,7 +263,7 @@ export function OnboardBranchPage() {
|
||||
</Text>
|
||||
|
||||
{mutation.data?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<Card padding={3} variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={Number(form.tenantid || defaultTenantId)}
|
||||
locationid={mutation.data.locationid}
|
||||
@@ -297,7 +297,7 @@ export function OnboardBranchPage() {
|
||||
<h1 className="sr-only">Onboard Branch</h1>
|
||||
|
||||
{/* Section 1: Business Details & Identity */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<div
|
||||
style={{
|
||||
@@ -368,7 +368,7 @@ export function OnboardBranchPage() {
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Location */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<div
|
||||
style={{
|
||||
@@ -428,7 +428,7 @@ export function OnboardBranchPage() {
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Operating Hours & Delivery */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -108,26 +110,26 @@ export function ReportsPage() {
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
<TabBar>
|
||||
<Tab
|
||||
label="Revenue"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={tab === 'revenue'}
|
||||
onClick={() => setTab('revenue')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Sales"
|
||||
icon={<BarChart3 size={14} />}
|
||||
isActive={tab === 'sales'}
|
||||
onClick={() => setTab('sales')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Inventory"
|
||||
icon={<Boxes size={14} />}
|
||||
isActive={tab === 'inventory'}
|
||||
onClick={() => setTab('inventory')}
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -167,9 +169,14 @@ export function ReportsPage() {
|
||||
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
||||
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="By branch" />
|
||||
<Card padding={0} elevation="low">
|
||||
/* No SectionHeader above it any more — the panel carries its own
|
||||
title and count, and the two stacked said "By branch" twice. */
|
||||
<Panel
|
||||
title="By branch"
|
||||
count={paged.total}
|
||||
countLabel="branches"
|
||||
footer={<TablePager paged={paged} label="branches" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -206,9 +213,7 @@ export function ReportsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="branches" />
|
||||
</Card>
|
||||
</VStack>
|
||||
</Panel>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : tab === 'sales' ? (
|
||||
@@ -273,7 +278,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="App versus counter" />
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||
<Bar
|
||||
@@ -357,7 +362,7 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" />
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -460,7 +465,7 @@ function MoverList({
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title={title} />
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
{rows.length === 0 ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
@@ -500,43 +505,7 @@ function MoverList({
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||
|
||||
function Th({ children }: { children: React.ReactNode; }) {
|
||||
return (
|
||||
|
||||
@@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import {
|
||||
useDeliveries,
|
||||
useOrders,
|
||||
@@ -37,6 +38,7 @@ import { shortAge } from '../posStatus';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
import { assignability, assignedFrom } from '../assignDelivery';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { isCounterSale } from '../branchStats';
|
||||
@@ -44,6 +46,21 @@ import './deliveries.css';
|
||||
|
||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
|
||||
/**
|
||||
* What the one search box matches, per view.
|
||||
*
|
||||
* The page used to draw three separate boxes, one inside each view, and the
|
||||
* only thing that differed between them was this line — the value behind them
|
||||
* was already a single `keyword`, filtering orders and deliveries through the
|
||||
* query and counter bills client-side. So the boxes were three renderings of
|
||||
* one control, and the placeholder is what survives of them.
|
||||
*/
|
||||
const SEARCH_HINT: Record<Tab, string> = {
|
||||
orders: 'Order ID, customer…',
|
||||
deliveries: 'Order ID, rider…',
|
||||
counter: 'Invoice, till, cashier…',
|
||||
};
|
||||
|
||||
/**
|
||||
* The bill number a counter sale was imported under.
|
||||
*
|
||||
@@ -287,43 +304,58 @@ export function SalesPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Sales"
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Online Orders"
|
||||
icon={<ShoppingCart size={14} />}
|
||||
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
||||
isActive={tab !== 'counter'}
|
||||
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
|
||||
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
|
||||
isTabsInline
|
||||
actions={
|
||||
<SearchInput
|
||||
label="Search sales"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder={SEARCH_HINT[tab]}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
/* THREE views, not four.
|
||||
|
||||
{tab !== 'counter' ? (
|
||||
<>
|
||||
<TabButton
|
||||
There used to be an "Online Orders" tab in front of these carrying
|
||||
the combined count — 86 beside Orders 85 and Deliveries 1. It was
|
||||
never a view of its own: it reported active whenever the page was
|
||||
NOT on Counter sales, and clicking it from either of the two tabs
|
||||
it contained did nothing at all. In the old flat pills that passed
|
||||
unnoticed. In a segmented control it does not: a segmented control
|
||||
means "one of these", and Online Orders and Orders were both drawn
|
||||
selected at the same time, which is simply a broken control.
|
||||
|
||||
Nothing is lost by dropping it. Its count was the sum of the two
|
||||
beside it, and the combined figure is already on the KPI row above.
|
||||
|
||||
Counter sales keeps the divider: Orders and Deliveries are two
|
||||
halves of the app's own trade and the till is a different channel,
|
||||
which is the grouping "Online Orders" was there to express. */
|
||||
<TabBar aria-label="Sales views">
|
||||
<Tab
|
||||
label="Orders"
|
||||
icon={<ShoppingCart size={14} />}
|
||||
badge={allOrders.length || undefined}
|
||||
count={allOrders.length || undefined}
|
||||
isActive={tab === 'orders'}
|
||||
onClick={() => setTab('orders')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Deliveries"
|
||||
icon={<Truck size={14} />}
|
||||
badge={allDeliveries.length || undefined}
|
||||
count={allDeliveries.length || undefined}
|
||||
isActive={tab === 'deliveries'}
|
||||
onClick={() => setTab('deliveries')}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Counter sales"
|
||||
icon={<Receipt size={14} />}
|
||||
badge={billRows.length || undefined}
|
||||
count={billRows.length || undefined}
|
||||
isActive={tab === 'counter'}
|
||||
onClick={() => setTab('counter')}
|
||||
isGroupStart
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -367,9 +399,6 @@ export function SalesPage() {
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
search={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
|
||||
}
|
||||
/>
|
||||
{/* Above the table, not below it and not in a drawer.
|
||||
Assigning is something you do WHILE reading the day's orders —
|
||||
@@ -451,9 +480,6 @@ export function SalesPage() {
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
search={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
|
||||
}
|
||||
/>
|
||||
<DeliveriesTable
|
||||
rows={deliveryRows}
|
||||
@@ -514,11 +540,9 @@ export function SalesPage() {
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
{/* No status strip here: a counter bill is rung and done, so it
|
||||
has no lifecycle to filter by. The search keeps the row. */}
|
||||
<HStack justify="end" wrap="wrap">
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||
</HStack>
|
||||
{/* No status strip and no search row: a counter bill is rung and
|
||||
done, so it has no lifecycle to filter by, and the search is in
|
||||
the page header with the tabs — one box for all three views. */}
|
||||
<BillsTable
|
||||
rows={billRows}
|
||||
queryKey={queryKey}
|
||||
@@ -580,35 +604,46 @@ function freshRow(
|
||||
* in view, the filters decide what is in view, and the table shows it. Reading
|
||||
* downward now follows that order.
|
||||
*/
|
||||
/**
|
||||
* The status ladder, under the view tabs.
|
||||
*
|
||||
* The SECONDARY size of the same control the views use — no track, smaller
|
||||
* type, a brand tint on the selected one. That subordination is the point: two
|
||||
* strips of tabs at equal weight on one screen read as two equally important
|
||||
* choices, when this one only narrows whatever the strip above it selected.
|
||||
*
|
||||
* It used to be a private `StatusTab` — a 30px chip with a 12px radius, a
|
||||
* hairline when idle and a solid brand fill when selected — which was a fourth
|
||||
* treatment of "tab" on a page that now has one.
|
||||
*
|
||||
* The wrapper it sat in is gone with it. It was an `HStack justify="between"`
|
||||
* holding the tabs and the search; the search moved to the page header, so the
|
||||
* row was a space-between with nothing on the right of it.
|
||||
*/
|
||||
function FilterRow({
|
||||
tabs,
|
||||
status,
|
||||
counts,
|
||||
onStatus,
|
||||
search,
|
||||
}: {
|
||||
/** Which ladder to show — orders and deliveries do not share one. */
|
||||
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
|
||||
status: StatusKey;
|
||||
counts: Record<StatusKey, number>;
|
||||
onStatus: (key: StatusKey) => void;
|
||||
search: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabBar size="sm" aria-label="Filter by status">
|
||||
{tabs.map((entry) => (
|
||||
<StatusTab
|
||||
<Tab
|
||||
key={entry.key}
|
||||
label={entry.label}
|
||||
n={counts[entry.key]}
|
||||
count={counts[entry.key]}
|
||||
isActive={status === entry.key}
|
||||
onClick={() => onStatus(entry.key)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{search}
|
||||
</HStack>
|
||||
</TabBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -649,7 +684,12 @@ function OrdersTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Orders"
|
||||
count={paged.total}
|
||||
countLabel="orders"
|
||||
footer={<TablePager paged={paged} label="orders" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -773,8 +813,7 @@ function OrdersTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="orders" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -807,7 +846,12 @@ function DeliveriesTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Deliveries"
|
||||
count={paged.total}
|
||||
countLabel="deliveries"
|
||||
footer={<TablePager paged={paged} label="deliveries" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -948,8 +992,7 @@ function DeliveriesTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="deliveries" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -980,7 +1023,12 @@ function BillsTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Counter sales"
|
||||
count={paged.total}
|
||||
countLabel="bills"
|
||||
footer={<TablePager paged={paged} label="bills" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -1062,8 +1110,7 @@ function BillsTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="bills" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
function Search({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* The page-local search box lived here. It is `components/SearchInput`
|
||||
now, in the header beside the tabs, and there is one of it. */
|
||||
|
||||
/** A status chip in the lifecycle's own colour. */
|
||||
function Chip({ label, color }: { label: string | undefined; color: string }) {
|
||||
@@ -1222,117 +1248,15 @@ function TotalsBar({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTab({
|
||||
label,
|
||||
n,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
n: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 30,
|
||||
padding: '0 11px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
|
||||
// Transparent when idle, not white. A row of white chips on a tinted
|
||||
// page reads as six raised slabs; only the selected one should carry a
|
||||
// fill, and the hairline is enough to keep the rest legible as chips.
|
||||
background: isActive ? 'var(--color-brand)' : 'transparent',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-2)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s, border-color .2s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontWeight: 700,
|
||||
opacity: isActive ? 0.85 : 0.55,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `StatusTab` lived here — it is the `sm` size of
|
||||
`components/TabBar` now. */
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `TabButton` lived here. It is `components/TabBar` now —
|
||||
one control for the six pages that had each grown their own. */
|
||||
|
||||
function Loading({ what }: { what: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading {what}…
|
||||
@@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) {
|
||||
|
||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -198,13 +199,14 @@ export function TerminalsPage() {
|
||||
*
|
||||
* A hairline and a radius, no fill. Every block here used to be a white
|
||||
* `elevation="low"` card, which on a tinted page reads as a stack of raised
|
||||
* slabs — and the console has been moving away from that everywhere else.
|
||||
* Defined once so the edges cannot drift apart the next time one of these is
|
||||
* edited.
|
||||
* slabs — this page went flat first, and the rest of the console has since
|
||||
* followed: the card tokens in `index.css` are now that treatment, applied
|
||||
* everywhere. What is left here is the shared card minus the fill, so the
|
||||
* page's own tint reads through.
|
||||
*/
|
||||
const panelStyle: React.CSSProperties = {
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: 'var(--card-border)',
|
||||
};
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
@@ -662,8 +664,12 @@ function FineTable({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} variant="transparent">
|
||||
<div style={{ ...panelStyle, overflow: 'hidden' }}>
|
||||
<Panel
|
||||
title="All terminals"
|
||||
count={paged.total}
|
||||
countLabel="terminals"
|
||||
footer={<TablePager paged={paged} label="terminals" />}
|
||||
>
|
||||
<Table<FineRowData>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
@@ -672,9 +678,7 @@ function FineTable({
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="terminals" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import type { RiderRosterRow } from '@/api/deliveries';
|
||||
import type { PosUser, StaffInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
usePosRoles,
|
||||
@@ -23,6 +24,7 @@ import { branchLabel } from '../format';
|
||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||
import { RiderDrawer } from '../RiderDrawer';
|
||||
import { isUnplaced } from '../staffPlacement';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -165,21 +167,21 @@ export function UsersPage() {
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
<TabBar>
|
||||
<Tab
|
||||
label="Store users"
|
||||
icon={<UserCog size={14} />}
|
||||
badge={staff.data?.length || undefined}
|
||||
count={staff.data?.length || undefined}
|
||||
isActive={group === 'store'}
|
||||
onClick={() => {
|
||||
setGroup('store');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Terminal accounts"
|
||||
icon={<Monitor size={14} />}
|
||||
badge={tillRows.length || undefined}
|
||||
count={tillRows.length || undefined}
|
||||
isActive={group === 'till'}
|
||||
onClick={() => {
|
||||
setGroup('till');
|
||||
@@ -191,17 +193,17 @@ export function UsersPage() {
|
||||
and not a till — so listing them together would invite an edit
|
||||
that moves someone between logins that do not exist for each
|
||||
other. */}
|
||||
<TabButton
|
||||
<Tab
|
||||
label="Riders"
|
||||
icon={<Bike size={14} />}
|
||||
badge={riderRows.length || undefined}
|
||||
count={riderRows.length || undefined}
|
||||
isActive={group === 'rider'}
|
||||
onClick={() => {
|
||||
setGroup('rider');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -226,17 +228,13 @@ export function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ width: 220 }}>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search people"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Name, email, mobile…"
|
||||
hasClear
|
||||
width={220}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
@@ -279,6 +277,9 @@ export function UsersPage() {
|
||||
row={editingTill.row === 'new' ? null : editingTill.row}
|
||||
tenantid={tenantid}
|
||||
locationid={editingTill.locationid}
|
||||
branchName={
|
||||
branches.find((entry) => entry.locationid === editingTill.locationid)?.locationname ?? ''
|
||||
}
|
||||
roles={roles.data ?? []}
|
||||
shifts={shifts.data ?? []}
|
||||
onClose={() => setEditingTill(null)}
|
||||
@@ -325,7 +326,7 @@ function RiderTable({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading riders…
|
||||
@@ -337,7 +338,7 @@ function RiderTable({
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>
|
||||
<Bike size={22} />
|
||||
@@ -354,7 +355,12 @@ function RiderTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Riders"
|
||||
count={rows.length}
|
||||
countLabel="riders"
|
||||
footer={<TablePager paged={paged} label="riders" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -411,8 +417,7 @@ function RiderTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="riders" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -445,7 +450,12 @@ function StaffTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Store users"
|
||||
count={rows.length}
|
||||
countLabel="people"
|
||||
footer={<TablePager paged={paged} label="people" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -504,8 +514,7 @@ function StaffTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="people" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -534,7 +543,12 @@ function TillTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Terminal accounts"
|
||||
count={rows.length}
|
||||
countLabel="terminal accounts"
|
||||
footer={<TablePager paged={paged} label="terminal accounts" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
@@ -598,8 +612,7 @@ function TillTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="terminal accounts" />
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -664,68 +677,11 @@ function StatusChip({ status }: { status: string | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||
|
||||
function Loading({ what }: { what: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading {what}…
|
||||
@@ -737,7 +693,7 @@ function Loading({ what }: { what: string }) {
|
||||
|
||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
.size-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.size-row {
|
||||
@@ -139,9 +139,9 @@
|
||||
|
||||
.size-add {
|
||||
padding: 13px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg-subtle);
|
||||
}
|
||||
|
||||
/* ── Health score ───────────────────────────────────────────────────────── */
|
||||
@@ -155,7 +155,7 @@
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--card-radius);
|
||||
font: 700 22px/1 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -196,7 +196,7 @@
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #f2d9a8;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fdf6e8;
|
||||
color: #8a5a00;
|
||||
font: 500 12.5px/1.4 var(--font-sans);
|
||||
@@ -204,8 +204,8 @@
|
||||
.health-allergens svg { flex: none; }
|
||||
|
||||
.health-facts {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.health-facts > div {
|
||||
@@ -259,8 +259,8 @@
|
||||
.stop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stop-row {
|
||||
@@ -331,7 +331,7 @@
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f2d9a8;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fdf6e8;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,40 @@
|
||||
Curated palette with deep purple/indigo accents (#662582), glassmorphic elements,
|
||||
live telemetry cards and interactive map timelines. */
|
||||
|
||||
/* ── Dispatch's palette is now the console's ────────────────────────────────
|
||||
|
||||
These fourteen names are kept, and every one of them is an ALIAS. About
|
||||
fifty rules in this file reference them, so pointing the names at the shared
|
||||
tokens restyles the whole page at once — and, more to the point, makes it
|
||||
impossible for Dispatch to drift away from the rest of the console again.
|
||||
|
||||
It had drifted. This file arrived as a port of another product and brought
|
||||
that product's palette with it: `#e2e8f0` for a hairline where the console
|
||||
uses `#e0e4ea`, `#1e293b` for body text where the console uses `#0f172a`,
|
||||
`#f8fafc` for a sunken fill against the console's `#fafbfc`. Each is a
|
||||
shade off the real token — close enough to look like a rendering
|
||||
inconsistency rather than a decision, and far enough that a Dispatch card
|
||||
beside a Console card were visibly two different whites.
|
||||
|
||||
The two shadow steps are now the card's, which is to say nothing at all.
|
||||
`--dispatch-shadow-lg` is referenced by no rule in this file and is gone
|
||||
rather than aliased. */
|
||||
:root {
|
||||
--dispatch-bg: #ffffff;
|
||||
--dispatch-bg-sub: #f8fafc;
|
||||
--dispatch-card-bg: #ffffff;
|
||||
--dispatch-border: #e2e8f0;
|
||||
--dispatch-border-active: #662582;
|
||||
--dispatch-text: #1e293b;
|
||||
--dispatch-text-muted: #64748b;
|
||||
--dispatch-accent: #662582;
|
||||
--dispatch-bg: var(--color-surface);
|
||||
--dispatch-bg-sub: var(--color-surface-subtle);
|
||||
--dispatch-card-bg: var(--card-bg);
|
||||
--dispatch-border: var(--color-line);
|
||||
--dispatch-border-active: var(--color-brand);
|
||||
--dispatch-text: var(--color-ink-1);
|
||||
--dispatch-text-muted: var(--color-ink-3);
|
||||
--dispatch-accent: var(--color-brand);
|
||||
--dispatch-accent-light: #9255ab;
|
||||
--dispatch-accent-soft: rgba(102, 37, 130, 0.08);
|
||||
--dispatch-success: #10b981;
|
||||
--dispatch-warning: #f59e0b;
|
||||
--dispatch-error: #ef4444;
|
||||
--dispatch-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
--dispatch-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
--dispatch-shadow-lg: 0 12px 30px rgba(102, 37, 130, 0.12);
|
||||
--dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent);
|
||||
--dispatch-success: var(--color-success, #1f9d55);
|
||||
--dispatch-warning: var(--color-warning, #b7860b);
|
||||
--dispatch-error: var(--color-error, #d64545);
|
||||
--dispatch-shadow-sm: var(--card-shadow);
|
||||
--dispatch-shadow-md: var(--card-shadow);
|
||||
}
|
||||
|
||||
.dispatch-container {
|
||||
@@ -30,221 +47,28 @@
|
||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
}
|
||||
|
||||
/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── */
|
||||
.dispatch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 14px;
|
||||
min-height: 44px;
|
||||
background: var(--dispatch-card-bg);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--dispatch-shadow-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* ── Top Operations Header (xpressconsole #hdr) ─────────────────────────────
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
GONE, because it was never rendered.
|
||||
|
||||
.logo-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #662582, #9255ab);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 11.5px;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 6px rgba(102, 37, 130, 0.25);
|
||||
}
|
||||
This file arrived as a port of another product, and it brought that
|
||||
product's whole operations header with it: `.dispatch-header`, a logo lockup,
|
||||
a city picker, a demo/live toggle, a live-GPS chip, a running clock, and
|
||||
`.date-chip` — a pill with prev/next arrows for stepping through days.
|
||||
|
||||
.logo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--dispatch-text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
Not one of those class names appears in any markup in this repository. The
|
||||
Dispatch page reads the date from `useDateScope()`, which is the range
|
||||
picker in the app's own top bar; two comments in `DispatchPage.tsx` already
|
||||
tell the reader to "change the date range in the top bar". So the header was
|
||||
215 lines of CSS for a component that does not exist, and `.date-chip` was a
|
||||
date filter nobody can see.
|
||||
|
||||
.logo-name em {
|
||||
color: var(--dispatch-accent);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
If the ported header is wanted later it is in the git history, but it would
|
||||
need rebuilding against this console's own controls rather than restoring:
|
||||
the date range, the branch scope and the account menu are all in the app
|
||||
shell, and a second set of them on one page is the drift this pass has been
|
||||
removing everywhere else. */
|
||||
|
||||
.logo-city-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo-city {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--dispatch-accent-soft);
|
||||
border: 1px solid rgba(102, 37, 130, 0.25);
|
||||
color: var(--dispatch-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.logo-city:hover {
|
||||
background: rgba(102, 37, 130, 0.14);
|
||||
border-color: rgba(102, 37, 130, 0.45);
|
||||
}
|
||||
|
||||
.demo-toggle-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(102, 37, 130, 0.06));
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #065f46;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.demo-toggle-chip:hover {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(102, 37, 130, 0.12));
|
||||
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-dot.is-demo {
|
||||
background: var(--dispatch-success);
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
|
||||
animation: pulse-ring 1.8s infinite;
|
||||
}
|
||||
|
||||
.toggle-dot.is-live {
|
||||
background: var(--dispatch-warning);
|
||||
}
|
||||
|
||||
.hdr-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hdr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
background: var(--dispatch-bg-sub);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
color: var(--dispatch-text);
|
||||
}
|
||||
|
||||
.live-gps-chip {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--dispatch-success);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
animation: pulse-ring 1.8s infinite;
|
||||
}
|
||||
|
||||
.strat-stat-orders {
|
||||
background: var(--dispatch-accent-soft);
|
||||
border-color: rgba(102, 37, 130, 0.2);
|
||||
color: var(--dispatch-accent);
|
||||
}
|
||||
|
||||
.strat-stat-profit {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* Date Chip */
|
||||
.date-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 3px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.date-nav-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--dispatch-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.date-nav-btn:hover {
|
||||
background: var(--dispatch-bg-sub);
|
||||
color: var(--dispatch-accent);
|
||||
}
|
||||
|
||||
.date-chip-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-badge {
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--dispatch-accent);
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hdr-clock {
|
||||
font-family: 'JetBrains Mono', monospace, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
background: #0f172a;
|
||||
color: #38bdf8;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.06em;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
|
||||
.strat-row {
|
||||
@@ -256,7 +80,7 @@
|
||||
min-height: 42px;
|
||||
background: var(--dispatch-card-bg);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--dispatch-shadow-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -454,23 +278,21 @@
|
||||
|
||||
.dispatch-container .rcard,
|
||||
.rcard {
|
||||
background: #ffffff;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--card-radius);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hover is the line, not a lift -- see the note on `.pcard` in index.css. */
|
||||
.dispatch-container .rcard:hover,
|
||||
.rcard:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(102, 37, 130, 0.35);
|
||||
border-color: var(--dispatch-accent);
|
||||
}
|
||||
|
||||
.dispatch-container .rcard.is-focused,
|
||||
@@ -645,10 +467,10 @@
|
||||
.dispatch-card-elevated,
|
||||
.dispatch-focus-card,
|
||||
.dispatch-map-card {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid var(--dispatch-border) !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: var(--dispatch-shadow-md) !important;
|
||||
background: var(--card-bg) !important;
|
||||
border: var(--card-border) !important;
|
||||
border-radius: var(--card-radius) !important;
|
||||
box-shadow: var(--card-shadow) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -707,7 +529,7 @@
|
||||
padding: 10px 14px;
|
||||
background: var(--dispatch-bg-sub);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
.rl-item {
|
||||
@@ -774,19 +596,16 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
background: #ffffff;
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.timeline-step-card:hover,
|
||||
.stop-timeline-card:hover {
|
||||
border-color: var(--dispatch-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 18px rgba(102, 37, 130, 0.12);
|
||||
}
|
||||
|
||||
.timeline-step-card.is-current {
|
||||
@@ -981,7 +800,7 @@
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(102, 37, 130, 0.25);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
@@ -9,7 +8,7 @@ import { errorMessage } from '@/api/client';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { Product } from '@/api/types';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton } from '@/features/store-admin/drawerKit';
|
||||
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
/**
|
||||
@@ -178,13 +177,7 @@ export function BulkRequestDialog({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{problem ? (
|
||||
<Card padding={2} variant="transparent">
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
{problem ? <Alert>{problem}</Alert> : null}
|
||||
|
||||
</VStack>
|
||||
</Drawer>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { queryKeys } from '@/queries/keys';
|
||||
import { money } from '@/features/store-admin/format';
|
||||
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton } from '@/features/store-admin/drawerKit';
|
||||
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
|
||||
import { Thumb } from './ui';
|
||||
|
||||
const QUICK = [10, 25, 50, 100] as const;
|
||||
@@ -141,9 +141,7 @@ export function RequestDialog({
|
||||
</Text>
|
||||
|
||||
{ask.isError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
That did not go through. Check your connection and try again.
|
||||
</Text>
|
||||
<Alert>That did not go through. Check your connection and try again.</Alert>
|
||||
) : null}
|
||||
|
||||
</Drawer>
|
||||
|
||||
@@ -2,17 +2,19 @@ import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useCustomers, useOrders } from '@/queries/hooks';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerCard, Row as DrawerRow, Section } from '@/features/store-admin/drawerKit';
|
||||
import { count } from '@/features/store-admin/format';
|
||||
import { Td, Th } from '../ui';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -126,17 +128,13 @@ export function StoreCustomersPage() {
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
actions={
|
||||
<div style={{ width: 230 }}>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search customers"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Name, phone, address…"
|
||||
hasClear
|
||||
width={240}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -185,7 +183,11 @@ export function StoreCustomersPage() {
|
||||
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
|
||||
over their whole history
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
count={paged.total}
|
||||
countLabel="customers"
|
||||
footer={<TablePager paged={paged} label="customers" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{
|
||||
@@ -221,8 +223,7 @@ export function StoreCustomersPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="customers" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -364,60 +365,47 @@ function CustomerDrawer({
|
||||
width={420}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
||||
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
||||
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line
|
||||
icon={<ShoppingBag size={13} />}
|
||||
{/* Built from the drawer kit, not from Astryx cards and a `Line` of its
|
||||
own. The local version set its label column to a fixed 74px and its
|
||||
text one size down, so this drawer's rows sat at a different height
|
||||
and a different weight from every other drawer's — and an address long
|
||||
enough to wrap pushed its value out of the panel. */}
|
||||
<Section title="How to reach them">
|
||||
<DrawerCard>
|
||||
<DrawerRow label="Phone" value={customer.contactno || '—'} icon={<Phone size={14} />} />
|
||||
<DrawerRow label="Email" value={customer.email || '—'} icon={<Mail size={14} />} />
|
||||
{/* Stacked: an address is prose, and right-aligning six comma-joined
|
||||
parts against a 74px label is how it ended up unreadable. */}
|
||||
<DrawerRow label="Address" value={address || '—'} icon={<MapPin size={14} />} isStacked />
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
<Section title="What they have bought">
|
||||
<DrawerCard>
|
||||
<DrawerRow
|
||||
label="Orders"
|
||||
value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`}
|
||||
icon={<ShoppingBag size={14} />}
|
||||
{...(orders === 0 ? { tone: 'muted' as const } : {})}
|
||||
/>
|
||||
{/* 97% of customers have coordinates. Shown as a reference rather
|
||||
than a map: this is a drawer, and a rider's app is where a pin
|
||||
actually gets used. */}
|
||||
<Line
|
||||
icon={<MapPin size={13} />}
|
||||
<DrawerRow
|
||||
label="Map ref"
|
||||
value={
|
||||
customer.latitude && customer.longitude
|
||||
? `${customer.latitude}, ${customer.longitude}`
|
||||
: 'Not placed'
|
||||
}
|
||||
icon={<MapPin size={14} />}
|
||||
{...(customer.latitude && customer.longitude ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
function Line({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<HStack gap={1.5} align="start">
|
||||
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }}>{icon}</span>
|
||||
<Text type="body" size="sm" color="secondary" style={{ width: 74, flex: 'none' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="body" size="sm" style={{ minWidth: 0 }}>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Initials({ name }: { name: string }) {
|
||||
const initials =
|
||||
@@ -451,7 +439,7 @@ function Initials({ name }: { name: string }) {
|
||||
|
||||
function Note({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import {
|
||||
useAppAisles,
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
canAskAgain, requestState,
|
||||
stockHealth,
|
||||
} from '../requestState';
|
||||
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
|
||||
import { Chip, Td, Th, Thumb } from '../ui';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -155,41 +157,37 @@ export function StoreProductsPage() {
|
||||
title="Products"
|
||||
actions={
|
||||
tab === 'requests' ? null : (
|
||||
<div style={{ width: 230 }}>
|
||||
<TextInput
|
||||
<SearchInput
|
||||
label="Search products"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
hasClear
|
||||
width={240}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
<TabBar>
|
||||
<Tab
|
||||
label="Catalogue"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="My stock"
|
||||
icon={<Boxes size={14} />}
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => setTab('stock')}
|
||||
/>
|
||||
<TabButton
|
||||
<Tab
|
||||
label="My requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
isActive={tab === 'requests'}
|
||||
onClick={() => setTab('requests')}
|
||||
badge={waiting || undefined}
|
||||
count={waiting || undefined}
|
||||
/>
|
||||
</HStack>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -211,7 +209,16 @@ export function StoreProductsPage() {
|
||||
<RequestsPanel rows={requestRows} isLoading={requests.isLoading} />
|
||||
)}
|
||||
|
||||
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
|
||||
{open ? (
|
||||
/* The row shows a Stock request chip; the drawer opened from it shows
|
||||
the same one, off the same map — see `request` on ProductDrawer. */
|
||||
<ProductDrawer
|
||||
product={open}
|
||||
canManage={false}
|
||||
{...(latest.get(open.productid) ? { request: latest.get(open.productid) } : {})}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{askingMany && askingMany.length > 0 && tenantid && locationid ? (
|
||||
<BulkRequestDialog
|
||||
@@ -349,7 +356,12 @@ function CataloguePanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Catalogue"
|
||||
count={paged.total}
|
||||
countLabel="products"
|
||||
footer={<TablePager paged={paged} label="products" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{
|
||||
@@ -398,8 +410,7 @@ function CataloguePanel({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="products" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
@@ -482,8 +493,14 @@ function CatalogueRow({
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isMuted>{unit || '—'}</Td>
|
||||
<Td isStrong>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>
|
||||
{/* An em dash for an unpriced product, not "₹0". Zero is a price a till
|
||||
would ring up; no price at all is why it cannot. The merchant's own
|
||||
products table and this product's drawer both say so — this cell was
|
||||
the one printing a figure that does not exist. */}
|
||||
<Td isStrong={effectivePrice(product) > 0}>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{effectivePrice(product) > 0 ? money(effectivePrice(product)) : '—'}
|
||||
</span>
|
||||
</Td>
|
||||
<Td isMuted={stock === 0}>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
|
||||
@@ -550,7 +567,12 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
|
||||
Today’s ledger. <strong>Opening</strong> is what was on the shelf before today — stock
|
||||
received today counts under <strong>In</strong> and becomes tomorrow’s opening.
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="My stock"
|
||||
count={paged.total}
|
||||
countLabel="stock rows"
|
||||
footer={<TablePager paged={paged} label="stock rows" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
||||
@@ -622,8 +644,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="stock rows" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -674,7 +695,12 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="My requests"
|
||||
count={paged.total}
|
||||
countLabel="requests"
|
||||
footer={<TablePager paged={paged} label="requests" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
||||
@@ -743,8 +769,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="requests" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -760,7 +785,7 @@ function stamp(value: string | undefined): string {
|
||||
|
||||
function Note({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
|
||||
import { count } from '@/features/store-admin/format';
|
||||
import { Td, Th } from '../ui';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
@@ -163,7 +164,7 @@ export function StoreStaffPage() {
|
||||
A suspended cashier is refused by the till, and now that these rows
|
||||
are asked for rather than hidden, the page can say so. */}
|
||||
{suspended > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<HStack gap={1.5} padding={2} align="start">
|
||||
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
|
||||
<TriangleAlert size={16} />
|
||||
@@ -199,7 +200,11 @@ export function StoreStaffPage() {
|
||||
? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet`
|
||||
: ' · no supervisor at this outlet'}
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
count={paged.total}
|
||||
countLabel="people"
|
||||
footer={<TablePager paged={paged} label="people" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table
|
||||
style={{
|
||||
@@ -284,8 +289,7 @@ export function StoreStaffPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="people" />
|
||||
</Card>
|
||||
</Panel>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -425,7 +429,7 @@ function AccessChip({ person }: { person: PosUser }) {
|
||||
|
||||
function Note({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
|
||||
@@ -11,64 +11,8 @@ import { ImageOff } from 'lucide-react';
|
||||
* merchant pages already went through.
|
||||
*/
|
||||
|
||||
/** A page tab, as used in the Store Admin's PageHeader tab row. */
|
||||
export function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The sixth and last copy of `TabButton` lived here, exported for the Store
|
||||
user pages. Every one of them now imports `components/TabBar`. */
|
||||
|
||||
/** A header action drawn like the tabs, so the two rows read as one bar. */
|
||||
export function BarAction({
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
CircleDot,
|
||||
Circle,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Info,
|
||||
PackageCheck,
|
||||
} from 'lucide-react';
|
||||
@@ -79,7 +78,28 @@ export function UploadDetailDrawer({
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Upload details"
|
||||
/* The FILE names this drawer, not the word "details".
|
||||
|
||||
It used to be titled "Upload details" with the header otherwise empty,
|
||||
and then re-stated the filename, the status chip and the uploader in a
|
||||
block of its own at the top of the body — a second header, in a
|
||||
different type scale, three lines below the real one. Every other
|
||||
drawer in the console names its subject in the bar; this one now does
|
||||
too, and the block is gone. */
|
||||
title={receipt.filename || 'Spreadsheet'}
|
||||
subtitle={`${totals.rows} row${totals.rows === 1 ? '' : 's'}${
|
||||
totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''
|
||||
}`}
|
||||
meta={
|
||||
<>
|
||||
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
|
||||
<span className="drawer-meta-text">
|
||||
{[showTenant ? receipt.tenantname : null, receipt.locationname, receipt.uploadedname]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
width={540}
|
||||
onClose={onClose}
|
||||
isFooterFilled={products.length === 0}
|
||||
@@ -109,33 +129,6 @@ export function UploadDetailDrawer({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* ── The file ──────────────────────────────────────────────────────── */}
|
||||
<div className="up-head">
|
||||
<span className="up-head-icon">
|
||||
<FileSpreadsheet size={22} />
|
||||
</span>
|
||||
<div className="up-head-body">
|
||||
<span className="up-head-name">{receipt.filename || 'Spreadsheet'}</span>
|
||||
<span className="up-head-sub">
|
||||
{totals.rows} row{totals.rows === 1 ? '' : 's'}
|
||||
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
|
||||
</span>
|
||||
<div className="up-head-row">
|
||||
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
|
||||
</div>
|
||||
<span className="up-head-meta">
|
||||
{[
|
||||
showTenant ? receipt.tenantname : null,
|
||||
receipt.locationname,
|
||||
receipt.uploadedname,
|
||||
when(receipt.created),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Summary ───────────────────────────────────────────────────────── */}
|
||||
<Metrics>
|
||||
<Metric label="Total rows" value={totals.rows || '—'} />
|
||||
@@ -251,6 +244,11 @@ export function UploadDetailDrawer({
|
||||
<Section title="File details">
|
||||
<DrawerCard>
|
||||
<Row label="File name" value={receipt.filename || '—'} />
|
||||
{/* The table's Uploaded on column. It used to be in the block of
|
||||
header this drawer drew for itself, and it is the one fact from
|
||||
that block the bar has no room for — the meta line there carries
|
||||
who and where, and a fourth item would push the title out. */}
|
||||
<Row label="Uploaded on" value={when(receipt.created)} />
|
||||
<Row label="Size" value={fileSize(file?.size_bytes)} />
|
||||
<Row label="Rows" value={totals.rows || '—'} />
|
||||
<Row label="Added" value={totals.added} />
|
||||
|
||||
@@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
fetchBatch,
|
||||
@@ -53,6 +54,7 @@ import { readStoredRows } from './shelveRows';
|
||||
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
||||
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { UploadDetailDrawer } from './UploadDetailDrawer';
|
||||
@@ -350,13 +352,12 @@ export function UploadsPanel({
|
||||
|
||||
<div className="up-toolbar-spacer" />
|
||||
|
||||
<input
|
||||
className="up-search"
|
||||
type="search"
|
||||
<SearchInput
|
||||
label="Search by file name"
|
||||
value={search}
|
||||
placeholder="Search by file name…"
|
||||
aria-label="Search by file name"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onChange={setSearch}
|
||||
placeholder="File name…"
|
||||
width={230}
|
||||
/>
|
||||
{/* "Check again" stood here. It is gone because the page reads the
|
||||
moving rows by itself now — every fifteen seconds while something is
|
||||
@@ -404,7 +405,12 @@ export function UploadsPanel({
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding={0} elevation="low">
|
||||
<Panel
|
||||
title="Uploads"
|
||||
count={paged.total}
|
||||
countLabel="uploads"
|
||||
footer={<TablePager paged={paged} label="uploads" />}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<table className="up-table">
|
||||
<thead>
|
||||
@@ -484,8 +490,7 @@ export function UploadsPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="uploads" />
|
||||
</Card>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{open ? (
|
||||
|
||||
@@ -53,19 +53,29 @@
|
||||
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
|
||||
.up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
.up-search, .up-date {
|
||||
/* `.up-search` was here. The toolbar uses `components/SearchInput` now, like
|
||||
every other search in the console.
|
||||
|
||||
The date field stays, and takes the search box's measurements so the two sit
|
||||
together: the same 38px, the same corner and the same hairline. It was 11px
|
||||
where the search is `--card-radius-sm`, which beside each other read as two
|
||||
different controls. */
|
||||
.up-date {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 11px;
|
||||
background: var(--color-surface);
|
||||
font: 400 13.5px/1 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
font: 400 13px/1 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.up-date:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||
}
|
||||
.up-search { min-width: 220px; }
|
||||
.up-date { font-variant-numeric: tabular-nums; color: var(--color-ink-2); }
|
||||
.up-search:focus, .up-date:focus { outline: 2px solid var(--color-brand); outline-offset: -1px; }
|
||||
.up-search::placeholder { color: var(--color-ink-4); }
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -114,32 +124,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Drawer: the file header ────────────────────────────────────────────── */
|
||||
/* This is what makes it an Upload Details workspace rather than the generic
|
||||
drawer: the file itself is the subject, so it gets an icon and its own block
|
||||
above everything. */
|
||||
.up-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
|
||||
.up-head-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.up-head-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.up-head-name {
|
||||
font: 600 17px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.up-head-sub { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
.up-head-meta { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-4); }
|
||||
.up-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
/* The drawer's file header used to live here — an icon, the filename and the
|
||||
status chip, drawn as a second header inside the body because the real one
|
||||
only said "Upload details". The bar names the file now, so these rules have
|
||||
no markup left to style. */
|
||||
|
||||
/* ── Drawer: the journey ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -238,7 +226,7 @@
|
||||
has to go and fix a spreadsheet. */
|
||||
.up-reject {
|
||||
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
background: color-mix(in oklab, var(--color-error) 5%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
258
src/index.css
258
src/index.css
@@ -50,6 +50,56 @@
|
||||
--color-surface-sunken: #f2f4f7;
|
||||
--color-line: #e0e4ea;
|
||||
|
||||
/* ── The card ───────────────────────────────────────────────────────────
|
||||
|
||||
ONE card for the whole console: a white fill, a hairline, a 12px corner
|
||||
and NO SHADOW. Separation comes from the line alone, so the page reads as
|
||||
a single plane with regions marked on it rather than as a tray of raised
|
||||
slabs — which is what it had become. A table inside a flat card reads as
|
||||
the table's container; inside a floating one it reads as a tile that
|
||||
happens to have a grid in it.
|
||||
|
||||
── Why these are tokens and not four numbers typed per surface ─────────
|
||||
|
||||
The app had grown FIVE card radii — 10, 12, 14, 16 and 18 — plus three
|
||||
unrelated shadow recipes, across surfaces that are all the same object:
|
||||
`.kpi`, `.panel`, `.pcard`, `.rcard`, `.ob-frame`, `.sp-card`,
|
||||
`.dispatch-map-card` and a dozen more. Each was right on the day it was
|
||||
written and none of them agreed. Anything card-shaped now reads these.
|
||||
|
||||
`--card-radius` is kept in step with Astryx's `--radius-container` BY
|
||||
HAND rather than aliased to it, because the generated theme sheet is
|
||||
scoped to `[data-astryx-theme]` and these tokens have to resolve outside
|
||||
it too — on the login, and inside a drawer. The two numbers are the same
|
||||
number; `src/theme/nearle.ts` is where the decision is recorded.
|
||||
|
||||
── The edge ────────────────────────────────────────────────────────────
|
||||
|
||||
A hairline PLUS a 1px micro-lift at 4%. Not a blur. A wide soft shadow
|
||||
makes a panel float, and a page of five floating panels is a tray of
|
||||
slabs; one crisp pixel is enough to separate the panel from the canvas
|
||||
and to make the hairline read as an edge rather than as a drawn line.
|
||||
|
||||
A bigger shadow does still exist in the app, deliberately, and belongs to
|
||||
things that genuinely float ABOVE the page: the drawer panel, a map
|
||||
popup, a chart tooltip. A card is not one of those.
|
||||
|
||||
── The edge is only half of it ─────────────────────────────────────────
|
||||
|
||||
These tokens paint a box. What makes the box read as designed rather than
|
||||
as a default is the ANATOMY inside it — a header band that names the
|
||||
panel and counts its rows, a body, a footer band that owns the pager.
|
||||
That lives in `components/Panel.tsx`, and a card that is just these
|
||||
tokens wrapped round a table is the bare rectangle this replaced. */
|
||||
--card-radius: 14px;
|
||||
--card-radius-sm: 10px; /* a card nested inside another card */
|
||||
--card-border: 1px solid var(--color-line);
|
||||
--card-bg: var(--color-surface);
|
||||
--card-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
/* The fill for a card that is deliberately quieter than the page — an
|
||||
explanatory block, a disabled region. Still flat, still hairlined. */
|
||||
--card-bg-subtle: var(--color-surface-subtle);
|
||||
|
||||
/* Neutral ramp — the ink scale every surface and label reads from. */
|
||||
--color-slate-50: #f7f7f7;
|
||||
--color-slate-100: #eff1f4;
|
||||
@@ -133,6 +183,111 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scrolling ──────────────────────────────────────────────────────────────
|
||||
|
||||
One scrolling behaviour for the whole console — the page, every panel that
|
||||
scrolls inside it, and every side drawer.
|
||||
|
||||
── What `scroll-behavior: smooth` does and does not do ─────────────────────
|
||||
|
||||
It animates PROGRAMMATIC scrolls: `scrollIntoView`, `scrollTo`, a jump to a
|
||||
`#hash`, and the browser's own move when focus lands on something off
|
||||
screen. It does NOT touch the mouse wheel, a trackpad or a touch drag —
|
||||
those are the operating system's and are already smooth. So this is the
|
||||
difference between a page that JUMPS when you tab into a field below the
|
||||
fold, or when the uploads drawer jumps you to its rejection panel, and one
|
||||
that travels there.
|
||||
|
||||
Declared here rather than per component so nothing is left out: a container
|
||||
that scrolls and is not in the list below is the one that will feel wrong.
|
||||
|
||||
Accessibility is already handled — the reduced-motion block above resets
|
||||
`scroll-behavior` to `auto !important` for everything, so a person who has
|
||||
asked for less motion gets the instant jump back.
|
||||
|
||||
── The two properties on `html` that are not about smoothness ─────────────
|
||||
|
||||
`scroll-padding-top` is the sticky header's height. Without it, anything
|
||||
scrolled to by the browser lands UNDER the 56px bar — the field you tabbed
|
||||
to is behind the nav, which reads as the page having scrolled to the wrong
|
||||
place.
|
||||
|
||||
`scrollbar-gutter: stable` reserves the scrollbar's width whether or not
|
||||
there is one. It stops the whole layout shifting sideways when a short page
|
||||
becomes a long one, when a filter empties a table, and — the reason it is
|
||||
here — when a drawer opens and locks the page behind it. */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* 56px bar + its 1px rule + 8px so the target is not flush against it. */
|
||||
scroll-padding-top: 65px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Every panel that scrolls inside the page.
|
||||
|
||||
`:where()` keeps the specificity at zero, so any of these can still set its
|
||||
own behaviour without an `!important` fight. */
|
||||
:where(
|
||||
.table-scroll, /* every table, sideways */
|
||||
.rail-scroll, /* the catalogue's category rail */
|
||||
.bulkreq-list, /* the bulk stock request */
|
||||
.district-list, /* choosing a partner's district */
|
||||
.kpi-strip, /* the console's KPI row, sideways */
|
||||
.chart2, /* the console's bar chart, sideways */
|
||||
.rail-filter-chips, /* dispatch's filter chips, sideways */
|
||||
.stops-timeline-carousel
|
||||
) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
/* The side drawers are not in that list. `.drawer-body` sets its own, beside
|
||||
the `overscroll-behavior` it already had — the drawer system owns its
|
||||
scrolling in `drawer.css`, and splitting half of it across two files is how
|
||||
one of the two gets changed alone. */
|
||||
|
||||
/* ── One scrollbar ──────────────────────────────────────────────────────────
|
||||
|
||||
Both properties are inherited, so declaring them on `html` reaches every
|
||||
scroll container in the app — including the ones inside drawers, which is
|
||||
how a drawer's scrollbar stops being a 15px platform-grey slab beside a
|
||||
520px panel.
|
||||
|
||||
The webkit block below says the same thing again for Chrome, Edge and
|
||||
Safari, where it wins over the standard properties. It is not redundant:
|
||||
only the pseudo-elements can round the thumb and give it a hover state, and
|
||||
a bar that responds to the pointer is most of what reads as "considered"
|
||||
rather than "default". */
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-line) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: var(--color-line);
|
||||
/* Drawn inside a transparent border, which is the only way to inset a
|
||||
webkit thumb — it leaves the bar reading as a 6px rail rather than a 10px
|
||||
slab, while keeping a 10px target for the pointer. */
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-ink-4);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
/* Where the two bars meet. Left at the platform default it is a small grey
|
||||
square in the corner of every scrolling panel. */
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Numeric columns line up only with tabular figures. Used by every KPI value
|
||||
and every money/count cell in a table. */
|
||||
.tabular {
|
||||
@@ -681,9 +836,9 @@ main {
|
||||
}
|
||||
|
||||
.catalogue-rail {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
/* The clip lives here, on the element that owns the radius. */
|
||||
@@ -694,10 +849,10 @@ main {
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 98px);
|
||||
overflow-y: auto;
|
||||
/* A thin, quiet scrollbar. The default is wide enough to read as a second
|
||||
column beside a 216px rail. */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-line) transparent;
|
||||
/* The thin, quiet scrollbar this rail used to declare for itself is now the
|
||||
whole console's — both scrollbar properties are inherited, so the pair on
|
||||
`html` reaches here. This rail was the only place that had been fixed, and
|
||||
it is why every other panel's bar looked like a different product's. */
|
||||
}
|
||||
|
||||
.rail-scroll::-webkit-scrollbar {
|
||||
@@ -810,16 +965,18 @@ main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
overflow: hidden;
|
||||
transition: border-color .18s, box-shadow .18s;
|
||||
transition: border-color .18s;
|
||||
}
|
||||
|
||||
/* Hover is a stronger LINE, not a lift. In a flat system a card that rises on
|
||||
hover is the only thing on the page with a z-axis, which reads as a bug
|
||||
rather than as feedback. */
|
||||
.pcard:hover {
|
||||
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent);
|
||||
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.pcard-media {
|
||||
@@ -1278,9 +1435,9 @@ main {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: border-color .18s, background .18s, transform .18s;
|
||||
}
|
||||
@@ -1300,8 +1457,8 @@ main {
|
||||
read. */
|
||||
.ob-drop {
|
||||
border: 1.5px dashed var(--color-slate-300);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg-subtle);
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
transition: border-color .18s, background .18s;
|
||||
@@ -1359,9 +1516,9 @@ main {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg-subtle);
|
||||
border: var(--card-border);
|
||||
}
|
||||
.ob-flow-icon {
|
||||
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
|
||||
@@ -1403,9 +1560,9 @@ main {
|
||||
body (the step's own fields) and foot (Back / Continue). The bands are
|
||||
separated by rules rather than gaps so the card reads as one object. */
|
||||
.ob-frame {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 18px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1465,7 +1622,7 @@ main {
|
||||
.ob-returnbar {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
@@ -1499,9 +1656,9 @@ main {
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
/* Text renders inline, so the title and its detail ran together on one line
|
||||
@@ -1540,7 +1697,7 @@ main {
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--color-warning-line, #e8d9a8);
|
||||
background: var(--color-warning-tint, #fdf7e6);
|
||||
color: var(--color-ink-1);
|
||||
@@ -1593,9 +1750,9 @@ main {
|
||||
something is picked, then takes the brand tint and shows its actions. */
|
||||
.bulkbar {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
@@ -1685,9 +1842,9 @@ main {
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.qty-step {
|
||||
@@ -1737,9 +1894,9 @@ main {
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg-subtle);
|
||||
}
|
||||
|
||||
.ob-guide[data-tone='warn'] {
|
||||
@@ -1814,7 +1971,10 @@ main {
|
||||
laptop and sit under the panel. The brief is explicit that the panel is not
|
||||
to be touched, and a background is not worth the risk of moving it. */
|
||||
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff);
|
||||
border-radius: 16px;
|
||||
/* The card radius, even though this is a tinted REGION rather than a card:
|
||||
it holds cards, and a 16px region around 12px cards reads as a rounding
|
||||
error rather than as a nesting. */
|
||||
border-radius: var(--card-radius);
|
||||
padding: 18px;
|
||||
}
|
||||
@media (max-width: 720px) { .sp-page { padding: 12px; } }
|
||||
@@ -1827,10 +1987,9 @@ main {
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 16px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
/* The store, not the person. A shop mark rather than an avatar — this record
|
||||
@@ -1946,10 +2105,9 @@ main {
|
||||
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
.sp-card {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sp-card[data-span='full'] { grid-column: 1 / -1; }
|
||||
@@ -2078,9 +2236,9 @@ main {
|
||||
.district-list {
|
||||
max-height: 208px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.district-row {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* Source: src\theme\nearle.ts
|
||||
* Command: astryx theme build src\theme\nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
@@ -88,9 +88,9 @@
|
||||
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--radius-element: 12px;
|
||||
--radius-container: 16px;
|
||||
--radius-page: 16px;
|
||||
--radius-chat: 16px;
|
||||
--radius-container: 14px;
|
||||
--radius-page: 14px;
|
||||
--radius-chat: 14px;
|
||||
--text-body-size: 14px;
|
||||
--text-label-size: 14px;
|
||||
--text-large-size: 17px;
|
||||
|
||||
4
src/theme/nearle.d.ts
vendored
4
src/theme/nearle.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* Source: src\theme\nearle.ts
|
||||
* Command: astryx theme build src\theme\nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* Source: src\theme\nearle.ts
|
||||
* Command: astryx theme build src\theme\nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
@@ -21,9 +21,9 @@ export const nearleTheme = {
|
||||
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
|
||||
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
||||
"--radius-element": "12px",
|
||||
"--radius-container": "16px",
|
||||
"--radius-page": "16px",
|
||||
"--radius-chat": "16px",
|
||||
"--radius-container": "14px",
|
||||
"--radius-page": "14px",
|
||||
"--radius-chat": "14px",
|
||||
"--text-body-size": "14px",
|
||||
"--text-label-size": "14px",
|
||||
"--text-large-size": "17px",
|
||||
|
||||
@@ -50,16 +50,33 @@ export const nearleTheme = defineTheme({
|
||||
},
|
||||
|
||||
tokens: {
|
||||
// ── Radius, matched to KROW ──────────────────────────────────────────
|
||||
// KROW uses exactly three radii: 16px on cards, 12px on controls, full on
|
||||
// pills. Astryx defaults to 8px on controls and 12px on cards, with
|
||||
// page/chat at 28px — that 28px is where the odd oversized surfaces in the
|
||||
// old console came from. `--radius-inner` (4px) is left alone: KROW does
|
||||
// use a 4px radius on its smallest inline markers.
|
||||
// ── Radius ───────────────────────────────────────────────────────────
|
||||
// ONE card radius for the whole console, and this is where it is set:
|
||||
// `--radius-container` is what Astryx's `Card` rounds by, so every one of
|
||||
// the 110 cards in the app takes its corner from this line. The
|
||||
// hand-rolled surfaces read the same number through `--card-radius` in
|
||||
// `index.css`, which is defined from this token.
|
||||
//
|
||||
// 14. The app had grown five card radii — 10, 12, 14, 16 and 18 — across
|
||||
// surfaces that are all the same object, and none of them agreed.
|
||||
//
|
||||
// 14 rather than 12 because the card is a FRAMED panel now, with a tinted
|
||||
// header band and a footer band inside the same clip: at 12 the band's
|
||||
// corner and the panel's corner were close enough to look like a
|
||||
// mis-rounding, and the header's fill reads better against a slightly
|
||||
// softer edge. A plain box is happier at 12; this is not a plain box —
|
||||
// see `components/Panel.tsx`.
|
||||
//
|
||||
// `--radius-element` stays at 12: a control is one step tighter than the
|
||||
// panel that holds it, which is what keeps a button inside a card from
|
||||
// looking like a small card.
|
||||
//
|
||||
// `--radius-inner` (4px) is left alone — Astryx uses it on the smallest
|
||||
// inline markers, which are not cards.
|
||||
'--radius-element': '12px',
|
||||
'--radius-container': '16px',
|
||||
'--radius-page': '16px',
|
||||
'--radius-chat': '16px',
|
||||
'--radius-container': '14px',
|
||||
'--radius-page': '14px',
|
||||
'--radius-chat': '14px',
|
||||
|
||||
// Control heights are NOT overridden: Astryx's `--size-element-md` is
|
||||
// already 32px, which is exactly KROW's control height.
|
||||
|
||||
Reference in New Issue
Block a user