ui improvement
This commit is contained in:
@@ -75,7 +75,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
margin: '48px auto',
|
margin: '48px auto',
|
||||||
maxWidth: 620,
|
maxWidth: 620,
|
||||||
padding: '28px 32px',
|
padding: '28px 32px',
|
||||||
borderRadius: 16,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: '1px solid #F1D3D3',
|
border: '1px solid #F1D3D3',
|
||||||
background: '#FFFBFB',
|
background: '#FFFBFB',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
|
|||||||
@@ -1,99 +1,89 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import './kpiCard.css';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
/**
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
* A KPI tile — the console's headline figure.
|
||||||
|
*
|
||||||
|
* ── There were two of these, and neither worked ─────────────────────────────
|
||||||
|
*
|
||||||
|
* This component put an icon inline with the label and CENTRED the value
|
||||||
|
* underneath it, which is why a row of tiles never lined up: a centred number
|
||||||
|
* sits in a different place in every tile depending on how long it is.
|
||||||
|
* `console.css` had a second, unrelated implementation — `.kpi` — with a
|
||||||
|
* brand-tinted icon tile on the left and the text stacked beside it. The two
|
||||||
|
* appeared on the same screen. The second was the better design and this is
|
||||||
|
* now that design, for both.
|
||||||
|
*
|
||||||
|
* ── `note` is rendered again, and that is the real change ───────────────────
|
||||||
|
*
|
||||||
|
* The line under the figure — "142 app · 38 counter", "12% of app orders" —
|
||||||
|
* was removed from the tile at some point but never removed from the CALLERS:
|
||||||
|
* 86 of them still compute and pass it, and it was being thrown away. That is
|
||||||
|
* the half of a KPI that carries the meaning. A bare `0` under "Unsynced
|
||||||
|
* bills" reads as "no data" when it means everything is in the books, and `42`
|
||||||
|
* under "Total orders" says nothing about which channel they came through.
|
||||||
|
*
|
||||||
|
* ── `tone` does something now ───────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* All five tones used to map to `var(--color-brand)`, so the prop was
|
||||||
|
* decorative — every tile was purple whether it reported takings or a failure.
|
||||||
|
* The tone now colours the icon tile only: the figure itself stays in ink,
|
||||||
|
* because a number that changes colour with its own value is hard to compare
|
||||||
|
* against the tile beside it. Brand remains the default, so a tile that is
|
||||||
|
* merely reporting is not shouting.
|
||||||
|
*/
|
||||||
|
|
||||||
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
|
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
|
||||||
|
|
||||||
const TONE_COLOR: Record<KpiTone, string> = {
|
|
||||||
accent: 'var(--color-brand)',
|
|
||||||
success: 'var(--color-brand)',
|
|
||||||
warning: 'var(--color-brand)',
|
|
||||||
error: 'var(--color-brand)',
|
|
||||||
neutral: 'var(--color-brand)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface KpiCardProps {
|
export interface KpiCardProps {
|
||||||
/** Small-caps label. Say what it is, not what it means. */
|
/** Small-caps label. Say what it is, not what it means. */
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
/**
|
/**
|
||||||
* Accepted and ignored, like `fill` below.
|
* The line under the figure — what the number is made of, or what it is a
|
||||||
*
|
* share of. This is where a KPI gets its meaning; see the note above.
|
||||||
* This was the sub-pill — where the number got its meaning, since "1 of 84
|
|
||||||
* orders" says something "1.2%" does not. The pill was removed from the tile
|
|
||||||
* deliberately; 87 call sites across 21 files still pass the text, so the
|
|
||||||
* prop stays declared to keep them compiling and is not read.
|
|
||||||
*
|
|
||||||
* That means every one of those 87 strings is now written and never shown.
|
|
||||||
* Either drop them from the callers or bring the pill back — right now it is
|
|
||||||
* neither, and this comment is here so that is visible rather than a puzzle
|
|
||||||
* for whoever next wonders where "84 orders" went.
|
|
||||||
*/
|
*/
|
||||||
note?: string;
|
note?: string;
|
||||||
|
/**
|
||||||
|
* Colours the icon tile. `neutral` and `accent` are both the brand — a tile
|
||||||
|
* reports by default and only says more when something is actually wrong.
|
||||||
|
*/
|
||||||
tone?: KpiTone;
|
tone?: KpiTone;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Accepted and ignored.
|
* Accepted and ignored.
|
||||||
*
|
*
|
||||||
* The underline bar this used to drive was removed from the tile. Six call
|
* The 2px underline bar this drove was removed from the tile deliberately —
|
||||||
* sites still pass it, so the prop stays declared to keep them compiling —
|
* a proportion bar under five tiles turned the strip into a chart. Six call
|
||||||
* it is not read. Either drop it from the callers or restore the bar; right
|
* sites still pass it, so the prop stays declared to keep them compiling.
|
||||||
* now it is neither, and this comment is here so that is visible.
|
* Drop it from those callers and this can go.
|
||||||
*/
|
*/
|
||||||
fill?: number;
|
fill?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
|
||||||
* A KPI tile.
|
|
||||||
*
|
|
||||||
* Label and icon, then a large tabular value. The sub-pill and the 2px tone
|
|
||||||
* underline were both removed from the tile; `note` and `fill` still arrive
|
|
||||||
* from the call sites and are ignored — see the props above. Sized to sit 4–6
|
|
||||||
* across rather than 4 — a console is glanced at all day, not read once.
|
|
||||||
*/
|
|
||||||
export function KpiCard({ label, value, tone = 'neutral', icon }: KpiCardProps) {
|
|
||||||
const color = TONE_COLOR[tone];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<div className="kpi-card" data-tone={tone}>
|
||||||
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
|
{/* Label and icon share the top row; the figure gets the full width of
|
||||||
{/* Icon first, then the label; the pill sits opposite it. Both are
|
the card underneath them.
|
||||||
fixed to this row so tiles line up whether or not they carry one. */}
|
|
||||||
{/* A fixed height, so a label that wraps to two lines (a long note
|
The icon used to sit in a column of its own to the LEFT, and at the
|
||||||
squeezes it) does not push that tile's value below its neighbours'. */}
|
width these tiles actually get — five across a 1,224px column, less
|
||||||
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
|
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
|
||||||
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
left about 134px for the number. "₹1,24,500" does not fit in 134px at
|
||||||
|
24px bold, so it wrapped, and it wrapped mid-figure: the first tile
|
||||||
|
on the Console read "₹1,24,50" on one line and "0" on the next. */}
|
||||||
|
<div className="kpi-card-head">
|
||||||
|
<span className="kpi-card-label">{label}</span>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
|
<span className="kpi-card-icon" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<Text
|
</div>
|
||||||
type="label"
|
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* Note pill removed as requested */}
|
<span className="kpi-card-value">{value}</span>
|
||||||
</HStack>
|
{note ? <span className="kpi-card-note">{note}</span> : null}
|
||||||
|
</div>
|
||||||
<HStack justify="center" align="center" style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
as="div"
|
|
||||||
hasTabularNumbers
|
|
||||||
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/components/Panel.tsx
Normal file
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',
|
alignItems: 'center',
|
||||||
gap: 14,
|
gap: 14,
|
||||||
padding: '14px 16px',
|
padding: '14px 16px',
|
||||||
borderRadius: 14,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
background: 'var(--color-surface-subtle)',
|
background: 'var(--color-surface-subtle)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -153,7 +153,7 @@ export function SheetDropzone({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
borderRadius: 13,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: `1.5px dashed ${
|
border: `1.5px dashed ${
|
||||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||||
}`,
|
}`,
|
||||||
|
|||||||
88
src/components/TabBar.tsx
Normal file
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 isMobile = useIsMobile();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A new page starts at the top.
|
||||||
|
|
||||||
|
A single-page app does not reload, so the document keeps whatever scroll
|
||||||
|
offset the last page was left at: read Sales down to row 40, click Inventory,
|
||||||
|
and Inventory opens 2,000px down — usually past everything it has, so it
|
||||||
|
looks empty. Nothing in the app was resetting this.
|
||||||
|
|
||||||
|
INSTANT, deliberately, and it is the one place in the console that opts out
|
||||||
|
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
|
||||||
|
animate those 2,000px on every navigation, which means a second of the new
|
||||||
|
page flying past before it settles — the page arriving late, rather than the
|
||||||
|
page arriving. Smooth is for a move you asked for within a page; a route
|
||||||
|
change is not one.
|
||||||
|
|
||||||
|
Keyed on `pathname` alone, not on the whole location: the branch scope and
|
||||||
|
the date range travel in the query string, and re-scoping a table you are
|
||||||
|
halfway down should leave you where you were.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
// Escape closes the account menu. A menu that can only be dismissed by
|
// Escape closes the account menu. A menu that can only be dismissed by
|
||||||
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||||
// sits over the page rather than beside it.
|
// sits over the page rather than beside it.
|
||||||
|
|||||||
143
src/components/tabBar.css
Normal file
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%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface-sunken);
|
background: var(--color-surface-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +84,11 @@
|
|||||||
|
|
||||||
.trail-map .leaflet-popup-content-wrapper {
|
.trail-map .leaflet-popup-content-wrapper {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
border-radius: 14px !important;
|
border-radius: var(--card-radius) !important;
|
||||||
background: #ffffff !important;
|
background: var(--card-bg) !important;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
|
||||||
|
genuinely floats, over a map, which is what a shadow is for. */
|
||||||
|
border: var(--card-border) !important;
|
||||||
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
|
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -69,6 +69,40 @@ export function LoginPage() {
|
|||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
/*
|
||||||
|
Each step brings its own panel into view.
|
||||||
|
|
||||||
|
The three steps are one screen, and on a phone the brand panel sits ABOVE the
|
||||||
|
form — so the card is taller than the viewport and answering the email step
|
||||||
|
replaces the panel below the fold. Without this the page does not move: you
|
||||||
|
press Continue, something changes off screen, and the password field you are
|
||||||
|
meant to type into is never shown to you.
|
||||||
|
|
||||||
|
Smooth rather than a jump, because unlike a route change this is a move
|
||||||
|
WITHIN a screen the reader is already looking at, and seeing the page travel
|
||||||
|
is what connects the button they pressed to the field that arrived. It is
|
||||||
|
skipped on the first render — arriving at a login already scrolled to the
|
||||||
|
form would hide the brand and the "which account is this" context above it —
|
||||||
|
and a reader who has asked for reduced motion gets the instant jump, via the
|
||||||
|
global rule in `index.css`.
|
||||||
|
|
||||||
|
DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic:
|
||||||
|
hooks must run in the same order on every render, and this component returns
|
||||||
|
early the moment a session exists. Put below that line, these two refs and
|
||||||
|
the effect would simply stop being called on the render that signs somebody
|
||||||
|
in — which is React's "rendered fewer hooks than expected" crash, on the
|
||||||
|
happy path.
|
||||||
|
*/
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isFirstStep = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstStep.current) {
|
||||||
|
isFirstStep.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||||
|
|
||||||
/** Step one: which door does this email need? */
|
/** Step one: which door does this email need? */
|
||||||
@@ -168,17 +202,31 @@ export function LoginPage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="login-split"
|
className="login-split"
|
||||||
|
/* The sign-in card is the console's card, at the console's corner.
|
||||||
|
|
||||||
|
It was the largest radius in the product at 28px, carrying a
|
||||||
|
two-layer drop shadow, and it was the FIRST surface anybody saw —
|
||||||
|
so it set an expectation the rest of the app then did not meet. It
|
||||||
|
is now the same flat hairline card as every other surface, just
|
||||||
|
bigger. */
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 1024,
|
maxWidth: 1024,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
borderRadius: 28,
|
borderRadius: 'var(--card-radius)',
|
||||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
boxShadow: 'var(--card-shadow)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
|
{/* The ref lives on a wrapper rather than inside the two panels, so
|
||||||
|
neither has to know it is being scrolled to. `display: grid` keeps
|
||||||
|
it out of the way: as a grid item the wrapper stretches to the row,
|
||||||
|
and the panel inside stretches with it, which is what centres the
|
||||||
|
form vertically beside the brand. A plain block here would collapse
|
||||||
|
to its content's height and un-centre it. */}
|
||||||
|
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
|
||||||
{step !== 'setup' ? (
|
{step !== 'setup' ? (
|
||||||
<FormPanel
|
<FormPanel
|
||||||
step={step}
|
step={step}
|
||||||
@@ -212,6 +260,7 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { IconButton } from '@astryxdesign/core/IconButton';
|
|||||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { Token } from '@astryxdesign/core/Token';
|
import { Token } from '@astryxdesign/core/Token';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||||
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||||
import { APP_BROWSE_CATEGORY } from './tenantCategories';
|
import { APP_BROWSE_CATEGORY } from './tenantCategories';
|
||||||
@@ -391,16 +391,12 @@ export function CatalogueBrowser({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<SearchInput
|
||||||
label="Search the catalogue"
|
label="Search the catalogue"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
width={220}
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search products…"
|
placeholder="Search products…"
|
||||||
startIcon={<Search size={14} />}
|
width={220}
|
||||||
hasClear
|
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
Bullets,
|
Bullets,
|
||||||
DrawerButton,
|
DrawerButton,
|
||||||
DrawerCard,
|
DrawerCard,
|
||||||
|
Metric,
|
||||||
|
Metrics,
|
||||||
|
Mono,
|
||||||
Row,
|
Row,
|
||||||
Section,
|
Section,
|
||||||
} from '@/features/store-admin/drawerKit';
|
} from '@/features/store-admin/drawerKit';
|
||||||
@@ -102,7 +105,7 @@ export function CatalogueDetailDrawer({
|
|||||||
subtitle={`${brand} · catalogue id ${product.id}`}
|
subtitle={`${brand} · catalogue id ${product.id}`}
|
||||||
width={540}
|
width={540}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
|
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
|
||||||
{...(!isImported && !blockedReason && onImport
|
{...(!isImported && !blockedReason && onImport
|
||||||
? {
|
? {
|
||||||
isFooterFilled: true,
|
isFooterFilled: true,
|
||||||
@@ -162,7 +165,7 @@ export function CatalogueDetailDrawer({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
|
<span className="drawer-metric-note">
|
||||||
{images.length} photos — only the first is imported
|
{images.length} photos — only the first is imported
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -171,31 +174,13 @@ export function CatalogueDetailDrawer({
|
|||||||
|
|
||||||
{/* A RANGE, not a price. What the shop charges is set after the import,
|
{/* A RANGE, not a price. What the shop charges is set after the import,
|
||||||
and conflating the two is how a catalogue figure ends up on a shelf. */}
|
and conflating the two is how a catalogue figure ends up on a shelf. */}
|
||||||
<DrawerCard tone="brand">
|
{/* The kit's own metric, not a 22px figure typed here. It was the largest
|
||||||
<div
|
type in any drawer in the console and it sat on a catalogue REFERENCE
|
||||||
style={{
|
— louder than the selling price in the product drawer next to it. */}
|
||||||
display: 'flex',
|
<Metrics cols={2}>
|
||||||
alignItems: 'center',
|
<Metric label="Market price range" value={product.price_range ?? '—'} />
|
||||||
justifyContent: 'space-between',
|
<Metric label="Pack size" value={product.size || '—'} isSmall />
|
||||||
gap: 16,
|
</Metrics>
|
||||||
padding: '14px 16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
||||||
<span className="drawer-metric-label">Market price range</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
font: '600 22px/1.2 var(--font-sans)',
|
|
||||||
color: 'var(--color-ink-1)',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{product.price_range ?? '—'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
|
|
||||||
</div>
|
|
||||||
</DrawerCard>
|
|
||||||
|
|
||||||
{product.description ? (
|
{product.description ? (
|
||||||
<Section title="Description">
|
<Section title="Description">
|
||||||
@@ -243,7 +228,7 @@ export function CatalogueDetailDrawer({
|
|||||||
label={fact.label}
|
label={fact.label}
|
||||||
value={
|
value={
|
||||||
fact.isMono ? (
|
fact.isMono ? (
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
|
<Mono>{fact.value}</Mono>
|
||||||
) : (
|
) : (
|
||||||
fact.value
|
fact.value
|
||||||
)
|
)
|
||||||
@@ -282,21 +267,21 @@ export function CatalogueDetailDrawer({
|
|||||||
label="Shown in the app under"
|
label="Shown in the app under"
|
||||||
value={
|
value={
|
||||||
aisle ? (
|
aisle ? (
|
||||||
<Badge label={aisle} colour="#0f8a5f" />
|
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
||||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
<span className="drawer-caption">
|
||||||
{suggested.rule === 'catalogue'
|
{suggested.rule === 'catalogue'
|
||||||
? `The catalogue's own category for this product.`
|
? `The catalogue's own category for this product.`
|
||||||
: product.category
|
: product.category
|
||||||
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
|
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
|
||||||
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
|
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
<span className="drawer-caption">
|
||||||
{aisle
|
{aisle
|
||||||
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
|
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
|
||||||
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
|
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
|
||||||
|
|||||||
@@ -63,40 +63,84 @@
|
|||||||
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
|
/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
|
||||||
row and a sparkline; with those gone it is a label and a number, and the
|
`.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
|
||||||
layout should not still be shaped around what it no longer holds. */
|
same object that `components/KpiCard.tsx` renders, with a different icon
|
||||||
.kpi {
|
radius, a different value size and a different gap. Both appeared on the
|
||||||
border: 1px solid var(--color-line); border-radius: 16px;
|
same screen. The component won, this strip now renders it, and the classes
|
||||||
background: var(--color-surface); padding: 16px;
|
are gone rather than left as a near-copy for someone to edit by mistake. */
|
||||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
|
|
||||||
}
|
|
||||||
.kpi-icon {
|
|
||||||
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
|
|
||||||
background: var(--color-brand-tint); color: var(--color-brand);
|
|
||||||
}
|
|
||||||
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
||||||
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
|
||||||
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
|
|
||||||
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
|
|
||||||
has stopped doing its job. The min-height keeps all five value rows level
|
|
||||||
whether the label took one line or two. */
|
|
||||||
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
|
|
||||||
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Panels ─────────────────────────────────────────────────────────────── */
|
/* ── Panels ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
The Console's panels are the SAME object as `components/Panel.tsx` — a
|
||||||
|
framed card with a header band, a body and a footer band. They stay as CSS
|
||||||
|
classes rather than becoming that component because these six sections each
|
||||||
|
lay their own body out (a chart, a donut, a map, a list of alerts) and have
|
||||||
|
no table or pager to hand it; the component's job is the table case.
|
||||||
|
|
||||||
|
What matters is that they are the same DESIGN. They were not: this file had
|
||||||
|
a white header with a 15.5px title and a sub-line, the component has a
|
||||||
|
tinted band with an 11px eyebrow, and the two sat on the same screen. The
|
||||||
|
band and the type below are now the component's, restated here.
|
||||||
|
|
||||||
|
The panel no longer pads itself — the bands and the body pad separately, or
|
||||||
|
a header band cannot reach the panel's edges. */
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--color-line); border-radius: 16px;
|
border: var(--card-border); border-radius: var(--card-radius);
|
||||||
background: var(--color-surface); padding: 18px;
|
background: var(--card-bg); box-shadow: var(--card-shadow);
|
||||||
display: flex; flex-direction: column; gap: 14px; min-width: 0;
|
display: flex; flex-direction: column; min-width: 0;
|
||||||
|
/* The clip belongs on the element that owns the radius, or the header
|
||||||
|
band's tint squares off the top two corners. */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The body. Every direct child of `.panel` that is not a band is content, so
|
||||||
|
the padding and the stack live on a wrapper the sections already render. */
|
||||||
|
.panel > :not(.panel-head):not(.panel-foot) {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
/* Consecutive content blocks should not double their gutter. */
|
||||||
|
.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
gap: 14px; flex-wrap: wrap;
|
||||||
|
min-height: 48px; padding: 8px 18px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border-bottom: var(--card-border);
|
||||||
|
}
|
||||||
|
/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a
|
||||||
|
card competes with the page's own title above it. */
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font: 600 11px/1.2 var(--font-sans);
|
||||||
|
letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
}
|
||||||
|
/* Title and sub-line sit on ONE row, the way the table panel's title and count
|
||||||
|
do. Every section wraps the pair in a plain `div`, which stacks them by
|
||||||
|
default — and a stacked pair is the two-line header this band replaced. */
|
||||||
|
.panel-head > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.panel-sub {
|
||||||
|
margin: 0;
|
||||||
|
font: 400 12px/1.4 var(--font-sans);
|
||||||
|
color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
|
|
||||||
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
|
|
||||||
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
|
||||||
.panel-foot {
|
.panel-foot {
|
||||||
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
|
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
|
padding: 8px 18px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border-top: var(--card-border);
|
||||||
|
font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3);
|
||||||
}
|
}
|
||||||
.panel-empty {
|
.panel-empty {
|
||||||
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
|
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
|
||||||
@@ -198,8 +242,8 @@
|
|||||||
.share {
|
.share {
|
||||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--color-line); border-radius: 12px;
|
border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
}
|
}
|
||||||
.share .donut-key { align-self: center; }
|
.share .donut-key { align-self: center; }
|
||||||
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
|
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||||
@@ -236,8 +280,8 @@
|
|||||||
.quick-list { display: flex; flex-direction: column; gap: 8px; }
|
.quick-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.quick {
|
.quick {
|
||||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
|
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
|
||||||
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
|
padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||||
text-decoration: none; background: var(--color-surface);
|
text-decoration: none; background: var(--card-bg);
|
||||||
}
|
}
|
||||||
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||||
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||||
@@ -277,13 +321,8 @@
|
|||||||
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
|
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
|
||||||
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
.search {
|
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
|
||||||
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
|
input version of the search box. Both use `components/SearchInput` now. */
|
||||||
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
|
|
||||||
background: var(--color-surface); color: var(--color-ink-4);
|
|
||||||
}
|
|
||||||
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
|
|
||||||
.search:focus-within { border-color: var(--color-brand); }
|
|
||||||
|
|
||||||
/* ── Segmented control ──────────────────────────────────────────────────── */
|
/* ── Segmented control ──────────────────────────────────────────────────── */
|
||||||
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
|
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
|
||||||
@@ -303,7 +342,7 @@
|
|||||||
|
|
||||||
.attention-card {
|
.attention-card {
|
||||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
|
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
|
||||||
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
|
align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius);
|
||||||
}
|
}
|
||||||
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
|
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
|
||||||
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
|
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
|
||||||
@@ -324,8 +363,8 @@
|
|||||||
/* ── Loading ────────────────────────────────────────────────────────────── */
|
/* ── Loading ────────────────────────────────────────────────────────────── */
|
||||||
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
|
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
|
||||||
.sk-card, .sk-chart {
|
.sk-card, .sk-chart {
|
||||||
border: 1px solid var(--color-line); border-radius: 16px;
|
border: var(--card-border); border-radius: var(--card-radius);
|
||||||
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.sk-card { height: 104px; }
|
.sk-card { height: 104px; }
|
||||||
.sk-chart { height: 300px; }
|
.sk-chart { height: 300px; }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface BranchOverviewProps {
|
|||||||
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
No branches are set up yet.
|
No branches are set up yet.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="branch-table">
|
<table className="branch-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
|
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
|
||||||
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { count, money } from '@/features/store-admin/format';
|
import { count, money } from '@/features/store-admin/format';
|
||||||
import type { ConsoleTotals } from '../consoleModel';
|
import type { ConsoleTotals } from '../consoleModel';
|
||||||
|
|
||||||
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kpi-strip">
|
<div className="kpi-strip">
|
||||||
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
<KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||||
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
<KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||||
<Kpi
|
<KpiCard
|
||||||
icon={<ListChecks size={17} />}
|
icon={<ListChecks size={17} />}
|
||||||
label="Total Orders"
|
label="Total Orders"
|
||||||
value={count(totals.totalOrders)}
|
value={count(totals.totalOrders)}
|
||||||
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
|
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
|
||||||
/>
|
/>
|
||||||
<Kpi
|
<KpiCard
|
||||||
icon={<Ban size={17} />}
|
icon={<Ban size={17} />}
|
||||||
label="Cancelled Orders"
|
label="Cancelled Orders"
|
||||||
value={count(totals.cancelled)}
|
value={count(totals.cancelled)}
|
||||||
@@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
|||||||
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
<Kpi
|
<KpiCard
|
||||||
icon={<CloudUpload size={17} />}
|
icon={<CloudUpload size={17} />}
|
||||||
label="Unsynced Bills"
|
label="Unsynced Bills"
|
||||||
value={count(totals.pendingBills)}
|
value={count(totals.pendingBills)}
|
||||||
@@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Kpi({
|
|
||||||
icon, label, value, note,
|
|
||||||
}: {
|
|
||||||
icon: ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
note?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article className="kpi">
|
|
||||||
<span className="kpi-icon" aria-hidden>{icon}</span>
|
|
||||||
<div className="kpi-head">
|
|
||||||
<span className="kpi-label">{label}</span>
|
|
||||||
<span className="kpi-value">{value}</span>
|
|
||||||
{note ? <span className="kpi-note">{note}</span> : null}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface ShopSummaryProps {
|
|||||||
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return (
|
return (
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
No branch is selected.
|
No branch is selected.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
|||||||
const total = row.onlineRevenue + row.counterRevenue;
|
const total = row.onlineRevenue + row.counterRevenue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack gap={2} align="start" wrap="wrap">
|
<HStack gap={2} align="start" wrap="wrap">
|
||||||
<span className="shop-mark" aria-hidden>
|
<span className="shop-mark" aria-hidden>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Search } from 'lucide-react';
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { branchLabel, count, money } from '@/features/store-admin/format';
|
import { branchLabel, count, money } from '@/features/store-admin/format';
|
||||||
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
|
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
|
||||||
import type { BranchRow, HealthTone } from '../consoleModel';
|
import type { BranchRow, HealthTone } from '../consoleModel';
|
||||||
@@ -111,15 +111,13 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="search">
|
<SearchInput
|
||||||
<Search size={14} aria-hidden />
|
label="Search tills"
|
||||||
<input
|
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(event) => setTerm(event.target.value)}
|
onChange={setTerm}
|
||||||
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
|
placeholder={showBranch ? 'Till ID, branch…' : 'Till ID…'}
|
||||||
aria-label="Search tills"
|
width={220}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Bike, Check, Truck } from 'lucide-react';
|
import { Bike, Check, Truck } from 'lucide-react';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
@@ -8,7 +7,7 @@ import { tenantsApi } from '@/api/tenants';
|
|||||||
import { useAllPartners, useAppRegions } from '@/queries/hooks';
|
import { useAllPartners, useAppRegions } from '@/queries/hooks';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
import { Alert, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which delivery partner supplies a merchant's riders.
|
* Which delivery partner supplies a merchant's riders.
|
||||||
@@ -89,11 +88,7 @@ export function PartnerAssignDrawer({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
{error ? (
|
{error ? <Alert>{error}</Alert> : null}
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Note icon={<Bike size={15} />}>
|
<Note icon={<Bike size={15} />}>
|
||||||
A partner supplies riders to this shop. With one set, the assign screen offers the
|
A partner supplies riders to this shop. With one set, the assign screen offers the
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Bike, Plus } from 'lucide-react';
|
import { Bike, Plus } from 'lucide-react';
|
||||||
import type { Partner, RiderRosterRow } from '@/api/deliveries';
|
import type { Partner, RiderRosterRow } from '@/api/deliveries';
|
||||||
import { usePartnerRiders } from '@/queries/hooks';
|
import { usePartnerRiders } from '@/queries/hooks';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
import {
|
||||||
|
Badge,
|
||||||
|
DrawerButton,
|
||||||
|
DrawerCard,
|
||||||
|
LinkAction,
|
||||||
|
Note,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
} from '@/features/store-admin/drawerKit';
|
||||||
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
|
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +64,11 @@ export function PartnerRidersDrawer({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<VStack gap={2}>
|
{/* No `VStack` around this. The drawer body IS a column with the
|
||||||
|
system's own gap; a second stack inside it set a second, different
|
||||||
|
one, which is why this drawer's sections sat closer together than
|
||||||
|
every other drawer's. */}
|
||||||
|
<>
|
||||||
<Note icon={<Bike size={15} />}>
|
<Note icon={<Bike size={15} />}>
|
||||||
These riders deliver for every merchant this partner supplies. A rider hired here does
|
These riders deliver for every merchant this partner supplies. A rider hired here does
|
||||||
not appear in the on-duty fleet until they open the rider app and start a shift — that
|
not appear in the on-duty fleet until they open the rider app and start a shift — that
|
||||||
@@ -67,9 +76,7 @@ export function PartnerRidersDrawer({
|
|||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
{riders.isLoading ? (
|
{riders.isLoading ? (
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Note>Reading riders…</Note>
|
||||||
Reading riders…
|
|
||||||
</Text>
|
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<Section title="No riders yet">
|
<Section title="No riders yet">
|
||||||
<DrawerCard>
|
<DrawerCard>
|
||||||
@@ -88,17 +95,17 @@ export function PartnerRidersDrawer({
|
|||||||
key={rider.userid}
|
key={rider.userid}
|
||||||
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
|
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
|
||||||
value={
|
value={
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
|
||||||
<Badge
|
<Badge
|
||||||
label={rider.isonduty ? 'On duty' : 'Off shift'}
|
label={rider.isonduty ? 'On duty' : 'Off shift'}
|
||||||
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
|
/* The success token, not a hex green typed here. The
|
||||||
/>
|
#0f8a5f this used was a fourth green in a console
|
||||||
<Button
|
that has one. */
|
||||||
label="Edit"
|
colour={
|
||||||
variant="ghost"
|
rider.isonduty ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-4)'
|
||||||
size="sm"
|
}
|
||||||
onClick={() => setEditing(rider)}
|
|
||||||
/>
|
/>
|
||||||
|
<LinkAction label="Edit" onClick={() => setEditing(rider)} />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -106,7 +113,7 @@ export function PartnerRidersDrawer({
|
|||||||
</DrawerCard>
|
</DrawerCard>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fleet-body">
|
<div className="fleet-body">
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Where the fleet is registered
|
Where the fleet is registered
|
||||||
@@ -162,7 +162,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
The day, rider by rider
|
The day, rider by rider
|
||||||
@@ -202,7 +202,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={2}>
|
<VStack gap={1} padding={2}>
|
||||||
<HStack gap={1} align="center">
|
<HStack gap={1} align="center">
|
||||||
<span className="fleet-note-icon">
|
<span className="fleet-note-icon">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function OnboardTenantPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card padding={4} elevation="low">
|
<Card padding={4}>
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack align="center" gap={1.5}>
|
<HStack align="center" gap={1.5}>
|
||||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||||
@@ -153,7 +153,7 @@ export function OnboardTenantPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{created?.tenantid && created?.locationid ? (
|
{created?.tenantid && created?.locationid ? (
|
||||||
<Card padding={3} elevation="low" variant="transparent">
|
<Card padding={3} variant="transparent">
|
||||||
<StoreQrPanel
|
<StoreQrPanel
|
||||||
tenantid={created.tenantid}
|
tenantid={created.tenantid}
|
||||||
locationid={created.locationid}
|
locationid={created.locationid}
|
||||||
@@ -241,7 +241,7 @@ export function OnboardTenantPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 1: Business Details Card */}
|
{/* Section 1: Business Details Card */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -318,7 +318,7 @@ export function OnboardTenantPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 2: Head Office Card */}
|
{/* Section 2: Head Office Card */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
@@ -28,6 +27,8 @@ import { Selector } from '@astryxdesign/core/Selector';
|
|||||||
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
|
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
|
||||||
import { DataState } from '@/components/DataState';
|
import { DataState } from '@/components/DataState';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import {
|
import {
|
||||||
@@ -256,7 +257,11 @@ export function PartnersPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Partners Table Card */}
|
{/* Partners Table Card */}
|
||||||
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
|
<Panel
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="partners"
|
||||||
|
footer={<TablePager paged={paged} label="partners" />}
|
||||||
|
>
|
||||||
<DataState
|
<DataState
|
||||||
isLoading={partners.isLoading}
|
isLoading={partners.isLoading}
|
||||||
error={null}
|
error={null}
|
||||||
@@ -274,9 +279,8 @@ export function PartnersPage() {
|
|||||||
dividers="rows"
|
dividers="rows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="partners" />
|
|
||||||
</DataState>
|
</DataState>
|
||||||
</Card>
|
</Panel>
|
||||||
|
|
||||||
{/* Drawers */}
|
{/* Drawers */}
|
||||||
{ridersFor ? (
|
{ridersFor ? (
|
||||||
@@ -553,14 +557,15 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
District
|
District
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
{/* Full width, not a fixed one: this sits above the 38-row district
|
||||||
|
list it filters, inside a drawer, and a 260px box over a list that
|
||||||
|
fills the panel reads as unrelated to it. */}
|
||||||
|
<SearchInput
|
||||||
label="Search districts"
|
label="Search districts"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={districtSearch}
|
value={districtSearch}
|
||||||
onChange={setDistrictSearch}
|
onChange={setDistrictSearch}
|
||||||
placeholder="Search all 38 districts…"
|
placeholder="Search all 38 districts…"
|
||||||
hasClear
|
width="full"
|
||||||
/>
|
/>
|
||||||
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||||
{shown.map((option) => {
|
{shown.map((option) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
@@ -12,10 +11,10 @@ import { DataState } from '@/components/DataState';
|
|||||||
import { Freshness } from '@/components/Freshness';
|
import { Freshness } from '@/components/Freshness';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
|
||||||
import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
|
import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||||
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
|
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
|
||||||
import type { TenantInfo, TenantLocation } from '@/api/types';
|
import type { TenantInfo, TenantLocation } from '@/api/types';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
@@ -279,12 +278,13 @@ export function StoreDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader
|
<Panel
|
||||||
title="Branches"
|
title="Branches"
|
||||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
count={paged.total}
|
||||||
/>
|
countLabel="branches"
|
||||||
|
actions={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||||
<Card padding={0} elevation="low">
|
footer={<TablePager paged={paged} label="branches" />}
|
||||||
|
>
|
||||||
<DataState
|
<DataState
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
@@ -307,9 +307,8 @@ export function StoreDetailPage() {
|
|||||||
dividers="rows"
|
dividers="rows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="branches" />
|
|
||||||
</DataState>
|
</DataState>
|
||||||
</Card>
|
</Panel>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{isPartnerOpen && tenantid ? (
|
{isPartnerOpen && tenantid ? (
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { Card } from '@astryxdesign/core/Card';
|
|||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { DataState } from '@/components/DataState';
|
import { DataState } from '@/components/DataState';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
||||||
import type { TenantInfo } from '@/api/types';
|
import type { TenantInfo } from '@/api/types';
|
||||||
|
|
||||||
@@ -240,43 +241,66 @@ export function StoresPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<TabBar>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Directory"
|
label="Directory"
|
||||||
isActive={tab === 'directory'}
|
isActive={tab === 'directory'}
|
||||||
onClick={() => setTab('directory')}
|
onClick={() => setTab('directory')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Awaiting approval"
|
label="Awaiting approval"
|
||||||
isActive={tab === 'pending'}
|
isActive={tab === 'pending'}
|
||||||
badge={pending.data?.length || undefined}
|
count={pending.data?.length || undefined}
|
||||||
onClick={() => setTab('pending')}
|
onClick={() => setTab('pending')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
|
|
||||||
{tab === 'pending' ? (
|
{tab === 'pending' ? (
|
||||||
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={1.5}>
|
/* The title, the count and the search are the panel's header band now.
|
||||||
<SectionHeader
|
The search filters THIS table; floating above the card it read as a
|
||||||
|
page-level control that happened to sit nearby, and the count was a
|
||||||
|
note beside a heading rather than the figure you check a filter
|
||||||
|
against. */
|
||||||
|
<Panel
|
||||||
|
footer={
|
||||||
|
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||||
|
<Text type="body" size="sm" color="secondary">
|
||||||
|
Page {page}
|
||||||
|
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||||
|
</Text>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Button
|
||||||
|
label="Previous"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={page === 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Next"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||||
|
onClick={() => setPage((current) => current + 1)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
title="Tenant directory"
|
title="Tenant directory"
|
||||||
note={`${rows.length} shown`}
|
count={rows.length}
|
||||||
action={
|
countLabel="tenants"
|
||||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
actions={
|
||||||
<TextInput
|
<SearchInput
|
||||||
label="Search tenants"
|
label="Search tenants"
|
||||||
isLabelHidden
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder="Search by name, company or city…"
|
placeholder="Name, company or city…"
|
||||||
hasClear
|
width={260}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
|
||||||
<DataState
|
<DataState
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
@@ -306,31 +330,7 @@ export function StoresPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
</Card>
|
</Panel>
|
||||||
|
|
||||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
|
||||||
<Text type="body" size="sm" color="secondary">
|
|
||||||
Page {page}
|
|
||||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
|
||||||
</Text>
|
|
||||||
<HStack gap={1}>
|
|
||||||
<Button
|
|
||||||
label="Previous"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={page === 1}
|
|
||||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Next"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
|
||||||
onClick={() => setPage((current) => current + 1)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
@@ -348,7 +348,7 @@ export function StoresPage() {
|
|||||||
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading the approval queue…
|
Reading the approval queue…
|
||||||
@@ -360,7 +360,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
|||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Nothing waiting
|
Nothing waiting
|
||||||
@@ -379,7 +379,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
|||||||
These merchants exist but are not approved, so they do not appear in the directory.
|
These merchants exist but are not approved, so they do not appear in the directory.
|
||||||
Approving is a database change today — the backend has no endpoint for it.
|
Approving is a database change today — the backend has no endpoint for it.
|
||||||
</Text>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -409,59 +409,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({
|
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||||
label,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
badge?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 18,
|
|
||||||
height: 18,
|
|
||||||
padding: '0 5px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-warning, #b7860b)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Th({ children }: { children?: React.ReactNode }) {
|
function Th({ children }: { children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,20 +2,37 @@
|
|||||||
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
|
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* The console's small control: a branch picker, a rider-partner picker and the
|
||||||
|
Shifts day input all wear this.
|
||||||
|
|
||||||
|
Its own shape before was 32px at a 7px corner — a seventh radius in an app
|
||||||
|
that has one. It takes the search box's measurements now, which matters most
|
||||||
|
on Inventory, where a `.fleet-select` branch picker sits immediately beside a
|
||||||
|
`SearchInput`: the two were a different height AND a different corner, six
|
||||||
|
pixels apart. */
|
||||||
.fleet-select {
|
.fleet-select {
|
||||||
height: 32px;
|
height: 38px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--color-border);
|
border: var(--card-border);
|
||||||
border-radius: 7px;
|
border-radius: var(--card-radius-sm);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-ink-1);
|
color: var(--color-ink-1);
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fleet-select:hover { border-color: var(--color-ink-4); }
|
||||||
|
|
||||||
|
/* The same focus treatment as the search box — a brand border and a 3px tint
|
||||||
|
ring, not the global 2px outline, so two controls sitting side by side do
|
||||||
|
not light up in different ways. */
|
||||||
|
.fleet-select:focus,
|
||||||
.fleet-select:focus-visible {
|
.fleet-select:focus-visible {
|
||||||
outline: 2px solid var(--color-brand);
|
outline: none;
|
||||||
outline-offset: 1px;
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── The recommendation banner ───────────────────────────────────────────── */
|
/* ── The recommendation banner ───────────────────────────────────────────── */
|
||||||
@@ -30,8 +47,8 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-left-width: 3px;
|
border-left-width: 3px;
|
||||||
border-radius: 9px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fleet-banner > svg {
|
.fleet-banner > svg {
|
||||||
|
|||||||
@@ -231,6 +231,23 @@ export function OnboardingPage() {
|
|||||||
persist(completeStep(state, step, nextOf(step)));
|
persist(completeStep(state, step, nextOf(step)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Each step of the wizard starts at the top of itself.
|
||||||
|
|
||||||
|
The steps are STATE, not routes, so the shell's own reset — which keys on the
|
||||||
|
pathname — never fires here: the URL is `/admin/onboarding` for all seven of
|
||||||
|
them. Finishing the inventory step from the bottom of a long list and
|
||||||
|
pressing Save & continue therefore opened the next step already scrolled past
|
||||||
|
its heading and its explanation, which is exactly the part a person setting
|
||||||
|
up a shop for the first time needs to read.
|
||||||
|
|
||||||
|
Instant, for the same reason the shell's is: this replaces the whole page
|
||||||
|
body, so there is nothing continuous for a smooth travel to connect.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
const heading = HEADINGS[step];
|
const heading = HEADINGS[step];
|
||||||
const isFirst = step === 'welcome';
|
const isFirst = step === 'welcome';
|
||||||
const isLast = step === 'done';
|
const isLast = step === 'done';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
|
|||||||
return (
|
return (
|
||||||
<VStack gap={3} className="ob-panel">
|
<VStack gap={3} className="ob-panel">
|
||||||
{productCount > 0 ? (
|
{productCount > 0 ? (
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={0.5}>
|
<VStack gap={0.5}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
You already have {productCount} product{productCount === 1 ? '' : 's'}
|
You already have {productCount} product{productCount === 1 ? '' : 's'}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
|
|||||||
{/* Only when it applies. Nothing below exists for a collection-only shop,
|
{/* Only when it applies. Nothing below exists for a collection-only shop,
|
||||||
and rendering it disabled would be showing work that is not theirs. */}
|
and rendering it disabled would be showing work that is not theirs. */}
|
||||||
{value.offersDelivery === true ? (
|
{value.offersDelivery === true ? (
|
||||||
<Card padding={3} elevation="low" className="ob-panel">
|
<Card padding={3} className="ob-panel">
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<VStack gap={0}>
|
<VStack gap={0}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const TIPS = [
|
|||||||
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
|
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
|
||||||
return (
|
return (
|
||||||
<VStack gap={3} className="ob-panel">
|
<VStack gap={3} className="ob-panel">
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<VStack gap={0.5}>
|
<VStack gap={0.5}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="ob-two">
|
<div className="ob-two">
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
||||||
<VStack gap={0.5}>
|
<VStack gap={0.5}>
|
||||||
@@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Tips
|
Tips
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function Group({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3}>
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<VStack gap={0}>
|
<VStack gap={0}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function WelcomeStep({
|
|||||||
|
|
||||||
<div className="ob-benefits">
|
<div className="ob-benefits">
|
||||||
{benefits.map(({ icon: Icon, title, body }) => (
|
{benefits.map(({ icon: Icon, title, body }) => (
|
||||||
<Card key={title} padding={3} elevation="low">
|
<Card key={title} padding={3}>
|
||||||
<VStack gap={1}>
|
<VStack gap={1}>
|
||||||
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
|
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ export function StoreQrPanel({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 10,
|
padding: 10,
|
||||||
borderRadius: 16,
|
borderRadius: 'var(--card-radius)',
|
||||||
background: '#ffffff',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -164,7 +164,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
|||||||
const rider = entry.live;
|
const rider = entry.live;
|
||||||
const worries = concerns(rider);
|
const worries = concerns(rider);
|
||||||
return (
|
return (
|
||||||
<Card key={rider.userid} padding={0} elevation="low">
|
<Card key={rider.userid} padding={0}>
|
||||||
<VStack gap={1} padding={2}>
|
<VStack gap={1} padding={2}>
|
||||||
<HStack justify="between" align="center" gap={1}>
|
<HStack justify="between" align="center" gap={1}>
|
||||||
<span className="active-name">
|
<span className="active-name">
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { EyeOff } from 'lucide-react';
|
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
DrawerButton,
|
||||||
|
DrawerCard,
|
||||||
|
Field,
|
||||||
|
Metric,
|
||||||
|
Metrics,
|
||||||
|
Note,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
TextField,
|
||||||
|
} from './drawerKit';
|
||||||
import type { CounterLabels } from './counterLabels';
|
import type { CounterLabels } from './counterLabels';
|
||||||
import type { CounterRow } from './counterRows';
|
import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows';
|
||||||
import { money } from './format';
|
import { money } from './format';
|
||||||
import { shortAge } from './posStatus';
|
import { shortAge } from './posStatus';
|
||||||
|
|
||||||
@@ -21,6 +28,19 @@ import { shortAge } from './posStatus';
|
|||||||
* Rename and hide live here rather than on the row. They are rare actions, and
|
* Rename and hide live here rather than on the row. They are rare actions, and
|
||||||
* a control on every row of a fifteen-row table is fifteen chances to hide a
|
* a control on every row of a fifteen-row table is fifteen chances to hide a
|
||||||
* counter by mistake.
|
* counter by mistake.
|
||||||
|
*
|
||||||
|
* ── What this used to get wrong ─────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The table's Status column — Active, Attention, Offline, Never used — was the
|
||||||
|
* one reading this drawer did not repeat, so the chip that made somebody open a
|
||||||
|
* counter vanished the moment they did. It is in the header now, from the same
|
||||||
|
* `state` the row chips from, and the named problem behind an "Attention" is
|
||||||
|
* stated rather than left as a colour.
|
||||||
|
*
|
||||||
|
* The readings were also drawn by a `.reading-list` belonging to the counters
|
||||||
|
* page stylesheet, which is why this drawer had a different row height, label
|
||||||
|
* weight and hairline from every other drawer in the console. It is built from
|
||||||
|
* the kit now, like the rest.
|
||||||
*/
|
*/
|
||||||
export interface CounterDrawerProps {
|
export interface CounterDrawerProps {
|
||||||
row: CounterRow;
|
row: CounterRow;
|
||||||
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
|
|||||||
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
|
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
|
||||||
);
|
);
|
||||||
const status = row.status;
|
const status = row.status;
|
||||||
|
const isNamed = labels.isNamed(row.terminalId);
|
||||||
const readings: [string, string][] = [
|
|
||||||
['Counter code', row.terminalId],
|
|
||||||
['Branch', row.branchName],
|
|
||||||
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
|
|
||||||
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
|
|
||||||
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
|
|
||||||
// The number that explains a revenue figure reading low, so it is stated
|
|
||||||
// even when it is zero rather than hidden as "nothing to report".
|
|
||||||
readings.push(['Waiting to send', String(status.pendingBills)]);
|
|
||||||
if (status.reason) readings.push(['Reported', status.reason]);
|
|
||||||
} else {
|
|
||||||
readings.push(['Status', 'This counter has never reported in.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
|
title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||||
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
|
{...(isNamed ? {} : { isTitleMono: true })}
|
||||||
|
subtitle={isNamed ? row.terminalId : row.branchName}
|
||||||
width={460}
|
width={460}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
/* The same chip the row wears, so the reading that made somebody open
|
||||||
<VStack gap={3}>
|
this counter is still on screen once they have. */
|
||||||
<div className="reading-list">
|
meta={
|
||||||
{readings.map(([label, value]) => (
|
<>
|
||||||
<div key={label} className="reading">
|
<Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
|
||||||
<span>{label}</span>
|
{isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
|
||||||
<strong>{value}</strong>
|
</>
|
||||||
</div>
|
}
|
||||||
))}
|
isFooterSpread
|
||||||
</div>
|
footer={
|
||||||
|
<>
|
||||||
<VStack gap={1.5}>
|
<DrawerButton
|
||||||
<TextInput
|
|
||||||
label="Name this counter"
|
|
||||||
value={draft}
|
|
||||||
onChange={setDraft}
|
|
||||||
placeholder={row.terminalId}
|
|
||||||
description="Shown instead of the code. The code stays on the receipts."
|
|
||||||
/>
|
|
||||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
|
||||||
<Button
|
|
||||||
label="Hide this counter"
|
label="Hide this counter"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
|
||||||
icon={<EyeOff size={14} />}
|
icon={<EyeOff size={14} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
labels.hide(row.terminalId);
|
labels.hide(row.terminalId);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<DrawerButton
|
||||||
label="Save name"
|
label="Save name"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
isDisabled={draft.trim() === '' && !isNamed}
|
||||||
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
labels.rename(row.terminalId, draft.trim());
|
labels.rename(row.terminalId, draft.trim());
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</>
|
||||||
<Text type="body" size="xsm" color="secondary">
|
}
|
||||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till
|
>
|
||||||
that has been retired.
|
{/* ── The period, in the figures the table totals ──────────────────── */}
|
||||||
</Text>
|
<Metrics cols={2}>
|
||||||
</VStack>
|
<Metric
|
||||||
</VStack>
|
label="Sold in this period"
|
||||||
|
value={money(row.periodAmount)}
|
||||||
|
note={`${row.periodBills} bill${row.periodBills === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Today on the till"
|
||||||
|
value={status ? money(status.todayAmount) : '—'}
|
||||||
|
note={status ? `${status.todayBills} bill${status.todayBills === 1 ? '' : 's'}` : 'never reported'}
|
||||||
|
isSmall
|
||||||
|
/>
|
||||||
|
</Metrics>
|
||||||
|
|
||||||
|
{/* The named problem behind an "Attention" or an "Offline". The board
|
||||||
|
already worked out what is wrong and how long it has been wrong; the
|
||||||
|
row could only show the colour. */}
|
||||||
|
{row.card ? (
|
||||||
|
<Note icon={<AlertTriangle size={15} />}>
|
||||||
|
<strong>{row.card.problem.headline}</strong> {row.card.problem.detail}
|
||||||
|
{row.card.problem.action ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<strong>{row.card.problem.action}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Note>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Section title="Readings">
|
||||||
|
<DrawerCard>
|
||||||
|
<Row label="Counter code" value={row.terminalId} icon={<Hash size={14} />} />
|
||||||
|
<Row label="Branch" value={row.branchName} icon={<MapPin size={14} />} />
|
||||||
|
{status ? (
|
||||||
|
<>
|
||||||
|
<Row
|
||||||
|
label="Last sale"
|
||||||
|
value={
|
||||||
|
status.lastBillAt
|
||||||
|
? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago`
|
||||||
|
: 'never'
|
||||||
|
}
|
||||||
|
icon={<Receipt size={14} />}
|
||||||
|
{...(status.lastBillAt ? {} : { tone: 'muted' as const })}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Last heard from"
|
||||||
|
value={status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`}
|
||||||
|
icon={<Monitor size={14} />}
|
||||||
|
{...(status.silentForMs == null ? { tone: 'muted' as const } : {})}
|
||||||
|
/>
|
||||||
|
{/* Stated even at zero. It is the number that explains a revenue
|
||||||
|
figure reading low, and hiding it as "nothing to report" is
|
||||||
|
what leaves somebody hunting for the missing money. */}
|
||||||
|
<Row
|
||||||
|
label="Waiting to send"
|
||||||
|
value={String(status.pendingBills)}
|
||||||
|
{...(status.pendingBills > 0 ? {} : { tone: 'muted' as const })}
|
||||||
|
/>
|
||||||
|
{status.reason ? <Row label="Reported" value={status.reason} isStacked /> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Row label="Status" value="This counter has never reported in." isStacked />
|
||||||
|
)}
|
||||||
|
</DrawerCard>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Name and visibility">
|
||||||
|
<Field
|
||||||
|
label="Name this counter"
|
||||||
|
description="Shown instead of the code. The code stays on the receipts."
|
||||||
|
>
|
||||||
|
<TextField value={draft} onChange={setDraft} placeholder={row.terminalId} />
|
||||||
|
</Field>
|
||||||
|
<Note>
|
||||||
|
Hiding removes a counter from this page. It keeps recording sales — use it for a till that
|
||||||
|
has been retired.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
||||||
import type { DateRange } from '@/api/insights';
|
import type { DateRange } from '@/api/insights';
|
||||||
|
import './dateRangePicker.css';
|
||||||
|
|
||||||
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: 180 }}>
|
<div className="datefilter" style={{ width: 180 }}>
|
||||||
<DateRangeInput
|
<DateRangeInput
|
||||||
width={180}
|
width={180}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ import './drawer.css';
|
|||||||
* else. Content comes from `drawerKit`. A drawer that styles its own card or
|
* else. Content comes from `drawerKit`. A drawer that styles its own card or
|
||||||
* badge is the beginning of the drift this component exists to end.
|
* badge is the beginning of the drift this component exists to end.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* How many drawers are open right now.
|
||||||
|
*
|
||||||
|
* Module scope rather than component state, because the count has to be shared
|
||||||
|
* between separate instances that know nothing about each other — see the
|
||||||
|
* scroll lock in the component below.
|
||||||
|
*/
|
||||||
|
let openDrawers = 0;
|
||||||
|
|
||||||
export interface DrawerProps {
|
export interface DrawerProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** One line under the title — a brand, an SKU, a store name. */
|
/** One line under the title — a brand, an SKU, a store name. */
|
||||||
@@ -78,6 +87,31 @@ export function Drawer({
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The page behind stops scrolling while this is open.
|
||||||
|
|
||||||
|
`overscroll-behavior: contain` on the body already stops a scroll that
|
||||||
|
reaches the end of the drawer from continuing into the page underneath. What
|
||||||
|
it does not stop is a wheel over the SCRIM, which is most of the screen — so
|
||||||
|
the list behind would scroll away under a drawer that stayed put, and closing
|
||||||
|
it left the operator somewhere they had not chosen to be.
|
||||||
|
|
||||||
|
No padding is added to compensate for the vanished scrollbar: `html` carries
|
||||||
|
`scrollbar-gutter: stable`, so the gutter is reserved whether or not a bar is
|
||||||
|
drawn there and the page does not jump sideways as this opens.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
/* Counted, not a boolean. Drawers stack — the partner's rider list opens a
|
||||||
|
rider drawer on top of itself — and closing the inner one must not
|
||||||
|
unlock the page while the outer one is still covering it. */
|
||||||
|
openDrawers += 1;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
openDrawers -= 1;
|
||||||
|
if (openDrawers === 0) document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
|
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
|
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
|
||||||
@@ -105,7 +139,10 @@ export function Drawer({
|
|||||||
|
|
||||||
{/* Beside the title, not beneath it. Stacked under the subtitle it was
|
{/* Beside the title, not beneath it. Stacked under the subtitle it was
|
||||||
a third line, and a third line is what made this header twice the
|
a third line, and a third line is what made this header twice the
|
||||||
height of the nav bar it now matches. */}
|
height of the nav bar it now matches.
|
||||||
|
|
||||||
|
The badge here is the same badge the table row wears, in the same
|
||||||
|
colour — see the note on `Badge` in `drawerKit`. */}
|
||||||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||||||
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Copy, MapPin } from 'lucide-react';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { AlertTriangle, Check, Copy } from 'lucide-react';
|
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Choice,
|
||||||
|
ChoiceGroup,
|
||||||
|
DrawerButton,
|
||||||
|
DrawerCard,
|
||||||
|
Field,
|
||||||
|
FieldRow,
|
||||||
|
Mono,
|
||||||
|
Note,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
SelectField,
|
||||||
|
TextField,
|
||||||
|
} from './drawerKit';
|
||||||
import { branchLabel } from './format';
|
import { branchLabel } from './format';
|
||||||
|
|
||||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||||
@@ -21,6 +30,16 @@ import { branchLabel } from './format';
|
|||||||
*
|
*
|
||||||
* Right-hand rather than centred, and Escape closes it — the same contract as
|
* Right-hand rather than centred, and Escape closes it — the same contract as
|
||||||
* the order drawer, so the two do not behave differently for no reason.
|
* the order drawer, so the two do not behave differently for no reason.
|
||||||
|
*
|
||||||
|
* ── Why this file stopped styling itself ────────────────────────────────────
|
||||||
|
*
|
||||||
|
* It used to build its own error box, its own Active/Inactive pair and its own
|
||||||
|
* PIN panel out of `HStack`s and inline styles, and pick Astryx's `TextInput`
|
||||||
|
* and `Selector` for the fields. None of those matched the drawers on either
|
||||||
|
* side of them: a different control height, a different label weight, a
|
||||||
|
* different radius, a third spelling of an error. The kit now carries a field,
|
||||||
|
* an input, a picker, a choice and an alert, so there is nothing left here to
|
||||||
|
* drift.
|
||||||
*/
|
*/
|
||||||
function Sheet({
|
function Sheet({
|
||||||
title,
|
title,
|
||||||
@@ -38,7 +57,7 @@ function Sheet({
|
|||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={title}
|
title={title}
|
||||||
width={440}
|
width={460}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
isFooterSpread
|
isFooterSpread
|
||||||
@@ -49,28 +68,6 @@ function Sheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Problem({ message }: { message: string }) {
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
gap={1}
|
|
||||||
align="start"
|
|
||||||
style={{
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle
|
|
||||||
size={14}
|
|
||||||
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
|
|
||||||
/>
|
|
||||||
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Back-office person ──────────────────────────────────────────────────── */
|
/* ── Back-office person ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,22 +78,16 @@ function Problem({ message }: { message: string }) {
|
|||||||
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
||||||
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
||||||
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* The back-office roles, and what each one can actually reach.
|
|
||||||
*
|
*
|
||||||
* The labels used to be bare — Administrator, Manager, Staff — and the
|
* ── The labels name the consequence, not the seniority ──────────────────────
|
||||||
* difference between them is not a matter of seniority, it is which workspace
|
|
||||||
* the account lands in. `resolveRole` sends roleids 1 and 3 to the merchant
|
|
||||||
* console; everything else becomes a branch user, pinned to one shop.
|
|
||||||
*
|
*
|
||||||
* Somebody choosing "Staff" for a person they meant to be a second
|
* The difference between these is not rank, it is which workspace the account
|
||||||
* administrator gets an account that signs in to the wrong workspace, and
|
* lands in. `resolveRole` sends roleids 1 and 3 to the merchant console;
|
||||||
* nothing on the way there says so. Naming the consequence in the option is the
|
* everything else becomes a branch user, pinned to one shop. Somebody choosing
|
||||||
* cheapest possible fix.
|
* "Staff" for a person they meant to be a second administrator gets an account
|
||||||
*/
|
* that signs in to the wrong workspace, and nothing on the way there says so.
|
||||||
/*
|
*
|
||||||
* Administrator (roleid 3) is NOT offered here, and its absence is the point.
|
* ── Administrator (roleid 3) is NOT offered, and its absence is the point ───
|
||||||
*
|
*
|
||||||
* An administrator is provisioned by Nearle Admin when the business is created
|
* An administrator is provisioned by Nearle Admin when the business is created
|
||||||
* — `createtenantuser` writes that account and forces the role — so offering it
|
* — `createtenantuser` writes that account and forces the role — so offering it
|
||||||
@@ -106,10 +97,16 @@ function Problem({ message }: { message: string }) {
|
|||||||
* Editing an existing administrator is unaffected: `roleOptions` below appends
|
* Editing an existing administrator is unaffected: `roleOptions` below appends
|
||||||
* whatever role the person already holds when this list does not know it, which
|
* whatever role the person already holds when this list does not know it, which
|
||||||
* exists precisely so editing a phone number cannot silently reassign someone.
|
* exists precisely so editing a phone number cannot silently reassign someone.
|
||||||
* Removing the option from CREATION therefore costs nothing on the edit path.
|
|
||||||
*/
|
*/
|
||||||
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
|
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
|
||||||
|
|
||||||
|
/* The default for a NEW person. Read from the list rather than typed twice —
|
||||||
|
this was `STAFF_ROLES[1]`, an index that has not existed since the list was
|
||||||
|
cut to one entry, and it only kept working because the `?? 4` behind it
|
||||||
|
happens to be the same role. Adding a second entry would have silently made
|
||||||
|
it the default. */
|
||||||
|
const DEFAULT_STAFF_ROLE = STAFF_ROLES[0]?.id ?? 4;
|
||||||
|
|
||||||
export function PersonDrawer({
|
export function PersonDrawer({
|
||||||
row,
|
row,
|
||||||
tenantid,
|
tenantid,
|
||||||
@@ -128,7 +125,7 @@ export function PersonDrawer({
|
|||||||
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
||||||
const [email, setEmail] = useState(row?.email ?? '');
|
const [email, setEmail] = useState(row?.email ?? '');
|
||||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
|
const [roleid, setRoleid] = useState(String(row?.roleid ?? DEFAULT_STAFF_ROLE));
|
||||||
/**
|
/**
|
||||||
* No branch by default, rather than the first one.
|
* No branch by default, rather than the first one.
|
||||||
*
|
*
|
||||||
@@ -179,8 +176,8 @@ export function PersonDrawer({
|
|||||||
onError: (error) => setProblem(errorMessage(error)),
|
onError: (error) => setProblem(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
function submit(event?: FormEvent) {
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
setProblem(null);
|
setProblem(null);
|
||||||
if (!firstname.trim()) return setProblem('A first name is required.');
|
if (!firstname.trim()) return setProblem('A first name is required.');
|
||||||
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
||||||
@@ -203,46 +200,54 @@ export function PersonDrawer({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||||
<Button
|
<DrawerButton
|
||||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isDisabled={save.isPending}
|
isDisabled={save.isPending}
|
||||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
onClick={() => submit()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={submit}>
|
{problem ? <Alert>{problem}</Alert> : null}
|
||||||
<VStack gap={2}>
|
|
||||||
{problem ? <Problem message={problem} /> : null}
|
|
||||||
|
|
||||||
<div className="form-grid">
|
<Section title="Who they are">
|
||||||
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
|
<FieldRow>
|
||||||
<TextInput label="Last name" value={lastname} onChange={setLastname} />
|
<Field label="First name" isRequired>
|
||||||
</div>
|
<TextField value={firstname} onChange={setFirstname} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Last name">
|
||||||
|
<TextField value={lastname} onChange={setLastname} />
|
||||||
|
</Field>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
<TextInput
|
<Field label="Email" isRequired description="What they sign in with.">
|
||||||
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
<TextField type="email" value={email} onChange={setEmail} />
|
||||||
value={email}
|
</Field>
|
||||||
onChange={setEmail}
|
|
||||||
description="What they sign in with."
|
|
||||||
/>
|
|
||||||
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
|
|
||||||
|
|
||||||
<div className="form-grid">
|
<Field label="Mobile">
|
||||||
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
|
<TextField type="tel" value={contactno} onChange={setContactno} />
|
||||||
<Selector
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Where they work">
|
||||||
|
<Field label="Role">
|
||||||
|
<SelectField value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
label="Branch"
|
label="Branch"
|
||||||
|
description="Leave this if their shop does not exist yet — place them when it opens."
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
value={locationid}
|
value={locationid}
|
||||||
onChange={setLocationid}
|
onChange={setLocationid}
|
||||||
placeholder="Not at a shop yet"
|
|
||||||
description="Leave this if their shop does not exist yet — place them when it opens."
|
|
||||||
options={[
|
options={[
|
||||||
/* An offered choice, not an absence. Somebody hired before
|
/* An offered choice, not an absence. Somebody hired before their
|
||||||
their outlet opens sits here, signs in, and is met by the
|
outlet opens sits here, signs in, and is met by the "No store
|
||||||
"No store assigned" screen until a branch is ready for
|
assigned" screen until a branch is ready for them — which is
|
||||||
them — which is what makes hiring before opening possible. */
|
what makes hiring before opening possible. */
|
||||||
{ value: '0', label: 'Not at a shop yet' },
|
{ value: '0', label: 'Not at a shop yet' },
|
||||||
...branches.map((branch) => ({
|
...branches.map((branch) => ({
|
||||||
value: String(branch.locationid),
|
value: String(branch.locationid),
|
||||||
@@ -250,21 +255,30 @@ export function PersonDrawer({
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<ActiveToggle
|
<Section title="Access">
|
||||||
isActive={isActive}
|
<ChoiceGroup>
|
||||||
onChange={setIsActive}
|
<Choice
|
||||||
activeNote="Can sign in"
|
label="Active"
|
||||||
inactiveNote="Cannot sign in. History is kept."
|
note="Can sign in"
|
||||||
|
isSelected={isActive}
|
||||||
|
onSelect={() => setIsActive(true)}
|
||||||
/>
|
/>
|
||||||
|
<Choice
|
||||||
|
label="Inactive"
|
||||||
|
note="Cannot sign in. History is kept."
|
||||||
|
isSelected={!isActive}
|
||||||
|
onSelect={() => setIsActive(false)}
|
||||||
|
/>
|
||||||
|
</ChoiceGroup>
|
||||||
|
|
||||||
<Text type="body" size="xsm" color="secondary">
|
<Note>
|
||||||
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
||||||
backend job until that changes.
|
backend job until that changes.
|
||||||
</Text>
|
</Note>
|
||||||
</VStack>
|
</Section>
|
||||||
</form>
|
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,6 +289,7 @@ export function TillDrawer({
|
|||||||
row,
|
row,
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
locationid,
|
||||||
|
branchName,
|
||||||
roles,
|
roles,
|
||||||
shifts,
|
shifts,
|
||||||
cashierOnly = false,
|
cashierOnly = false,
|
||||||
@@ -283,6 +298,15 @@ export function TillDrawer({
|
|||||||
row: PosUser | null;
|
row: PosUser | null;
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
|
/**
|
||||||
|
* The outlet this till account belongs to.
|
||||||
|
*
|
||||||
|
* Shown, not asked: a till account is created against the branch whose row
|
||||||
|
* was clicked and cannot be moved from here. It is stated because the table
|
||||||
|
* carries a Branch column, and a drawer that silently drops a column the row
|
||||||
|
* shows leaves the reader unsure which shop they are editing.
|
||||||
|
*/
|
||||||
|
branchName?: string | undefined;
|
||||||
roles: PosRole[];
|
roles: PosRole[];
|
||||||
shifts: StaffShift[];
|
shifts: StaffShift[];
|
||||||
/**
|
/**
|
||||||
@@ -346,8 +370,8 @@ export function TillDrawer({
|
|||||||
onError: (error) => setProblem(errorMessage(error)),
|
onError: (error) => setProblem(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
function submit(event?: FormEvent) {
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
setProblem(null);
|
setProblem(null);
|
||||||
if (!fullName.trim()) return setProblem('A name is required.');
|
if (!fullName.trim()) return setProblem('A name is required.');
|
||||||
const mobile = normaliseMobile(contactno);
|
const mobile = normaliseMobile(contactno);
|
||||||
@@ -366,42 +390,30 @@ export function TillDrawer({
|
|||||||
title="Account created"
|
title="Account created"
|
||||||
subtitle={fullName}
|
subtitle={fullName}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={<Button label="Done" variant="primary" onClick={onClose} />}
|
footer={<DrawerButton label="Done" variant="primary" onClick={onClose} />}
|
||||||
>
|
>
|
||||||
<VStack gap={2}>
|
<Note tone="brand">
|
||||||
<Text type="body" size="sm">
|
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it is
|
||||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
|
lost, reissue rather than look it up.
|
||||||
is lost, reissue rather than look it up.
|
</Note>
|
||||||
</Text>
|
|
||||||
<HStack
|
<DrawerCard tone="subtle">
|
||||||
justify="between"
|
<Row
|
||||||
align="center"
|
label="PIN"
|
||||||
style={{
|
value={
|
||||||
padding: '14px 16px',
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
|
||||||
borderRadius: 12,
|
<Mono isLarge>{issued}</Mono>
|
||||||
border: '1px solid var(--color-line)',
|
<DrawerButton
|
||||||
background: 'var(--color-surface-subtle)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '0.18em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{issued}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
label="Copy"
|
label="Copy"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
|
||||||
icon={<Copy size={13} />}
|
icon={<Copy size={13} />}
|
||||||
onClick={() => void navigator.clipboard?.writeText(issued)}
|
onClick={() => void navigator.clipboard?.writeText(issued)}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</span>
|
||||||
</VStack>
|
}
|
||||||
|
isStacked
|
||||||
|
/>
|
||||||
|
</DrawerCard>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -427,70 +439,73 @@ export function TillDrawer({
|
|||||||
: 'Add a terminal account'
|
: 'Add a terminal account'
|
||||||
: (row.full_name ?? 'Edit terminal account')
|
: (row.full_name ?? 'Edit terminal account')
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle={isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`}
|
||||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||||
<Button
|
<DrawerButton
|
||||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isDisabled={save.isPending}
|
isDisabled={save.isPending}
|
||||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
onClick={() => submit()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={submit}>
|
{problem ? <Alert>{problem}</Alert> : null}
|
||||||
<VStack gap={2}>
|
|
||||||
{problem ? <Problem message={problem} /> : null}
|
|
||||||
|
|
||||||
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
|
{branchName ? (
|
||||||
|
<DrawerCard>
|
||||||
|
<Row label="Branch" value={branchLabel(branchName)} icon={<MapPin size={14} />} />
|
||||||
|
</DrawerCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* The role is shown either way — a form that silently decides a
|
<Section title="Who they are">
|
||||||
field leaves the reader wondering what was decided. A shop just
|
<Field label="Full name" isRequired>
|
||||||
cannot change it: the merchant issues supervisors, and the backend
|
<TextField value={fullName} onChange={setFullName} />
|
||||||
only lets roleid 7 manage till users at all. */}
|
</Field>
|
||||||
|
|
||||||
|
{/* The role is shown either way — a form that silently decides a field
|
||||||
|
leaves the reader wondering what was decided. A shop just cannot
|
||||||
|
change it: the merchant issues supervisors, and the backend only
|
||||||
|
lets roleid 7 manage till users at all. */}
|
||||||
{cashierOnly ? (
|
{cashierOnly ? (
|
||||||
<Selector
|
<Field label="Role" description="Supervisors are issued by your store administrator.">
|
||||||
label="Role"
|
<SelectField
|
||||||
value="cashier"
|
value="cashier"
|
||||||
onChange={() => undefined}
|
onChange={() => undefined}
|
||||||
options={[{ value: 'cashier', label: 'Cashier' }]}
|
options={[{ value: 'cashier', label: 'Cashier' }]}
|
||||||
isDisabled
|
isDisabled
|
||||||
description="Supervisors are issued by your store administrator."
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Field label="Role" {...(chosenRole?.description ? { description: chosenRole.description } : {})}>
|
||||||
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
|
<SelectField value={role} onChange={setRole} options={roleOptions} />
|
||||||
{chosenRole?.description ? (
|
</Field>
|
||||||
<Text type="body" size="xsm" color="secondary">
|
|
||||||
{chosenRole.description}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<Field
|
||||||
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
label="Mobile"
|
||||||
value={contactno}
|
isRequired
|
||||||
onChange={setContactno}
|
|
||||||
description="Ten digits. This is what they sign in with at the till."
|
description="Ten digits. This is what they sign in with at the till."
|
||||||
/>
|
>
|
||||||
|
<TextField type="tel" value={contactno} onChange={setContactno} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<TextInput
|
<Field
|
||||||
label="PIN"
|
label="PIN"
|
||||||
value={pin}
|
|
||||||
onChange={setPin}
|
|
||||||
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
||||||
/>
|
>
|
||||||
|
<TextField value={pin} onChange={setPin} />
|
||||||
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Selector
|
<Section title="When they work">
|
||||||
label="Shift"
|
<Field label="Shift" description="Informational. Nothing refuses a bill rung outside it.">
|
||||||
|
<SelectField
|
||||||
value={shiftId}
|
value={shiftId}
|
||||||
onChange={setShiftId}
|
onChange={setShiftId}
|
||||||
options={[
|
options={[
|
||||||
@@ -500,112 +515,26 @@ export function TillDrawer({
|
|||||||
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
description="Informational. Nothing refuses a bill rung outside it."
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<ActiveToggle
|
<Section title="Access">
|
||||||
isActive={isActive}
|
<ChoiceGroup>
|
||||||
onChange={setIsActive}
|
|
||||||
activeNote="Can open a till"
|
|
||||||
inactiveNote="Cannot sign in at a till. Their bills are kept."
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</form>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Shared ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Active / inactive, as a pair of buttons rather than a checkbox.
|
|
||||||
*
|
|
||||||
* Deactivating is the closest thing this console has to a delete, so it says
|
|
||||||
* what it does in words on the control itself instead of leaving the
|
|
||||||
* consequence to a checkbox label the eye skips.
|
|
||||||
*/
|
|
||||||
function ActiveToggle({
|
|
||||||
isActive,
|
|
||||||
onChange,
|
|
||||||
activeNote,
|
|
||||||
inactiveNote,
|
|
||||||
}: {
|
|
||||||
isActive: boolean;
|
|
||||||
onChange: (next: boolean) => void;
|
|
||||||
activeNote: string;
|
|
||||||
inactiveNote: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<VStack gap={1}>
|
|
||||||
<Text
|
|
||||||
type="label"
|
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
|
||||||
>
|
|
||||||
Access
|
|
||||||
</Text>
|
|
||||||
<HStack gap={1}>
|
|
||||||
<Choice
|
<Choice
|
||||||
label="Active"
|
label="Active"
|
||||||
note={activeNote}
|
note="Can open a till"
|
||||||
isSelected={isActive}
|
isSelected={isActive}
|
||||||
onSelect={() => onChange(true)}
|
onSelect={() => setIsActive(true)}
|
||||||
/>
|
/>
|
||||||
<Choice
|
<Choice
|
||||||
label="Inactive"
|
label="Inactive"
|
||||||
note={inactiveNote}
|
note="Cannot sign in at a till. Their bills are kept."
|
||||||
isSelected={!isActive}
|
isSelected={!isActive}
|
||||||
onSelect={() => onChange(false)}
|
onSelect={() => setIsActive(false)}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</ChoiceGroup>
|
||||||
</VStack>
|
</Section>
|
||||||
);
|
</Sheet>
|
||||||
}
|
|
||||||
|
|
||||||
function Choice({
|
|
||||||
label,
|
|
||||||
note,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
note: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
textAlign: 'left',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
|
||||||
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
|
||||||
transition: 'border-color .15s, background .15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 5,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected ? <Check size={12} /> : null}
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
|
||||||
{note}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
|
|||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import type { DeliveryRow } from '@/api/types';
|
import type { DeliveryRow } from '@/api/types';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
|
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
|
||||||
@@ -63,7 +64,7 @@ export function PlanVsActualPanel({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading the day…
|
Reading the day…
|
||||||
@@ -75,7 +76,7 @@ export function PlanVsActualPanel({
|
|||||||
|
|
||||||
if (finished.length === 0) {
|
if (finished.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Nothing finished in this range
|
Nothing finished in this range
|
||||||
@@ -94,7 +95,7 @@ export function PlanVsActualPanel({
|
|||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
{/* ── The promise ────────────────────────────────────────────────── */}
|
{/* ── The promise ────────────────────────────────────────────────── */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Against the promise
|
Against the promise
|
||||||
@@ -134,7 +135,7 @@ export function PlanVsActualPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Where the time goes ────────────────────────────────────────── */}
|
{/* ── Where the time goes ────────────────────────────────────────── */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -191,7 +192,7 @@ export function PlanVsActualPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Distance ───────────────────────────────────────────────────── */}
|
{/* ── Distance ───────────────────────────────────────────────────── */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={2}>
|
<VStack gap={1} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Distance
|
Distance
|
||||||
@@ -222,7 +223,12 @@ export function PlanVsActualPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── The deliveries themselves ──────────────────────────────────── */}
|
{/* ── The deliveries themselves ──────────────────────────────────── */}
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Planned vs actual"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="deliveries"
|
||||||
|
footer={<TablePager paged={paged} label="deliveries" />}
|
||||||
|
>
|
||||||
<VStack gap={1} padding={2} align="stretch">
|
<VStack gap={1} padding={2} align="stretch">
|
||||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -298,8 +304,7 @@ export function PlanVsActualPanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="deliveries" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import type { CatalogueProduct, Product } from '@/api/types';
|
import type { CatalogueProduct, Product, StockRequest } from '@/api/types';
|
||||||
|
/* A pure reading of a `StockRequest`, not a Store user component — the same
|
||||||
|
three functions the Store user's table chips from, so the row and this
|
||||||
|
drawer cannot label one request two ways. */
|
||||||
|
import { REQUEST_COLOR, REQUEST_LABEL, requestState } from '@/features/store-user/requestState';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
||||||
|
import { aisleLabelOf, UNCATEGORISED_AISLE } from './appAisle';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import {
|
import {
|
||||||
@@ -57,9 +62,23 @@ import {
|
|||||||
export function ProductDrawer({
|
export function ProductDrawer({
|
||||||
product,
|
product,
|
||||||
canManage = true,
|
canManage = true,
|
||||||
|
request,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
product: Product;
|
product: Product;
|
||||||
|
/**
|
||||||
|
* The branch's latest stock request for this product, where there is one.
|
||||||
|
*
|
||||||
|
* Passed in rather than fetched: the Store user's catalogue already holds
|
||||||
|
* every request for the page — `latestByProduct` — and a second read here
|
||||||
|
* would be a request per drawer open for data already in hand. It is absent
|
||||||
|
* in the merchant's console, which has no "ask for stock" at all.
|
||||||
|
*
|
||||||
|
* It is here because the row carries a Stock request column, and a drawer
|
||||||
|
* that drops a column the row shows makes the reader go back to the list to
|
||||||
|
* find out what they were just looking at.
|
||||||
|
*/
|
||||||
|
request?: StockRequest | undefined;
|
||||||
/**
|
/**
|
||||||
* False in the Store user workspace.
|
* False in the Store user workspace.
|
||||||
*
|
*
|
||||||
@@ -77,11 +96,20 @@ export function ProductDrawer({
|
|||||||
const [shot, setShot] = useState(0);
|
const [shot, setShot] = useState(0);
|
||||||
|
|
||||||
/* The aisle name for this product's subcategoryid, from the same list the
|
/* The aisle name for this product's subcategoryid, from the same list the
|
||||||
customer app groups by. */
|
customer app groups by — and through the same helper the products table
|
||||||
|
prints with, because the two used to disagree: the row fell back to the
|
||||||
|
product's own `categoryname` when the platform list had no entry, and this
|
||||||
|
drawer did not, so one product read "Dairy" in the list and
|
||||||
|
"Uncategorized" in the sheet opened from it. */
|
||||||
const aisles = useAppAisles(tenantid || undefined);
|
const aisles = useAppAisles(tenantid || undefined);
|
||||||
const aisleName =
|
const aisleNames = useMemo(() => {
|
||||||
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
|
const out = new Map<number, string>();
|
||||||
null;
|
for (const row of aisles.data ?? []) {
|
||||||
|
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [aisles.data]);
|
||||||
|
const aisle = aisleLabelOf(product, aisleNames);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
What the catalogue said, preferred from the product's own copy.
|
What the catalogue said, preferred from the product's own copy.
|
||||||
@@ -115,6 +143,18 @@ export function ProductDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* The row's chip, rebuilt from the same reading of the request the table
|
||||||
|
uses. Quantity is stated because "Asked for" without a number is the half
|
||||||
|
of the fact nobody needs. */
|
||||||
|
const requestStateValue = requestState(request);
|
||||||
|
const requestChip = requestStateValue
|
||||||
|
? {
|
||||||
|
label: REQUEST_LABEL[requestStateValue],
|
||||||
|
colour: REQUEST_COLOR[requestStateValue],
|
||||||
|
detail: `${count(request?.qty ?? 0)} ${[product.unitvalue, product.productunit].filter(Boolean).join(' ') || 'units'}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const price = effectivePrice(product);
|
const price = effectivePrice(product);
|
||||||
const cost = product.productcost ?? 0;
|
const cost = product.productcost ?? 0;
|
||||||
/* Margin is derived, not stored. Shown only when both halves are real — a
|
/* Margin is derived, not stored. Shown only when both halves are real — a
|
||||||
@@ -238,15 +278,32 @@ export function ProductDrawer({
|
|||||||
cosmetic default here: it is the heading the app will print. */}
|
cosmetic default here: it is the heading the app will print. */}
|
||||||
<Row
|
<Row
|
||||||
label="Shown in the app under"
|
label="Shown in the app under"
|
||||||
value={aisleName ?? 'Uncategorized'}
|
value={aisle}
|
||||||
icon={<Tag size={14} />}
|
icon={<Tag size={14} />}
|
||||||
{...(aisleName ? {} : { tone: 'muted' as const })}
|
{...(aisle === UNCATEGORISED_AISLE ? { tone: 'muted' as const } : {})}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Unit"
|
label="Unit"
|
||||||
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
||||||
icon={<Ruler size={14} />}
|
icon={<Ruler size={14} />}
|
||||||
/>
|
/>
|
||||||
|
{/* The row's own Stock request column, repeated here rather than
|
||||||
|
dropped. Same chip, same wording, same quantity — it is read from
|
||||||
|
the same `requestState` the table reads. */}
|
||||||
|
{requestChip ? (
|
||||||
|
<Row
|
||||||
|
label="Stock request"
|
||||||
|
value={
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
|
||||||
|
>
|
||||||
|
<Badge label={requestChip.label} colour={requestChip.colour} />
|
||||||
|
{requestChip.detail}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
icon={<ClipboardList size={14} />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</DrawerCard>
|
</DrawerCard>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { ImageOff } from 'lucide-react';
|
import { ImageOff } from 'lucide-react';
|
||||||
import type { Product } from '@/api/types';
|
import type { Product } from '@/api/types';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { Panel } from '@/components/Panel';
|
||||||
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
||||||
|
import { aisleLabelOf } from './appAisle';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { count, money } from './format';
|
import { count, money } from './format';
|
||||||
import { ProductDrawer } from './ProductDrawer';
|
import { ProductDrawer } from './ProductDrawer';
|
||||||
@@ -133,11 +133,11 @@ export function ProductsPanel({
|
|||||||
[aisles.data],
|
[aisles.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
/* The panel's own header owns the category filter now, rather than a
|
||||||
<VStack gap={1.5}>
|
`SectionHeader` floating above the card. It filters THIS table; sitting
|
||||||
<SectionHeader
|
outside the panel it read as a page-level control that happened to be
|
||||||
title="Products"
|
nearby. */
|
||||||
action={
|
const filter = (
|
||||||
<div style={{ width: 190 }}>
|
<div style={{ width: 190 }}>
|
||||||
<Selector
|
<Selector
|
||||||
label="Category"
|
label="Category"
|
||||||
@@ -149,9 +149,10 @@ export function ProductsPanel({
|
|||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
);
|
||||||
/>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={1.5}>
|
||||||
{selection.count > 0 ? (
|
{selection.count > 0 ? (
|
||||||
<div className="bulkbar" data-active="yes">
|
<div className="bulkbar" data-active="yes">
|
||||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||||
@@ -180,6 +181,7 @@ export function ProductsPanel({
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
queryKey={category}
|
queryKey={category}
|
||||||
isLoading={products.isLoading}
|
isLoading={products.isLoading}
|
||||||
|
actions={filter}
|
||||||
onOpen={setOpen}
|
onOpen={setOpen}
|
||||||
onPrice={(product) => setPublishing([product])}
|
onPrice={(product) => setPublishing([product])}
|
||||||
/>
|
/>
|
||||||
@@ -222,6 +224,7 @@ function ProductTable({
|
|||||||
aisleNames,
|
aisleNames,
|
||||||
selection,
|
selection,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
actions,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPrice,
|
onPrice,
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -232,6 +235,8 @@ function ProductTable({
|
|||||||
/** The tick state, owned by the panel so its bar can act on the choice. */
|
/** The tick state, owned by the panel so its bar can act on the choice. */
|
||||||
selection: ReturnType<typeof useSelection>;
|
selection: ReturnType<typeof useSelection>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** The category filter, rendered in the panel header beside the count. */
|
||||||
|
actions: ReactNode;
|
||||||
onOpen: (product: Product) => void;
|
onOpen: (product: Product) => void;
|
||||||
onPrice: (product: Product) => void;
|
onPrice: (product: Product) => void;
|
||||||
/** The category filter; changing it returns the pager to page 1. */
|
/** The category filter; changing it returns the pager to page 1. */
|
||||||
@@ -242,20 +247,18 @@ function ProductTable({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel title="Products" actions={actions} padding="md">
|
||||||
<VStack gap={0.5} padding={3}>
|
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Reading your catalogue…
|
Reading your catalogue…
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel title="Products" count={0} countLabel="products" actions={actions} padding="md">
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Nothing here yet
|
Nothing here yet
|
||||||
</Text>
|
</Text>
|
||||||
@@ -265,12 +268,18 @@ function ProductTable({
|
|||||||
were expecting rows.
|
were expecting rows.
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Products"
|
||||||
|
count={rows.length}
|
||||||
|
countLabel="products"
|
||||||
|
actions={actions}
|
||||||
|
footer={<TablePager paged={paged} label="products" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
{/* `table-layout: fixed` with an explicit colgroup. Without it the
|
{/* `table-layout: fixed` with an explicit colgroup. Without it the
|
||||||
browser sizes every column to its content, so Category and Status
|
browser sizes every column to its content, so Category and Status
|
||||||
@@ -342,8 +351,7 @@ function ProductTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="products" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,11 +453,9 @@ function Row({
|
|||||||
>
|
>
|
||||||
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
|
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
|
||||||
selects `a.*` and joins no subcategory table — so it is looked up
|
selects `a.*` and joins no subcategory table — so it is looked up
|
||||||
from the platform list the app itself groups by. */}
|
from the platform list the app itself groups by. The drawer prints
|
||||||
{aisleNames.get(product.subcategoryid ?? 0) ??
|
the same string from the same helper, or the two disagree. */}
|
||||||
product.categoryname ??
|
{aisleLabelOf(product, aisleNames)}
|
||||||
product.Subcategoryname ??
|
|
||||||
'Uncategorized'}
|
|
||||||
</span>
|
</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
|
|||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
import type { DeliveryRow } from '@/api/types';
|
import type { DeliveryRow } from '@/api/types';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { coverage, profitability } from './profitability';
|
import { coverage, profitability } from './profitability';
|
||||||
@@ -48,7 +49,7 @@ export function ProfitPanel({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading the day…
|
Reading the day…
|
||||||
@@ -60,7 +61,7 @@ export function ProfitPanel({
|
|||||||
|
|
||||||
if (result.deliveries === 0) {
|
if (result.deliveries === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Nothing finished in this range
|
Nothing finished in this range
|
||||||
@@ -76,7 +77,7 @@ export function ProfitPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -129,7 +130,7 @@ export function ProfitPanel({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={2}>
|
<VStack gap={1} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
By rider
|
By rider
|
||||||
@@ -167,7 +168,12 @@ export function ProfitPanel({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Delivery profit"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="deliveries"
|
||||||
|
footer={<TablePager paged={paged} label="deliveries" />}
|
||||||
|
>
|
||||||
<VStack gap={1} padding={2}>
|
<VStack gap={1} padding={2}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Delivery by delivery
|
Delivery by delivery
|
||||||
@@ -211,8 +217,7 @@ export function ProfitPanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="deliveries" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="pva-note">
|
<div className="pva-note">
|
||||||
<Info size={13} />
|
<Info size={13} />
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
|||||||
value={
|
value={
|
||||||
<Badge
|
<Badge
|
||||||
label={`${results.length - failed.length} of ${results.length}`}
|
label={`${results.length - failed.length} of ${results.length}`}
|
||||||
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
|
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : 'var(--color-success, #1f9d55)'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{...(failed.length === 0 ? { tone: 'success' as const } : {})}
|
{...(failed.length === 0 ? { tone: 'success' as const } : {})}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { Bike, Building2, CircleDot } from 'lucide-react';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Bike, Info } from 'lucide-react';
|
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
||||||
import type { TenantLocation } from '@/api/types';
|
import type { TenantLocation } from '@/api/types';
|
||||||
import { useRiderShifts } from '@/queries/hooks';
|
import { useRiderShifts } from '@/queries/hooks';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import { DrawerButton } from './drawerKit';
|
import {
|
||||||
import './pages/deliveries.css';
|
Alert,
|
||||||
|
Badge,
|
||||||
|
DrawerButton,
|
||||||
|
DrawerCard,
|
||||||
|
Field,
|
||||||
|
FieldRow,
|
||||||
|
Note,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
SelectField,
|
||||||
|
TextField,
|
||||||
|
} from './drawerKit';
|
||||||
|
import { branchLabel } from './format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hiring a rider, or editing one.
|
* Hiring a rider, or editing one.
|
||||||
@@ -30,6 +37,15 @@ import './pages/deliveries.css';
|
|||||||
* and start a shift, because that list requires a clock-in stamped today. That
|
* and start a shift, because that list requires a clock-in stamped today. That
|
||||||
* is correct behaviour and it looks exactly like a failed save, so the drawer
|
* is correct behaviour and it looks exactly like a failed save, so the drawer
|
||||||
* says it before you press the button rather than leaving you to wonder.
|
* says it before you press the button rather than leaving you to wonder.
|
||||||
|
*
|
||||||
|
* ── What it used to leave out ───────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The roster table carries Partner and On shift, and this drawer carried
|
||||||
|
* neither — so opening a rider to check who they ride for lost the answer the
|
||||||
|
* row had just given. Both are FACTS rather than fields: whose rider this is
|
||||||
|
* comes from the scope the drawer was opened in, and being on shift means a
|
||||||
|
* clock-in stamped today, which the console cannot set. They are stated at the
|
||||||
|
* top, above the things that can actually be typed.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Whose rider this is.
|
* Whose rider this is.
|
||||||
@@ -140,12 +156,34 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
|||||||
applocationid > 0 &&
|
applocationid > 0 &&
|
||||||
!save.isPending;
|
!save.isPending;
|
||||||
|
|
||||||
|
/* Whose rider this is, in the words the roster's Partner column uses. An own
|
||||||
|
rider reads "Own rider" there and here, rather than an empty cell in one
|
||||||
|
place and nothing at all in the other. */
|
||||||
|
const partnerName =
|
||||||
|
owner.kind === 'partner' ? owner.partnername : (row?.partnername ?? 'Own rider');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={isNew ? 'Add a rider' : row.fullname || 'Rider'}
|
title={isNew ? 'Add a rider' : row.fullname || 'Rider'}
|
||||||
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
|
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
|
||||||
width={480}
|
width={480}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
/* The roster's Status column, beside the title — the same chip, and the
|
||||||
|
reason somebody opened this rider in the first place. */
|
||||||
|
{...(isNew
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
meta: (
|
||||||
|
<Badge
|
||||||
|
label={row.status || 'Active'}
|
||||||
|
colour={
|
||||||
|
(row.status ?? 'Active') === 'Active'
|
||||||
|
? 'var(--color-success, #1f9d55)'
|
||||||
|
: 'var(--color-warning, #b7860b)'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
isFooterSpread
|
isFooterSpread
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -160,46 +198,61 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<VStack gap={3}>
|
{problem ? <Alert>{problem}</Alert> : null}
|
||||||
|
|
||||||
{/* The region is stated, not offered. If the branch has none, saying so
|
{/* The region is stated, not offered. If the branch has none, saying so
|
||||||
here beats a 400 after the form is filled in. */}
|
here beats a 400 after the form is filled in. */}
|
||||||
{applocationid === 0 ? (
|
{applocationid === 0 ? (
|
||||||
<div className="rider-note" data-tone="stop">
|
<Alert tone="warning">
|
||||||
<Info size={15} />
|
This branch has no delivery region set, so a rider added to it would not appear anywhere.
|
||||||
<Text type="body" size="sm">
|
Set the region on the branch first.
|
||||||
This branch has no delivery region set, so a rider added to it would not appear
|
</Alert>
|
||||||
anywhere. Set the region on the branch first.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
{/* ── What the roster row said ───────────────────────────────────────
|
||||||
<HStack gap={1.5} wrap="wrap">
|
Read-only, because neither can be typed: ownership comes from the
|
||||||
<TextInput label="First name" value={form.firstname} onChange={set('firstname')} />
|
scope this drawer was opened in, and being on shift means a clock-in
|
||||||
<TextInput label="Last name" value={form.lastname} onChange={set('lastname')} />
|
stamped today, which only the rider app writes. */}
|
||||||
</HStack>
|
{isNew ? null : (
|
||||||
<TextInput
|
<DrawerCard>
|
||||||
label="Phone"
|
<Row label="Partner" value={partnerName} icon={<Building2 size={14} />} />
|
||||||
value={form.contactno}
|
<Row
|
||||||
onChange={set('contactno')}
|
label="On shift"
|
||||||
description="How the shop reaches them, and how the app signs them in."
|
value={row.isonduty ? 'On shift' : 'Off shift'}
|
||||||
|
icon={<CircleDot size={14} />}
|
||||||
|
{...(row.isonduty ? { tone: 'success' as const } : { tone: 'muted' as const })}
|
||||||
/>
|
/>
|
||||||
<TextInput label="Email" value={form.email} onChange={set('email')} />
|
</DrawerCard>
|
||||||
</VStack>
|
)}
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<Section title="Who they are">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<FieldRow>
|
||||||
Shift and partner
|
<Field label="First name" isRequired>
|
||||||
</Text>
|
<TextField value={form.firstname} onChange={set('firstname')} />
|
||||||
<Selector
|
</Field>
|
||||||
label="Shift"
|
<Field label="Last name">
|
||||||
size="sm"
|
<TextField value={form.lastname} onChange={set('lastname')} />
|
||||||
|
</Field>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
isRequired
|
||||||
|
description="How the shop reaches them, and how the app signs them in."
|
||||||
|
>
|
||||||
|
<TextField type="tel" value={form.contactno} onChange={set('contactno')} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email">
|
||||||
|
<TextField type="email" value={form.email} onChange={set('email')} />
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Shift and branch">
|
||||||
|
<Field label="Shift" isRequired>
|
||||||
|
<SelectField
|
||||||
value={form.shiftid}
|
value={form.shiftid}
|
||||||
onChange={(value) => set('shiftid')(String(value))}
|
onChange={set('shiftid')}
|
||||||
options={(shifts.data ?? []).map((shift) => ({
|
|
||||||
value: String(shift.shiftid),
|
|
||||||
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
|
|
||||||
}))}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
shifts.isLoading
|
shifts.isLoading
|
||||||
? 'Loading shifts…'
|
? 'Loading shifts…'
|
||||||
@@ -207,94 +260,89 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
|||||||
? 'Choose a shift'
|
? 'Choose a shift'
|
||||||
: 'No shifts set up for this region'
|
: 'No shifts set up for this region'
|
||||||
}
|
}
|
||||||
|
options={(shifts.data ?? []).map((shift) => ({
|
||||||
|
value: String(shift.shiftid),
|
||||||
|
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{/* A merchant's rider works out of one of that merchant's branches.
|
{/* A merchant's rider works out of one of that merchant's branches.
|
||||||
A partner's does not — a partner supplies several merchants and is
|
A partner's does not — a partner supplies several merchants and is
|
||||||
tied to none of their outlets — so the question is only asked
|
tied to none of their outlets — so the question is only asked
|
||||||
where it has an answer. */}
|
where it has an answer. */}
|
||||||
{owner.kind === 'tenant' ? (
|
{owner.kind === 'tenant' ? (
|
||||||
<Selector
|
<Field
|
||||||
label="Branch"
|
label="Branch"
|
||||||
size="sm"
|
description="Which of your outlets this rider works out of. It can be changed later."
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
value={form.locationid}
|
value={form.locationid}
|
||||||
onChange={(value) => set('locationid')(String(value))}
|
onChange={set('locationid')}
|
||||||
|
placeholder="Choose the branch they ride from"
|
||||||
options={owner.branches.map((entry) => ({
|
options={owner.branches.map((entry) => ({
|
||||||
value: String(entry.locationid),
|
value: String(entry.locationid),
|
||||||
label: entry.locationname || `Branch ${entry.locationid}`,
|
label: branchLabel(entry.locationname) || `Branch ${entry.locationid}`,
|
||||||
}))}
|
}))}
|
||||||
placeholder="Choose the branch they ride from"
|
|
||||||
description="Which of your outlets this rider works out of. It can be changed later."
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<div className="rider-note">
|
<Note>
|
||||||
<Info size={14} />
|
|
||||||
<Text type="body" size="sm" color="secondary">
|
|
||||||
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
|
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
|
||||||
partner supplies in this region.
|
partner supplies in this region.
|
||||||
</Text>
|
</Note>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</Section>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<Section title="Vehicle and papers">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<FieldRow>
|
||||||
Vehicle and papers
|
<Field label="Vehicle">
|
||||||
</Text>
|
<TextField
|
||||||
<HStack gap={1.5} wrap="wrap">
|
|
||||||
<TextInput
|
|
||||||
label="Vehicle"
|
|
||||||
value={form.vehiclename}
|
value={form.vehiclename}
|
||||||
onChange={set('vehiclename')}
|
onChange={set('vehiclename')}
|
||||||
placeholder="Bike, Scooter…"
|
placeholder="Bike, Scooter…"
|
||||||
/>
|
/>
|
||||||
<TextInput label="Number plate" value={form.vehicleno} onChange={set('vehicleno')} />
|
</Field>
|
||||||
</HStack>
|
<Field label="Number plate">
|
||||||
<HStack gap={1.5} wrap="wrap">
|
<TextField value={form.vehicleno} onChange={set('vehicleno')} />
|
||||||
<TextInput label="Licence no." value={form.licenseno} onChange={set('licenseno')} />
|
</Field>
|
||||||
<TextInput
|
</FieldRow>
|
||||||
label="Registration no."
|
<FieldRow>
|
||||||
value={form.registrationno}
|
<Field label="Licence no.">
|
||||||
onChange={set('registrationno')}
|
<TextField value={form.licenseno} onChange={set('licenseno')} />
|
||||||
/>
|
</Field>
|
||||||
</HStack>
|
<Field label="Registration no.">
|
||||||
<TextInput
|
<TextField value={form.registrationno} onChange={set('registrationno')} />
|
||||||
label="ID number"
|
</Field>
|
||||||
value={form.identificationno}
|
</FieldRow>
|
||||||
onChange={set('identificationno')}
|
<Field label="ID number">
|
||||||
/>
|
<TextField value={form.identificationno} onChange={set('identificationno')} />
|
||||||
</VStack>
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{!isNew ? (
|
{!isNew ? (
|
||||||
<Selector
|
<Section title="Status">
|
||||||
label="Status"
|
<Field label="Status">
|
||||||
size="sm"
|
<SelectField
|
||||||
value={form.status}
|
value={form.status}
|
||||||
onChange={(value) => set('status')(String(value))}
|
onChange={set('status')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'Active', label: 'Active' },
|
{ value: 'Active', label: 'Active' },
|
||||||
{ value: 'InActive', label: 'Inactive — cannot be given work' },
|
{ value: 'InActive', label: 'Inactive — cannot be given work' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</Field>
|
||||||
|
</Section>
|
||||||
{problem ? (
|
|
||||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
||||||
{problem}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Said before the button, not after. This is the behaviour most likely
|
{/* Said before the button, not after. This is the behaviour most likely
|
||||||
to be read as a failed save. */}
|
to be read as a failed save. */}
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<div className="rider-note">
|
<Note>
|
||||||
<Info size={15} />
|
They will show here straight away, but not in the fleet you assign deliveries from until
|
||||||
<Text type="body" size="xsm">
|
they sign in to the rider app and start a shift.
|
||||||
They will show here straight away, but not in the fleet you assign deliveries from
|
</Note>
|
||||||
until they sign in to the rider app and start a shift.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</VStack>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser';
|
|||||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import { DrawerButton } from './drawerKit';
|
import { Alert, DrawerButton } from './drawerKit';
|
||||||
import { buildDeliveries, riderName } from './assignDelivery';
|
import { buildDeliveries, riderName } from './assignDelivery';
|
||||||
import { money } from './format';
|
import { money } from './format';
|
||||||
import { orderValue } from './orderStatus';
|
import { orderValue } from './orderStatus';
|
||||||
@@ -271,11 +271,7 @@ export function RoutePlanDrawer({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{problem ? (
|
{problem ? <Alert>{problem}</Alert> : null}
|
||||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
||||||
{problem}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{outcome ? (
|
{outcome ? (
|
||||||
<Text type="body" size="sm" role="status">
|
<Text type="body" size="sm" role="status">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
aisleForCategory,
|
aisleForCategory,
|
||||||
aisleIdForCategory,
|
aisleIdForCategory,
|
||||||
aisleIdsFrom,
|
aisleIdsFrom,
|
||||||
|
aisleLabelOf,
|
||||||
|
UNCATEGORISED_AISLE,
|
||||||
} from './appAisle';
|
} from './appAisle';
|
||||||
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
|
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
|
||||||
|
|
||||||
@@ -89,3 +91,36 @@ test('every aisle has an id, and no two share one', () => {
|
|||||||
assert.equal(new Set(values).size, values.length);
|
assert.equal(new Set(values).size, values.length);
|
||||||
assert.ok(values.every((id) => id > 0));
|
assert.ok(values.every((id) => id > 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── The label a row and a drawer must agree on ───────────────────────────── */
|
||||||
|
|
||||||
|
// The bug this replaces: the table fell back to `categoryname`, the drawer did
|
||||||
|
// not, so one product read "Dairy" in the list and "Uncategorized" in the sheet
|
||||||
|
// opened from it.
|
||||||
|
test('the printed aisle prefers the platform list, then the row own names', () => {
|
||||||
|
const names = new Map([[15, 'Dairy, Deli & Egg']]);
|
||||||
|
assert.equal(aisleLabelOf({ subcategoryid: 15 }, names), 'Dairy, Deli & Egg');
|
||||||
|
assert.equal(
|
||||||
|
aisleLabelOf({ subcategoryid: 99, categoryname: 'Dairy' }, names),
|
||||||
|
'Dairy',
|
||||||
|
'a subcategoryid the platform list does not know still has the row own name',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
aisleLabelOf({ subcategoryid: 99, Subcategoryname: 'Snacks' }, names),
|
||||||
|
'Snacks',
|
||||||
|
'the misspelt wire field is read too',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `categoryname` comes back as '' on every row `getlocationproducts` returns,
|
||||||
|
// and `??` does not skip an empty string — it printed a blank cell.
|
||||||
|
test('a blank name is absent, not a label', () => {
|
||||||
|
const names = new Map<number, string>();
|
||||||
|
assert.equal(aisleLabelOf({ subcategoryid: 0, categoryname: '' }, names), 'Uncategorized');
|
||||||
|
assert.equal(aisleLabelOf({ categoryname: ' ' }, names), 'Uncategorized');
|
||||||
|
assert.equal(aisleLabelOf({}, names), 'Uncategorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Uncategorized" is the app own heading, spelt the app own way', () => {
|
||||||
|
assert.equal(UNCATEGORISED_AISLE, 'Uncategorized');
|
||||||
|
});
|
||||||
|
|||||||
@@ -196,3 +196,44 @@ export function aisleIdForCategory(
|
|||||||
if (!aisle) return 0;
|
if (!aisle) return 0;
|
||||||
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
|
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The aisle name to PRINT for a product, on a row or in a drawer.
|
||||||
|
*
|
||||||
|
* This exists because the table and the drawer each grew their own version of
|
||||||
|
* it and they disagreed: the row fell back to `categoryname` when the platform
|
||||||
|
* list had no entry for the product's `subcategoryid`, and the drawer did not —
|
||||||
|
* so the same product read "Dairy" in the list and "Uncategorized" the moment
|
||||||
|
* you opened it. There is one answer to "where does the app show this", and it
|
||||||
|
* is here.
|
||||||
|
*
|
||||||
|
* Blank is treated as absent, which `??` alone does not do. `categoryname` is
|
||||||
|
* empty on every row `getlocationproducts` returns — the app's category 2 has
|
||||||
|
* no row in `productcategories` — and falling back to an empty string printed a
|
||||||
|
* blank cell where "Uncategorized" is the truth: it is the heading the customer
|
||||||
|
* app will actually render for a product with no aisle.
|
||||||
|
*/
|
||||||
|
export function aisleLabelOf(
|
||||||
|
product: {
|
||||||
|
subcategoryid?: number | undefined;
|
||||||
|
categoryname?: string | undefined;
|
||||||
|
Subcategoryname?: string | undefined;
|
||||||
|
},
|
||||||
|
aisleNames: ReadonlyMap<number, string>,
|
||||||
|
): string {
|
||||||
|
const named = aisleNames.get(product.subcategoryid ?? 0);
|
||||||
|
return (
|
||||||
|
firstFilled(named, product.categoryname, product.Subcategoryname) ?? UNCATEGORISED_AISLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The heading the customer app prints for anything with no aisle. */
|
||||||
|
export const UNCATEGORISED_AISLE = 'Uncategorized';
|
||||||
|
|
||||||
|
function firstFilled(...values: (string | undefined)[]): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,3 +124,42 @@ export function byUrgency(a: CounterRow, b: CounterRow): number {
|
|||||||
const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
|
const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
|
||||||
return rank[a.state] - rank[b.state];
|
return rank[a.state] - rank[b.state];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── How a counter's state is named and coloured ──────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four states, in the words and colours the whole console uses for them.
|
||||||
|
*
|
||||||
|
* These lived as two private maps inside `CountersPage`, which is why the
|
||||||
|
* drawer opened from that table showed no status at all: the vocabulary was not
|
||||||
|
* reachable from it. A state has one name and one colour wherever it is
|
||||||
|
* printed — the table's pill, the attention card, the drawer's header badge.
|
||||||
|
*/
|
||||||
|
export const COUNTER_STATE_LABEL: Record<CounterState, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
attention: 'Attention',
|
||||||
|
never: 'Never used',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The page's own pill tone, for `data-tone` in `counters.css`. */
|
||||||
|
export const COUNTER_STATE_TONE: Record<CounterState, string> = {
|
||||||
|
active: 'healthy',
|
||||||
|
attention: 'attention',
|
||||||
|
never: 'neutral',
|
||||||
|
offline: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same four states as a colour, for the drawer badge.
|
||||||
|
*
|
||||||
|
* The badge takes a colour rather than a tone name because `drawerKit`'s
|
||||||
|
* `Badge` is fed one by every other drawer — statuses in this console come from
|
||||||
|
* several vocabularies and the shared badge deliberately knows none of them.
|
||||||
|
*/
|
||||||
|
export const COUNTER_STATE_COLOUR: Record<CounterState, string> = {
|
||||||
|
active: 'var(--color-success, #1f9d55)',
|
||||||
|
attention: 'var(--color-warning, #b7860b)',
|
||||||
|
never: 'var(--color-ink-4)',
|
||||||
|
offline: 'var(--color-error, #d64545)',
|
||||||
|
};
|
||||||
|
|||||||
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 ══════════════════════════════════════════════
|
/* ══ The drawer design system ══════════════════════════════════════════════
|
||||||
One shell, one header, one card, one row, one badge, one button — used by
|
One shell, one header, one card, one row, one badge, one button, one field —
|
||||||
every side drawer in the console so they read as one product rather than as
|
used by every side drawer in the console so they read as one product rather
|
||||||
several. Everything below is built on the tokens already in `index.css`:
|
than as several.
|
||||||
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
|
|
||||||
the muted greys. No drawer defines a colour of its own.
|
|
||||||
|
|
||||||
Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */
|
── What changed in this pass, and why ──────────────────────────────────────
|
||||||
|
|
||||||
|
1. EVERY SIZE IS A TOKEN NOW. The old sheet typed its own numbers at each
|
||||||
|
use — 13.5px here, 14.5px there, 11.5px somewhere else, four radii, three
|
||||||
|
control heights. Half-pixel type is what made the drawers look hand-made
|
||||||
|
beside the tables they open from: no two labels sat on the same baseline
|
||||||
|
and no two boxes shared a corner. There is one ramp below and nothing
|
||||||
|
outside it.
|
||||||
|
|
||||||
|
2. THE HEADER IS THE SURFACE, NOT THE BRAND. It used to be a bar of
|
||||||
|
saturated purple. That cost real information: a status badge cannot be
|
||||||
|
read in its own colour on a purple ground, so the badge had to be
|
||||||
|
re-invented there as a white chip with a coloured dot — the one badge in
|
||||||
|
the console that did not match the chip in the row it was opened from. A
|
||||||
|
white header with a hairline gives the badge its colour back, lets the
|
||||||
|
title sit in the same ink as the page, and stops the drawer announcing
|
||||||
|
itself louder than the data it carries. The brand still has the primary
|
||||||
|
button, the icons and the timeline.
|
||||||
|
|
||||||
|
The header keeps its 57px, which is not cosmetic: `AppShell`'s nav bar is
|
||||||
|
a 56px row plus a 1px rule, so an open drawer's header finishes on exactly
|
||||||
|
the line the nav bar finishes on, edge to edge across the screen.
|
||||||
|
|
||||||
|
3. THE KIT COVERS FORMS. Seven drawers are forms and the kit had nothing for
|
||||||
|
them, so each reached for Astryx's inputs and its own inline styles. The
|
||||||
|
field, input, select, choice and alert rules below are that missing half.
|
||||||
|
|
||||||
|
Everything reads from the brand tokens in `index.css`. No rule here invents
|
||||||
|
a colour. */
|
||||||
|
|
||||||
|
.drawer-root {
|
||||||
|
/* ── The ramp ──────────────────────────────────────────────────────────
|
||||||
|
Whole pixels, and a real ratio between the steps. Anything that needs a
|
||||||
|
size takes one of these. */
|
||||||
|
--dr-text-xs: 11px; /* eyebrow caps: section titles, metric labels */
|
||||||
|
--dr-text-sm: 12px; /* secondary: subtitles, notes under a value */
|
||||||
|
--dr-text-md: 13px; /* body: row labels, help text, buttons */
|
||||||
|
--dr-text-lg: 14px; /* emphasis: row values, field labels, titles */
|
||||||
|
--dr-text-xl: 18px; /* the one figure a drawer is opened to read */
|
||||||
|
|
||||||
|
/* ── Spacing, on a 4px grid ─────────────────────────────────────────── */
|
||||||
|
--dr-gap-xs: 4px;
|
||||||
|
--dr-gap-sm: 8px;
|
||||||
|
--dr-gap-md: 12px;
|
||||||
|
--dr-gap-lg: 16px;
|
||||||
|
--dr-gap-xl: 20px;
|
||||||
|
|
||||||
|
/* ── Shape ────────────────────────────────────────────────────────────
|
||||||
|
The card radius is the CONSOLE's card radius, not one of the drawer's
|
||||||
|
own. A card in a drawer and a card on the page behind it are the same
|
||||||
|
object seen in two places, and this file had them at 10 and 16.
|
||||||
|
|
||||||
|
`--dr-radius-sm` stays a local number because it is a CONTROL radius —
|
||||||
|
the close button, an input, a photo thumbnail — not a small card. */
|
||||||
|
--dr-radius: var(--card-radius);
|
||||||
|
--dr-radius-sm: 8px;
|
||||||
|
--dr-control: 36px; /* every button and input is this tall */
|
||||||
|
--dr-pad-x: 20px; /* the panel's own gutter, used by head/body/foot */
|
||||||
|
--dr-row-pad: 11px 14px;
|
||||||
|
|
||||||
|
/* ── Lines ──────────────────────────────────────────────────────────── */
|
||||||
|
--dr-line: var(--color-line);
|
||||||
|
--dr-line-soft: color-mix(in oklab, var(--color-line) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shell ──────────────────────────────────────────────────────────────── */
|
/* ── Shell ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -26,7 +87,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: rgb(15 23 42 / 0.32);
|
background: rgb(15 23 42 / 0.28);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
animation: drawer-fade 180ms ease;
|
animation: drawer-fade 180ms ease;
|
||||||
}
|
}
|
||||||
@@ -40,7 +101,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 92vw;
|
max-width: 92vw;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18);
|
/* A hairline as well as the shadow. With a white header the panel needs an
|
||||||
|
edge of its own, or it bleeds into a white table behind it. */
|
||||||
|
border-left: 1px solid var(--dr-line);
|
||||||
|
box-shadow: -32px 0 64px -24px rgb(15 23 42 / 0.22);
|
||||||
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
|
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,36 +123,26 @@
|
|||||||
|
|
||||||
/* ── Header ─────────────────────────────────────────────────────────────────
|
/* ── Header ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ONE BAR HIGH, and the same bar the page wears.
|
ONE BAR HIGH, and the same bar the page wears — see the note at the top of
|
||||||
|
this file for the 57 and for why it is no longer purple.
|
||||||
|
|
||||||
`AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's
|
It is a ROW rather than a stack: title and subtitle share a line and a half
|
||||||
header is 56 + 1 too: open a drawer and its header sits on exactly the line
|
in the middle, the status badge sits beside them, and the close button ends
|
||||||
the nav bar sits on, edge to edge across the screen, instead of a 116px block
|
the row. A stacked header was a third line, and a third line is what made
|
||||||
starting level with it and finishing 60px lower.
|
this twice the height of the nav bar it now matches. */
|
||||||
|
|
||||||
Getting there meant the header becoming a ROW rather than a stack: title and
|
|
||||||
subtitle share a line-and-a-half in the middle, the status badge sits beside
|
|
||||||
them, and the close button ends the row. Nothing was dropped to make it fit —
|
|
||||||
the same four things are in it.
|
|
||||||
|
|
||||||
It is painted in the brand purple. The one place a drawer needs to announce
|
|
||||||
itself is the moment it slides in over the page, and the purple does that
|
|
||||||
without the panel needing a border or a heavier shadow. */
|
|
||||||
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--dr-gap-md);
|
||||||
/* 57, not 56: box-sizing is border-box across the app, so the 1px rule
|
/* 57, not 56: box-sizing is border-box across the app, so the 1px rule
|
||||||
below eats into the height. The nav bar is a 56px row PLUS its rule, and
|
below eats into the height. The nav bar is a 56px row PLUS its rule, and
|
||||||
the two have to finish on the same pixel. */
|
the two have to finish on the same pixel. */
|
||||||
height: 57px;
|
height: 57px;
|
||||||
padding: 0 12px 0 20px;
|
padding: 0 var(--dr-gap-md) 0 var(--dr-pad-x);
|
||||||
background: var(--color-brand);
|
background: var(--color-surface);
|
||||||
/* A darker purple, not the grey page rule: a grey seam across a saturated
|
border-bottom: 1px solid var(--dr-line);
|
||||||
ground reads as a rendering fault rather than an edge. */
|
|
||||||
border-bottom: 1px solid var(--color-brand-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head-text {
|
.drawer-head-text {
|
||||||
@@ -106,42 +160,44 @@
|
|||||||
full text is on the page behind it as well as in the tooltip. */
|
full text is on the page behind it as well as in the tooltip. */
|
||||||
.drawer-title {
|
.drawer-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 600 15px/1.3 var(--font-sans);
|
font: 600 var(--dr-text-lg)/1.3 var(--font-sans);
|
||||||
letter-spacing: -0.005em;
|
letter-spacing: -0.006em;
|
||||||
color: #fff;
|
color: var(--color-ink-1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* An order id is a code, and a code reads better tabular. */
|
/* An order id is a code, and a code reads better tabular. */
|
||||||
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; }
|
.drawer-title[data-mono='true'] {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--dr-text-md);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-sub {
|
.drawer-sub {
|
||||||
font: 400 11.5px/1.35 var(--font-sans);
|
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||||
/* Not a grey: a grey on purple goes muddy. White at 72% keeps the same
|
color: var(--color-ink-4);
|
||||||
"quieter than the title" relationship the page's ink scale gives. */
|
|
||||||
color: rgb(255 255 255 / 0.72);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Beside the title now, not under it — that stacked row is what made the
|
/* Beside the title, not under it — that stacked row is what made the header
|
||||||
header three lines tall. It never wraps: a second badge shortens the title
|
three lines tall. It never wraps: a second badge shortens the title rather
|
||||||
rather than pushing the row taller. */
|
than pushing the row taller. */
|
||||||
.drawer-meta {
|
.drawer-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--dr-gap-sm);
|
||||||
flex: none;
|
flex: none;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
/* Capped, because the meta keeps its width and the title gives way. A branch
|
/* Capped, because the meta keeps its width and the title gives way. A branch
|
||||||
named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
|
named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */
|
||||||
.drawer-meta-text {
|
.drawer-meta-text {
|
||||||
font: 400 12px/1.4 var(--font-sans);
|
font: 400 var(--dr-text-sm)/1.4 var(--font-sans);
|
||||||
color: rgb(255 255 255 / 0.72);
|
color: var(--color-ink-4);
|
||||||
max-width: 30ch;
|
max-width: 24ch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -150,18 +206,22 @@
|
|||||||
.drawer-close {
|
.drawer-close {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 9px;
|
border-radius: var(--dr-radius-sm);
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
flex: none;
|
flex: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgb(255 255 255 / 0.14);
|
background: transparent;
|
||||||
color: #fff;
|
color: var(--color-ink-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 160ms ease;
|
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||||
}
|
}
|
||||||
.drawer-close:hover { background: rgb(255 255 255 / 0.26); }
|
.drawer-close:hover {
|
||||||
.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
background: var(--color-surface-sunken);
|
||||||
|
border-color: var(--dr-line);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
}
|
||||||
|
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -169,11 +229,20 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* Stops a scroll that reaches the end of the drawer from continuing into the
|
||||||
|
page underneath. The page is also locked outright while a drawer is open —
|
||||||
|
see `Drawer.tsx` — because this does nothing about a wheel over the scrim,
|
||||||
|
which is most of the screen. */
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 20px 24px 24px;
|
/* Animates the moves the drawer makes for you: the uploads drawer's jump to
|
||||||
|
its rejection panel, and the browser's own scroll when you tab to a field
|
||||||
|
below the fold in a long form. The wheel is untouched. The global
|
||||||
|
reduced-motion reset in `index.css` turns this back to `auto`. */
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
padding: var(--dr-pad-x) var(--dr-pad-x) 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: var(--dr-gap-xl);
|
||||||
}
|
}
|
||||||
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
|
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
|
||||||
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
|
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
|
||||||
@@ -197,9 +266,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: var(--dr-gap-sm);
|
||||||
padding: 12px 24px;
|
padding: var(--dr-gap-md) var(--dr-pad-x);
|
||||||
border-top: 1px solid var(--color-line);
|
border-top: 1px solid var(--dr-line);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
.drawer-foot > * { flex: 0 1 auto; }
|
.drawer-foot > * { flex: 0 1 auto; }
|
||||||
@@ -212,23 +281,24 @@
|
|||||||
|
|
||||||
/* ── Section ────────────────────────────────────────────────────────────── */
|
/* ── Section ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.drawer-section { display: flex; flex-direction: column; gap: 10px; }
|
.drawer-section { display: flex; flex-direction: column; gap: var(--dr-gap-md); }
|
||||||
|
|
||||||
.drawer-section-title {
|
.drawer-section-title {
|
||||||
font: 600 11.5px/1 var(--font-sans);
|
margin: 0;
|
||||||
letter-spacing: 0.08em;
|
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||||
|
letter-spacing: 0.075em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-ink-3);
|
color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card ───────────────────────────────────────────────────────────────── */
|
/* ── Card ───────────────────────────────────────────────────────────────── */
|
||||||
/* One radius, one border, everywhere. Rows inside are separated by hairlines
|
/* One radius, one border, everywhere. Rows inside are separated by hairlines
|
||||||
rather than by giving each its own card — a stack of cards for four labelled
|
rather than by giving each its own card — a stack of cards for four labelled
|
||||||
values is the clutter this redesign exists to remove. */
|
values is the clutter this system exists to remove. */
|
||||||
.drawer-card {
|
.drawer-card {
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
border-radius: 12px;
|
border-radius: var(--dr-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
|
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
|
||||||
@@ -242,47 +312,53 @@
|
|||||||
.drawer-row {
|
.drawer-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 16px;
|
gap: var(--dr-gap-lg);
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 11px 14px;
|
padding: var(--dr-row-pad);
|
||||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
border-bottom: 1px solid var(--dr-line-soft);
|
||||||
}
|
}
|
||||||
.drawer-row:last-child { border-bottom: 0; }
|
.drawer-row:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
.drawer-row-label {
|
.drawer-row-label {
|
||||||
font: 400 13.5px/1.45 var(--font-sans);
|
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
|
||||||
color: var(--color-ink-3);
|
color: var(--color-ink-3);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--dr-gap-sm);
|
||||||
}
|
}
|
||||||
.drawer-row-label svg { color: var(--color-brand); flex: none; }
|
.drawer-row-label svg { color: var(--color-ink-4); flex: none; }
|
||||||
|
|
||||||
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
|
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
|
||||||
.drawer-row-value {
|
.drawer-row-value {
|
||||||
font: 500 14px/1.45 var(--font-sans);
|
font: 500 var(--dr-text-lg)/1.45 var(--font-sans);
|
||||||
color: var(--color-ink-1);
|
color: var(--color-ink-1);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; }
|
.drawer-row-value[data-tone='success'] { color: var(--color-success, #1f9d55); font-weight: 600; }
|
||||||
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); }
|
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); font-weight: 400; }
|
||||||
|
|
||||||
/* A row that wraps its value under the label — for prose, not for figures. */
|
/* A row that wraps its value under the label — for prose, not for figures. */
|
||||||
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; }
|
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: var(--dr-gap-xs); }
|
||||||
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; }
|
.drawer-row[data-stacked='true'] .drawer-row-value {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--dr-text-md);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Metric grid ────────────────────────────────────────────────────────── */
|
/* ── Metric grid ────────────────────────────────────────────────────────── */
|
||||||
/* Three equal columns. Equal, not auto: the whole point is that the figures sit
|
/* Equal columns, not auto: the whole point is that the figures sit on a
|
||||||
on a rhythm, and `auto` columns move them every time a value changes width. */
|
rhythm, and `auto` columns move them every time a value changes width. */
|
||||||
.drawer-metrics {
|
.drawer-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--color-line);
|
background: var(--dr-line);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
border-radius: 12px;
|
border-radius: var(--dr-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -291,66 +367,54 @@
|
|||||||
.drawer-metric {
|
.drawer-metric {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: var(--dr-gap-xs);
|
||||||
padding: 12px 14px;
|
padding: var(--dr-gap-md) 14px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.drawer-metric-label {
|
.drawer-metric-label {
|
||||||
font: 600 11px/1 var(--font-sans);
|
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.065em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-ink-4);
|
color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
.drawer-metric-value {
|
.drawer-metric-value {
|
||||||
font: 600 17px/1.2 var(--font-sans);
|
font: 600 var(--dr-text-xl)/1.2 var(--font-sans);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
color: var(--color-ink-1);
|
color: var(--color-ink-1);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; }
|
.drawer-metric-value[data-size='sm'] { font-size: var(--dr-text-lg); letter-spacing: 0; }
|
||||||
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); }
|
.drawer-metric-note { font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); }
|
||||||
|
|
||||||
/* ── Status badge ───────────────────────────────────────────────────────── */
|
/* ── Status badge ───────────────────────────────────────────────────────── */
|
||||||
/* One badge, tinted from a single colour, so every status in the console is the
|
/* One badge, tinted from a single colour, so every status in the console is the
|
||||||
same shape and only the hue carries meaning. */
|
same shape and only the hue carries meaning.
|
||||||
|
|
||||||
|
It looks the same in the header as it does on a card. That is the point of
|
||||||
|
the header no longer being purple: the chip beside the title is now exactly
|
||||||
|
the chip in the row the drawer was opened from, rather than a white-on-purple
|
||||||
|
translation of it with the colour demoted to a dot. */
|
||||||
.drawer-badge {
|
.drawer-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 3px 10px;
|
padding: 3px 9px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font: 600 12px/1.5 var(--font-sans);
|
font: 600 var(--dr-text-sm)/1.5 var(--font-sans);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--badge);
|
color: var(--badge);
|
||||||
background: color-mix(in oklab, var(--badge) 12%, transparent);
|
background: color-mix(in oklab, var(--badge) 11%, transparent);
|
||||||
}
|
border: 1px solid color-mix(in oklab, var(--badge) 24%, transparent);
|
||||||
|
|
||||||
/* In the purple header the status colour cannot be the text colour — a 12%
|
|
||||||
tint of green on purple is unreadable, and several statuses are darker than
|
|
||||||
the ground. The label goes white on a translucent chip and the colour moves
|
|
||||||
to a dot, lightened so it still separates from the purple. The status is
|
|
||||||
still distinguishable at a glance, which is the badge's whole job. */
|
|
||||||
.drawer-head .drawer-badge {
|
|
||||||
color: #fff;
|
|
||||||
background: rgb(255 255 255 / 0.16);
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
.drawer-head .drawer-badge::before {
|
|
||||||
content: '';
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex: none;
|
|
||||||
background: color-mix(in oklab, var(--badge) 55%, white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Timeline ───────────────────────────────────────────────────────────── */
|
/* ── Timeline ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.drawer-timeline { display: flex; flex-direction: column; }
|
.drawer-timeline { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; }
|
.drawer-step { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: var(--dr-gap-md); }
|
||||||
|
|
||||||
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
|
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
|
||||||
@@ -362,7 +426,7 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--color-surface-sunken);
|
background: var(--color-surface-sunken);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
color: var(--color-ink-4);
|
color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
.drawer-step-dot[data-done='true'] {
|
.drawer-step-dot[data-done='true'] {
|
||||||
@@ -375,7 +439,7 @@
|
|||||||
.drawer-step-dot[data-current='true'] {
|
.drawer-step-dot[data-current='true'] {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 2px solid var(--color-brand);
|
border: 2px solid var(--color-brand);
|
||||||
box-shadow: 0 0 0 4px var(--color-brand-tint);
|
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
.drawer-step-dot[data-tone='danger'] {
|
.drawer-step-dot[data-tone='danger'] {
|
||||||
background: color-mix(in oklab, var(--color-error) 14%, transparent);
|
background: color-mix(in oklab, var(--color-error) 14%, transparent);
|
||||||
@@ -384,24 +448,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Purple only as far as the journey has actually got. */
|
/* Purple only as far as the journey has actually got. */
|
||||||
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); }
|
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 18px; background: var(--dr-line); }
|
||||||
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
|
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
|
||||||
|
|
||||||
.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; }
|
.drawer-step-body {
|
||||||
|
padding-bottom: var(--dr-gap-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
|
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
|
||||||
.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
.drawer-step-label { font: 500 var(--dr-text-md)/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||||
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
|
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
|
||||||
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
|
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
|
||||||
.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; }
|
.drawer-step-at {
|
||||||
|
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||||
|
color: var(--color-ink-4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Route ──────────────────────────────────────────────────────────────── */
|
/* ── Route ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; }
|
.drawer-party {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
gap: var(--dr-gap-md);
|
||||||
|
padding: var(--dr-gap-md) 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-party-icon {
|
.drawer-party-icon {
|
||||||
width: 30px;
|
width: 28px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
border-radius: 9px;
|
border-radius: var(--dr-radius-sm);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -411,13 +489,21 @@
|
|||||||
|
|
||||||
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
.drawer-party-kind {
|
.drawer-party-kind {
|
||||||
font: 600 10.5px/1 var(--font-sans);
|
font: 600 var(--dr-text-xs)/1 var(--font-sans);
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.075em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-ink-4);
|
color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; }
|
.drawer-party-name {
|
||||||
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; }
|
font: 600 var(--dr-text-lg)/1.35 var(--font-sans);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.drawer-party-detail {
|
||||||
|
font: 400 var(--dr-text-md)/1.45 var(--font-sans);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
/* The phone reads as something you could act on, without pretending to be a
|
/* The phone reads as something you could act on, without pretending to be a
|
||||||
button — these drawers are opened on a desktop where tel: does nothing. */
|
button — these drawers are opened on a desktop where tel: does nothing. */
|
||||||
@@ -425,8 +511,8 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 4px;
|
margin-top: var(--dr-gap-xs);
|
||||||
font: 500 12.5px/1 var(--font-sans);
|
font: 500 var(--dr-text-md)/1 var(--font-sans);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -439,8 +525,8 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
color: var(--color-ink-4);
|
color: var(--color-ink-4);
|
||||||
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
border-top: 1px solid var(--dr-line-soft);
|
||||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
border-bottom: 1px solid var(--dr-line-soft);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--color-surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,49 +536,195 @@
|
|||||||
.drawer-note {
|
.drawer-note {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: var(--dr-gap-sm);
|
||||||
padding: 12px 14px;
|
padding: var(--dr-gap-md) 14px;
|
||||||
border-radius: 12px;
|
border-radius: var(--dr-radius);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
font: 400 13px/1.5 var(--font-sans);
|
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
|
||||||
color: var(--color-ink-3);
|
color: var(--color-ink-3);
|
||||||
}
|
}
|
||||||
.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; }
|
.drawer-note svg { color: var(--color-ink-4); margin-top: 2px; flex: none; }
|
||||||
|
.drawer-note strong { color: var(--color-ink-1); font-weight: 600; }
|
||||||
.drawer-note[data-tone='brand'] {
|
.drawer-note[data-tone='brand'] {
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
||||||
|
color: var(--color-ink-2);
|
||||||
}
|
}
|
||||||
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
|
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
|
||||||
|
|
||||||
|
/* ── Alert ──────────────────────────────────────────────────────────────── */
|
||||||
|
/* The loud sibling of the note: a save that failed, a precondition that is not
|
||||||
|
met. Three drawers each wrote their own version of this box; there is one. */
|
||||||
|
.drawer-alert {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--dr-gap-sm);
|
||||||
|
padding: var(--dr-gap-md) 14px;
|
||||||
|
border-radius: var(--dr-radius);
|
||||||
|
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
|
||||||
|
}
|
||||||
|
.drawer-alert svg { margin-top: 2px; flex: none; }
|
||||||
|
.drawer-alert[data-tone='error'] {
|
||||||
|
color: var(--color-error, #d64545);
|
||||||
|
background: color-mix(in oklab, var(--color-error, #d64545) 7%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-error, #d64545) 22%, transparent);
|
||||||
|
}
|
||||||
|
.drawer-alert[data-tone='warning'] {
|
||||||
|
color: var(--color-warning, #b7860b);
|
||||||
|
background: color-mix(in oklab, var(--color-warning, #b7860b) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-warning, #b7860b) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fields ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.drawer-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
|
||||||
|
.drawer-field-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
.drawer-field-req { color: var(--color-error, #d64545); }
|
||||||
|
|
||||||
|
.drawer-field-help {
|
||||||
|
font: 400 var(--dr-text-sm)/1.45 var(--font-sans);
|
||||||
|
color: var(--color-ink-4);
|
||||||
|
}
|
||||||
|
.drawer-field-error {
|
||||||
|
font: 500 var(--dr-text-sm)/1.45 var(--font-sans);
|
||||||
|
color: var(--color-error, #d64545);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two fields on a line, stacking rather than squeezing. 200px is a first name
|
||||||
|
and a last name side by side at 480px, and one above the other below it. */
|
||||||
|
.drawer-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: var(--dr-gap-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--dr-control);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--dr-radius-sm);
|
||||||
|
border: 1px solid var(--dr-line);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
font: 400 var(--dr-text-lg)/1 var(--font-sans);
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
/* Safari gives a text input its own inner shadow and a rounded-rect it will
|
||||||
|
not let go of; the whole point of this rule is that the field matches the
|
||||||
|
button beside it. */
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.drawer-input::placeholder { color: var(--color-ink-4); }
|
||||||
|
.drawer-input:hover:not(:disabled) { border-color: var(--color-ink-4); }
|
||||||
|
.drawer-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
.drawer-input[aria-invalid='true'] {
|
||||||
|
border-color: var(--color-error, #d64545);
|
||||||
|
}
|
||||||
|
.drawer-input[aria-invalid='true']:focus {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-error, #d64545) 14%, transparent);
|
||||||
|
}
|
||||||
|
.drawer-input:disabled {
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-ink-4);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A native select with the platform arrow suppressed and ours drawn on top, so
|
||||||
|
a picker and a text field are the same object at a glance. */
|
||||||
|
.drawer-select { position: relative; display: block; min-width: 0; }
|
||||||
|
.drawer-select select { padding-right: 34px; cursor: pointer; }
|
||||||
|
.drawer-select svg {
|
||||||
|
position: absolute;
|
||||||
|
right: 11px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--color-ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Choice cards ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.drawer-choices { display: flex; gap: var(--dr-gap-sm); }
|
||||||
|
|
||||||
|
.drawer-choice {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--dr-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--dr-line);
|
||||||
|
background: var(--color-surface);
|
||||||
|
transition: border-color 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
.drawer-choice:hover { border-color: var(--color-ink-4); }
|
||||||
|
.drawer-choice[data-selected='true'] {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
.drawer-choice:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.drawer-choice-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font: 600 var(--dr-text-md)/1.3 var(--font-sans);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
}
|
||||||
|
.drawer-choice[data-selected='true'] .drawer-choice-label { color: var(--color-brand); }
|
||||||
|
.drawer-choice-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font: 400 var(--dr-text-sm)/1.35 var(--font-sans);
|
||||||
|
color: var(--color-ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||||
/* One height, one radius, one type ramp for every button in every drawer. */
|
/* One height, one radius, one type ramp for every button in every drawer — and
|
||||||
|
the same height as an input, so a form does not step up and down its own
|
||||||
|
right-hand edge. */
|
||||||
.drawer-btn {
|
.drawer-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
height: 42px;
|
height: var(--dr-control);
|
||||||
padding: 0 18px;
|
padding: 0 14px;
|
||||||
border-radius: 11px;
|
border-radius: var(--dr-radius-sm);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font: 600 14px/1 var(--font-sans);
|
font: 600 var(--dr-text-md)/1 var(--font-sans);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.drawer-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.drawer-btn svg { flex: none; }
|
||||||
|
|
||||||
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
|
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
|
||||||
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
|
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
|
||||||
|
|
||||||
.drawer-btn[data-variant='secondary'] {
|
.drawer-btn[data-variant='secondary'] {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent);
|
border-color: var(--dr-line);
|
||||||
color: var(--color-brand);
|
color: var(--color-ink-1);
|
||||||
|
}
|
||||||
|
.drawer-btn[data-variant='secondary']:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
border-color: var(--color-ink-4);
|
||||||
}
|
}
|
||||||
.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); }
|
|
||||||
|
|
||||||
/* Outline, never filled: a destructive action should be reachable without
|
/* Outline, never filled: a destructive action should be reachable without
|
||||||
being the brightest thing in the drawer. */
|
being the brightest thing in the drawer. */
|
||||||
@@ -505,8 +737,11 @@
|
|||||||
background: color-mix(in oklab, var(--color-error) 8%, transparent);
|
background: color-mix(in oklab, var(--color-error) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); }
|
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-3); }
|
||||||
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
.drawer-btn[data-variant='ghost']:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
}
|
||||||
|
|
||||||
/* A text-weight action that lives inside a section, not in the footer —
|
/* A text-weight action that lives inside a section, not in the footer —
|
||||||
"Group with another size". */
|
"Group with another size". */
|
||||||
@@ -517,22 +752,38 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
font: 600 13px/1 var(--font-sans);
|
font: 600 var(--dr-text-md)/1 var(--font-sans);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.drawer-link-action:hover { text-decoration: underline; }
|
.drawer-link-action:hover { text-decoration: underline; }
|
||||||
|
.drawer-link-action:disabled { opacity: 0.45; cursor: not-allowed; text-decoration: none; }
|
||||||
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
|
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
|
||||||
|
|
||||||
|
/* ── Codes ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.drawer-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--dr-text-md);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* A PIN shown once, meant to be read across a desk and typed in. */
|
||||||
|
.drawer-mono[data-size='lg'] {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Image area ─────────────────────────────────────────────────────────── */
|
/* ── Image area ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.drawer-figure {
|
.drawer-figure {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 16px;
|
padding: var(--dr-gap-lg);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
border-radius: 12px;
|
border-radius: var(--dr-radius);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
|
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
|
||||||
will happily size to it and push the image straight out of the panel —
|
will happily size to it and push the image straight out of the panel —
|
||||||
which is exactly what happened. Nothing leaves this box. */
|
which is exactly what happened. Nothing leaves this box. */
|
||||||
@@ -557,13 +808,13 @@
|
|||||||
.drawer-figure[data-tall='true'] { height: 260px; }
|
.drawer-figure[data-tall='true'] { height: 260px; }
|
||||||
.drawer-figure[data-tall='true'] img { max-height: 228px; }
|
.drawer-figure[data-tall='true'] img { max-height: 228px; }
|
||||||
|
|
||||||
.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
|
.drawer-thumbs { display: flex; gap: var(--dr-gap-sm); flex-wrap: wrap; }
|
||||||
.drawer-thumb {
|
.drawer-thumb {
|
||||||
width: 54px;
|
width: 52px;
|
||||||
height: 54px;
|
height: 52px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 10px;
|
border-radius: var(--dr-radius-sm);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -579,32 +830,54 @@
|
|||||||
|
|
||||||
/* ── Highlight bullets ──────────────────────────────────────────────────── */
|
/* ── Highlight bullets ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
.drawer-bullets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--dr-gap-lg);
|
||||||
|
}
|
||||||
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
|
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
.drawer-bullet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
.drawer-bullet {
|
.drawer-bullet {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: var(--dr-gap-sm);
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
font: 400 13.5px/1.45 var(--font-sans);
|
font: 400 var(--dr-text-md)/1.5 var(--font-sans);
|
||||||
color: var(--color-ink-2);
|
color: var(--color-ink-2);
|
||||||
}
|
}
|
||||||
.drawer-bullet::before {
|
.drawer-bullet::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 5px;
|
width: 4px;
|
||||||
height: 5px;
|
height: 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Caption ────────────────────────────────────────────────────────────── */
|
||||||
|
/* An explanatory line inside a card — why a product was filed where it was,
|
||||||
|
what an import will and will not do. Quieter than prose and not a note: it
|
||||||
|
has no box of its own because it is already inside one. */
|
||||||
|
.drawer-caption {
|
||||||
|
font: 400 var(--dr-text-md)/1.55 var(--font-sans);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
}
|
||||||
|
.drawer-caption strong { color: var(--color-ink-1); font-weight: 600; }
|
||||||
|
|
||||||
/* ── Prose ──────────────────────────────────────────────────────────────── */
|
/* ── Prose ──────────────────────────────────────────────────────────────── */
|
||||||
/* 15px and 1.6 — a description is the one thing in a drawer that gets read
|
/* A description is the one thing in a drawer that gets read rather than
|
||||||
rather than scanned. */
|
scanned, so it is the one thing set above the body size. */
|
||||||
.drawer-prose {
|
.drawer-prose {
|
||||||
font: 400 14.5px/1.6 var(--font-sans);
|
margin: 0;
|
||||||
|
font: 400 var(--dr-text-lg)/1.6 var(--font-sans);
|
||||||
color: var(--color-ink-2);
|
color: var(--color-ink-2);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -612,35 +885,33 @@
|
|||||||
.drawer-prose p:last-child { margin-bottom: 0; }
|
.drawer-prose p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
/* ── Narrower screens ───────────────────────────────────────────────────── */
|
/* ── Narrower screens ───────────────────────────────────────────────────── */
|
||||||
/* The panel is already capped at 92vw; below that the padding is what has to
|
/* The panel is already capped at 92vw; below that the gutter is what has to
|
||||||
give, or a 520px layout inside a 360px panel just clips. */
|
give, or a 520px layout inside a 360px panel just clips. */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.drawer-head { padding: 16px 16px 14px; }
|
.drawer-root { --dr-pad-x: 16px; }
|
||||||
.drawer-body { padding: 16px; gap: 20px; }
|
|
||||||
.drawer-foot { padding: 12px 16px; }
|
|
||||||
.drawer-title { font-size: 19px; }
|
|
||||||
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.drawer-metrics[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.drawer-choices { flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Health score, inside the drawer system ─────────────────────────────── */
|
/* ── Health score, inside the drawer system ─────────────────────────────── */
|
||||||
/* The panel's own pieces — score chip, points, tags — already sit on the brand
|
/* The panel's own pieces — score chip, points, tags — already sit on the brand
|
||||||
palette and are kept. What is added here is the wrapper that makes it read as
|
palette and are kept. What is added here is the wrapper that makes it read as
|
||||||
one compact card rather than as a loose stack, which is what the redesign
|
one compact card rather than as a loose stack. */
|
||||||
asks of it: informative, and not competing with the product it describes. */
|
|
||||||
.health-body {
|
.health-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--dr-line);
|
||||||
border-radius: 12px;
|
border-radius: var(--dr-radius);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-per {
|
.health-per {
|
||||||
font: 400 12px/1.3 var(--font-sans);
|
font: 400 var(--dr-text-sm)/1.3 var(--font-sans);
|
||||||
color: var(--color-ink-4);
|
color: var(--color-ink-4);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); }
|
.health-caveat { font: 400 var(--dr-text-md)/1.55 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties, ReactNode } from 'react';
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
import { Info } from 'lucide-react';
|
import { AlertTriangle, Check, ChevronDown, Info } from 'lucide-react';
|
||||||
import './drawer.css';
|
import './drawer.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,10 +133,15 @@ export function Badge({ label, colour }: { label: string; colour: string }) {
|
|||||||
<span
|
<span
|
||||||
className="drawer-badge"
|
className="drawer-badge"
|
||||||
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
|
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
|
||||||
`background` directly, so the stylesheet can decide how to spend it.
|
`background` directly, so the stylesheet decides how to spend it — as
|
||||||
An inline colour would win over every rule, and the badge has to look
|
text, as an 11% fill and as a 24% border, from the one value.
|
||||||
different in the purple header than it does on a white card — see
|
|
||||||
`.drawer-head .drawer-badge`. */
|
It is now the same badge in the header as on a card. It was not while
|
||||||
|
the header was painted brand purple: a tinted chip is unreadable on a
|
||||||
|
saturated ground, so the header had its own white-chip-with-a-dot
|
||||||
|
variant, and the status beside a drawer's title did not match the chip
|
||||||
|
in the row it was opened from. Dropping the purple dropped the
|
||||||
|
variant. */
|
||||||
style={{ '--badge': colour } as CSSProperties}
|
style={{ '--badge': colour } as CSSProperties}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -227,3 +232,204 @@ export function Bullets({ items }: { items: readonly string[] }) {
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Form pieces ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Seven of the console's drawers are FORMS — add a rider, edit a person, issue
|
||||||
|
a till account, name a counter — and until now the kit had nothing for them.
|
||||||
|
So each one reached for Astryx's `TextInput`, `Selector` and `VStack`, laid
|
||||||
|
them out with its own inline styles, and wrote its own error box. That is why
|
||||||
|
the edit drawers and the detail drawers did not look like the same product:
|
||||||
|
different label case, different field gap, different control height, three
|
||||||
|
spellings of an error.
|
||||||
|
|
||||||
|
These are that missing half. They are deliberately plain — a label, a
|
||||||
|
control, a line of help, an error — and they carry no layout of their own
|
||||||
|
beyond the field: `Section` still supplies the grouping, the same as it does
|
||||||
|
for a read-only drawer. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labelled control.
|
||||||
|
*
|
||||||
|
* The label is a real `<label>` wrapping its control, so clicking it focuses
|
||||||
|
* the field and a screen reader names it — which the hand-rolled versions of
|
||||||
|
* this got wrong in three drawers by rendering the label as a `Text`.
|
||||||
|
*/
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
isRequired,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: ReactNode;
|
||||||
|
/** One line under the control — what it is for, or what it will do. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Shown in place of the description, in the error colour. */
|
||||||
|
error?: string | undefined;
|
||||||
|
isRequired?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="drawer-field">
|
||||||
|
<span className="drawer-field-label">
|
||||||
|
{label}
|
||||||
|
{isRequired ? (
|
||||||
|
<span className="drawer-field-req" aria-hidden>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<span className="drawer-field-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : description ? (
|
||||||
|
<span className="drawer-field-help">{description}</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two fields on one line, stacking on a narrow panel. */
|
||||||
|
export function FieldRow({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="drawer-field-row">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = 'text',
|
||||||
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: 'text' | 'email' | 'tel' | 'number';
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isInvalid?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="drawer-input"
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...(isInvalid ? { 'aria-invalid': true } : {})}
|
||||||
|
{...(placeholder ? { placeholder } : {})}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
isDisabled,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
options: readonly { value: string; label: string }[];
|
||||||
|
/** The empty first option. Absent when every choice is a real one. */
|
||||||
|
placeholder?: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="drawer-select">
|
||||||
|
<select
|
||||||
|
className="drawer-input"
|
||||||
|
value={value}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={15} aria-hidden />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something that went wrong, or something the reader must know before acting.
|
||||||
|
*
|
||||||
|
* Distinct from `Note`, which is quiet by design and explains an absence. This
|
||||||
|
* one is the save that failed and the precondition that is not met, and it is
|
||||||
|
* the piece three drawers each wrote for themselves.
|
||||||
|
*/
|
||||||
|
export function Alert({
|
||||||
|
tone = 'error',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone?: 'error' | 'warning';
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="drawer-alert" data-tone={tone} role="alert">
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One choice out of two or three, as cards rather than as a checkbox.
|
||||||
|
*
|
||||||
|
* Deactivating somebody is the closest thing this console has to a delete, so
|
||||||
|
* the consequence is written on the control itself instead of being left to a
|
||||||
|
* label the eye skips.
|
||||||
|
*/
|
||||||
|
export function ChoiceGroup({ label, children }: { label?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="drawer-field">
|
||||||
|
{label ? <span className="drawer-field-label">{label}</span> : null}
|
||||||
|
<div className="drawer-choices">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Choice({
|
||||||
|
label,
|
||||||
|
note,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-choice"
|
||||||
|
data-selected={isSelected}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span className="drawer-choice-label">
|
||||||
|
{isSelected ? <Check size={12} /> : null}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{note ? <span className="drawer-choice-note">{note}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A code — an id, a PIN, a batch — in the mono face. */
|
||||||
|
export function Mono({ children, isLarge }: { children: ReactNode; isLarge?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className="drawer-mono" {...(isLarge ? { 'data-size': 'lg' } : {})}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function CounterSalesDrawer({
|
|||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -265,7 +265,7 @@ export function CounterSalesDrawer({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function ConsolePage() {
|
|||||||
Loading branches…
|
Loading branches…
|
||||||
</Text>
|
</Text>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={3}>
|
<VStack gap={1} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
No branches yet
|
No branches yet
|
||||||
@@ -215,7 +215,7 @@ export function ConsolePage() {
|
|||||||
{rows.length > 1 ? (
|
{rows.length > 1 ? (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader title="Comparison" />
|
<SectionHeader title="Comparison" />
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<ComparisonTable rows={rows} />
|
<ComparisonTable rows={rows} />
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +233,7 @@ export function ConsolePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<TillTable rows={rows} />
|
<TillTable rows={rows} />
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +244,7 @@ export function ConsolePage() {
|
|||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader title="Needs you" />
|
<SectionHeader title="Needs you" />
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Nothing waiting
|
Nothing waiting
|
||||||
@@ -285,7 +285,7 @@ function BranchCard({ row }: { row: Row }) {
|
|||||||
const isActive = branch.status?.toLowerCase() === 'active';
|
const isActive = branch.status?.toLowerCase() === 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<HStack justify="between" align="start" gap={1}>
|
<HStack justify="between" align="start" gap={1}>
|
||||||
<VStack gap={0}>
|
<VStack gap={0}>
|
||||||
@@ -700,7 +700,7 @@ function AlertRow({ alert }: { alert: Alert }) {
|
|||||||
: 'var(--color-brand)';
|
: 'var(--color-brand)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||||
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
|
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { useDateScope } from '@/components/shell/DateScope';
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
import { Activity, AlertTriangle, IndianRupee, Monitor } from 'lucide-react';
|
||||||
|
import { KpiCard, type KpiTone } from '@/components/KpiCard';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { useCounterLabels } from '../counterLabels';
|
import { useCounterLabels } from '../counterLabels';
|
||||||
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
import {
|
||||||
|
byUrgency,
|
||||||
|
countsOf,
|
||||||
|
filterRows,
|
||||||
|
rowsOf,
|
||||||
|
COUNTER_STATE_LABEL,
|
||||||
|
COUNTER_STATE_TONE,
|
||||||
|
type CounterRow,
|
||||||
|
type CounterState,
|
||||||
|
} from '../counterRows';
|
||||||
import { branchLabel, count, money } from '../format';
|
import { branchLabel, count, money } from '../format';
|
||||||
import { shortAge } from '../posStatus';
|
import { shortAge } from '../posStatus';
|
||||||
import { useTerminalBoard } from '../useTerminalBoard';
|
import { useTerminalBoard } from '../useTerminalBoard';
|
||||||
@@ -33,19 +44,10 @@ import './counters.css';
|
|||||||
*/
|
*/
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
const STATE_LABEL: Record<CounterState, string> = {
|
/* The names and tones live with the rows now, in `counterRows`, so the drawer
|
||||||
active: 'Active',
|
opened from this table can chip a counter the same way the row does. */
|
||||||
attention: 'Attention',
|
const STATE_LABEL = COUNTER_STATE_LABEL;
|
||||||
never: 'Never used',
|
const STATE_TONE = COUNTER_STATE_TONE;
|
||||||
offline: 'Offline',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATE_TONE: Record<CounterState, string> = {
|
|
||||||
active: 'healthy',
|
|
||||||
attention: 'attention',
|
|
||||||
never: 'neutral',
|
|
||||||
offline: 'critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CountersPage() {
|
export function CountersPage() {
|
||||||
const { branches, scoped, isPinned } = useBranchScope();
|
const { branches, scoped, isPinned } = useBranchScope();
|
||||||
@@ -121,15 +123,13 @@ export function CountersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="counter-controls">
|
<div className="counter-controls">
|
||||||
<label className="search" style={{ flex: 1, minWidth: 240 }}>
|
<SearchInput
|
||||||
<Search size={14} aria-hidden />
|
label="Search counters"
|
||||||
<input
|
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(event) => { setTerm(event.target.value); setPage(1); }}
|
onChange={(next) => { setTerm(next); setPage(1); }}
|
||||||
placeholder="Search counters by code, name or branch…"
|
placeholder="Code, name or branch…"
|
||||||
aria-label="Search counters"
|
width={300}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
{showBranch ? (
|
{showBranch ? (
|
||||||
<label className="picker">
|
<label className="picker">
|
||||||
@@ -251,18 +251,31 @@ export function CountersPage() {
|
|||||||
|
|
||||||
/* ── Pieces ───────────────────────────────────────────────────────────────── */
|
/* ── Pieces ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This page's KPI tile was a THIRD copy of the same object, drawn on
|
||||||
|
* `.kpi` classes that no longer exist. It is the shared component now; only
|
||||||
|
* the tone words differ, and they are translated here rather than teaching the
|
||||||
|
* component a second vocabulary — this page says "healthy" and "attention",
|
||||||
|
* the component says "success" and "warning", and the counters board is the
|
||||||
|
* only screen using the first pair.
|
||||||
|
*/
|
||||||
|
const KPI_TONE: Record<string, KpiTone> = {
|
||||||
|
healthy: 'success',
|
||||||
|
attention: 'warning',
|
||||||
|
critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
function Kpi({
|
function Kpi({
|
||||||
icon, label, value, note, tone,
|
icon, label, value, note, tone,
|
||||||
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
|
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
|
||||||
return (
|
return (
|
||||||
<article className="kpi">
|
<KpiCard
|
||||||
<span className="kpi-icon" data-tone={tone} aria-hidden>{icon}</span>
|
icon={icon}
|
||||||
<div className="kpi-head">
|
label={label}
|
||||||
<span className="kpi-label">{label}</span>
|
value={value}
|
||||||
<span className="kpi-value">{value}</span>
|
note={note}
|
||||||
<span className="kpi-note">{note}</span>
|
{...(tone && KPI_TONE[tone] ? { tone: KPI_TONE[tone] } : {})}
|
||||||
</div>
|
/>
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { useSelection, type Selection } from '@/components/useSelection';
|
import { useSelection, type Selection } from '@/components/useSelection';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { AssignBar } from '../AssignBar';
|
import { AssignBar } from '../AssignBar';
|
||||||
@@ -461,7 +462,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
) : mode === 'riders' || mode === 'stores' ? (
|
) : mode === 'riders' || mode === 'stores' ? (
|
||||||
<Card padding={0} elevation="low" className="dispatch-card-elevated">
|
<Card padding={0} className="dispatch-card-elevated">
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
{/* No banner over the map.
|
{/* No banner over the map.
|
||||||
A title, a stop count, a payload total and a pulsing
|
A title, a stop count, a payload total and a pulsing
|
||||||
@@ -516,7 +517,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<span style={{ color: 'var(--color-ink-4)' }}>
|
<span style={{ color: 'var(--color-ink-4)' }}>
|
||||||
<MapPin size={28} />
|
<MapPin size={28} />
|
||||||
@@ -785,7 +786,7 @@ function EnhancedGroupDetail({
|
|||||||
{assignBar}
|
{assignBar}
|
||||||
|
|
||||||
{showMap ? (
|
{showMap ? (
|
||||||
<Card padding={0} elevation="low" className="dispatch-map-card">
|
<Card padding={0} className="dispatch-map-card">
|
||||||
<VStack gap={1.5} padding={2}>
|
<VStack gap={1.5} padding={2}>
|
||||||
<GroupMap stops={group.stops} groupName={group.name} locations={locations} />
|
<GroupMap stops={group.stops} groupName={group.name} locations={locations} />
|
||||||
|
|
||||||
@@ -836,7 +837,12 @@ function EnhancedGroupDetail({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Stops"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="stops"
|
||||||
|
footer={<TablePager paged={paged} label="stops" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="stops-table">
|
<table className="stops-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -921,8 +927,7 @@ function EnhancedGroupDetail({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="stops" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Button } from '@astryxdesign/core/Button';
|
|||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -24,10 +23,12 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { stockApi } from '@/api/stock';
|
import { stockApi } from '@/api/stock';
|
||||||
import type { StockRequest } from '@/api/types';
|
import type { StockRequest } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||||
import { useSelection } from '@/components/useSelection';
|
import { useSelection } from '@/components/useSelection';
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
type StepId,
|
type StepId,
|
||||||
} from '@/features/onboarding/onboardingState';
|
} from '@/features/onboarding/onboardingState';
|
||||||
import { useLocationProducts } from '@/queries/hooks';
|
import { useLocationProducts } from '@/queries/hooks';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { branchLabel, count, money } from '../format';
|
import { branchLabel, count, money } from '../format';
|
||||||
@@ -155,33 +157,33 @@ export function InventoryPage() {
|
|||||||
giving it a row of its own below. It only appears on that tab —
|
giving it a row of its own below. It only appears on that tab —
|
||||||
an action for a panel you are not looking at is a misfire waiting
|
an action for a panel you are not looking at is a misfire waiting
|
||||||
to happen. */
|
to happen. */
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<TabBar>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Products"
|
label="Products"
|
||||||
icon={<PackageSearch size={14} />}
|
icon={<PackageSearch size={14} />}
|
||||||
isActive={tab === 'products'}
|
isActive={tab === 'products'}
|
||||||
onClick={() => chooseTab('products')}
|
onClick={() => chooseTab('products')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Catalogue"
|
label="Catalogue"
|
||||||
icon={<DownloadCloud size={14} />}
|
icon={<DownloadCloud size={14} />}
|
||||||
isActive={tab === 'catalogue'}
|
isActive={tab === 'catalogue'}
|
||||||
onClick={() => chooseTab('catalogue')}
|
onClick={() => chooseTab('catalogue')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Stock requests"
|
label="Stock requests"
|
||||||
icon={<ClipboardList size={14} />}
|
icon={<ClipboardList size={14} />}
|
||||||
isActive={tab === 'requests'}
|
isActive={tab === 'requests'}
|
||||||
onClick={() => chooseTab('requests')}
|
onClick={() => chooseTab('requests')}
|
||||||
badge={pending.length || undefined}
|
count={pending.length || undefined}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Stock"
|
label="Stock"
|
||||||
icon={<Layers size={14} />}
|
icon={<Layers size={14} />}
|
||||||
isActive={tab === 'stock'}
|
isActive={tab === 'stock'}
|
||||||
onClick={() => chooseTab('stock')}
|
onClick={() => chooseTab('stock')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -443,17 +445,14 @@ function RequestsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search box */}
|
{/* Search box */}
|
||||||
<div style={{ width: 240, minWidth: 180 }}>
|
<SearchInput
|
||||||
<TextInput
|
|
||||||
label="Search requests"
|
label="Search requests"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search product or branch…"
|
placeholder="Product or branch…"
|
||||||
|
width={240}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Bulk Action Bar ──────────────────────────────────────────────── */}
|
{/* ── Bulk Action Bar ──────────────────────────────────────────────── */}
|
||||||
{selection.count > 0 ? (
|
{selection.count > 0 ? (
|
||||||
@@ -554,7 +553,7 @@ function RequestsPanel({
|
|||||||
|
|
||||||
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading stock requests…
|
Reading stock requests…
|
||||||
@@ -562,7 +561,7 @@ function RequestsPanel({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}>
|
<VStack gap={0.5} padding={4} align="center" style={{ textAlign: 'center' }}>
|
||||||
<PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} />
|
<PackageSearch size={32} style={{ color: 'var(--color-ink-4)', marginBottom: 4 }} />
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
@@ -578,7 +577,12 @@ function RequestsPanel({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Stock requests"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="stock requests"
|
||||||
|
footer={<TablePager paged={paged} label="stock requests" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -747,8 +751,7 @@ function RequestsPanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="stock requests" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Guidance Note ──────────────────────────────────────────────── */}
|
{/* ── Guidance Note ──────────────────────────────────────────────── */}
|
||||||
@@ -961,22 +964,18 @@ function StockPanel() {
|
|||||||
</select>
|
</select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ width: 220, minWidth: 160 }}>
|
<SearchInput
|
||||||
<TextInput
|
|
||||||
label="Search stock"
|
label="Search stock"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search products…"
|
placeholder="Search products…"
|
||||||
hasClear
|
width={220}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
{/* ── Table Card ──────────────────────────────────────────────────── */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
{statement.isLoading ? (
|
{statement.isLoading ? (
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
@@ -1220,63 +1219,7 @@ function BarAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({
|
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
badge?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 18,
|
|
||||||
height: 18,
|
|
||||||
padding: '0 5px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-warning, #b7860b)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Th({
|
function Th({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function OnboardBranchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card padding={4} elevation="low">
|
<Card padding={4}>
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack align="center" gap={1.5}>
|
<HStack align="center" gap={1.5}>
|
||||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||||
@@ -263,7 +263,7 @@ export function OnboardBranchPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{mutation.data?.locationid ? (
|
{mutation.data?.locationid ? (
|
||||||
<Card padding={3} elevation="low" variant="transparent">
|
<Card padding={3} variant="transparent">
|
||||||
<StoreQrPanel
|
<StoreQrPanel
|
||||||
tenantid={Number(form.tenantid || defaultTenantId)}
|
tenantid={Number(form.tenantid || defaultTenantId)}
|
||||||
locationid={mutation.data.locationid}
|
locationid={mutation.data.locationid}
|
||||||
@@ -297,7 +297,7 @@ export function OnboardBranchPage() {
|
|||||||
<h1 className="sr-only">Onboard Branch</h1>
|
<h1 className="sr-only">Onboard Branch</h1>
|
||||||
|
|
||||||
{/* Section 1: Business Details & Identity */}
|
{/* Section 1: Business Details & Identity */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -368,7 +368,7 @@ export function OnboardBranchPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 2: Location */}
|
{/* Section 2: Location */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -428,7 +428,7 @@ export function OnboardBranchPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: Operating Hours & Delivery */}
|
{/* Section 3: Operating Hours & Delivery */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
||||||
import { branchLabel, count, money, percent, share } from '../format';
|
import { branchLabel, count, money, percent, share } from '../format';
|
||||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -108,26 +110,26 @@ export function ReportsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<TabBar>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Revenue"
|
label="Revenue"
|
||||||
icon={<IndianRupee size={14} />}
|
icon={<IndianRupee size={14} />}
|
||||||
isActive={tab === 'revenue'}
|
isActive={tab === 'revenue'}
|
||||||
onClick={() => setTab('revenue')}
|
onClick={() => setTab('revenue')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Sales"
|
label="Sales"
|
||||||
icon={<BarChart3 size={14} />}
|
icon={<BarChart3 size={14} />}
|
||||||
isActive={tab === 'sales'}
|
isActive={tab === 'sales'}
|
||||||
onClick={() => setTab('sales')}
|
onClick={() => setTab('sales')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Inventory"
|
label="Inventory"
|
||||||
icon={<Boxes size={14} />}
|
icon={<Boxes size={14} />}
|
||||||
isActive={tab === 'inventory'}
|
isActive={tab === 'inventory'}
|
||||||
onClick={() => setTab('inventory')}
|
onClick={() => setTab('inventory')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -167,9 +169,14 @@ export function ReportsPage() {
|
|||||||
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
||||||
|
|
||||||
{rows.length > 1 ? (
|
{rows.length > 1 ? (
|
||||||
<VStack gap={1.5}>
|
/* No SectionHeader above it any more — the panel carries its own
|
||||||
<SectionHeader title="By branch" />
|
title and count, and the two stacked said "By branch" twice. */
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="By branch"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="branches"
|
||||||
|
footer={<TablePager paged={paged} label="branches" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -206,9 +213,7 @@ export function ReportsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="branches" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
) : null}
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
) : tab === 'sales' ? (
|
) : tab === 'sales' ? (
|
||||||
@@ -273,7 +278,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
|||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader title="App versus counter" />
|
<SectionHeader title="App versus counter" />
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||||
<Bar
|
<Bar
|
||||||
@@ -357,7 +362,7 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
|
|||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader title="Counter takings by day" />
|
<SectionHeader title="Counter takings by day" />
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||||
<div style={{ minWidth: 400, height: '100%' }}>
|
<div style={{ minWidth: 400, height: '100%' }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -460,7 +465,7 @@ function MoverList({
|
|||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader title={title} />
|
<SectionHeader title={title} />
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
@@ -500,43 +505,7 @@ function MoverList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({
|
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Th({ children }: { children: React.ReactNode; }) {
|
function Th({ children }: { children: React.ReactNode; }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
|||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
|
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
|
||||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import {
|
import {
|
||||||
useDeliveries,
|
useDeliveries,
|
||||||
useOrders,
|
useOrders,
|
||||||
@@ -37,6 +38,7 @@ import { shortAge } from '../posStatus';
|
|||||||
import { AssignBar } from '../AssignBar';
|
import { AssignBar } from '../AssignBar';
|
||||||
import { assignability, assignedFrom } from '../assignDelivery';
|
import { assignability, assignedFrom } from '../assignDelivery';
|
||||||
import { useSelection } from '@/components/useSelection';
|
import { useSelection } from '@/components/useSelection';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { isCounterSale } from '../branchStats';
|
import { isCounterSale } from '../branchStats';
|
||||||
@@ -44,6 +46,21 @@ import './deliveries.css';
|
|||||||
|
|
||||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the one search box matches, per view.
|
||||||
|
*
|
||||||
|
* The page used to draw three separate boxes, one inside each view, and the
|
||||||
|
* only thing that differed between them was this line — the value behind them
|
||||||
|
* was already a single `keyword`, filtering orders and deliveries through the
|
||||||
|
* query and counter bills client-side. So the boxes were three renderings of
|
||||||
|
* one control, and the placeholder is what survives of them.
|
||||||
|
*/
|
||||||
|
const SEARCH_HINT: Record<Tab, string> = {
|
||||||
|
orders: 'Order ID, customer…',
|
||||||
|
deliveries: 'Order ID, rider…',
|
||||||
|
counter: 'Invoice, till, cashier…',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bill number a counter sale was imported under.
|
* The bill number a counter sale was imported under.
|
||||||
*
|
*
|
||||||
@@ -287,43 +304,58 @@ export function SalesPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Sales"
|
title="Sales"
|
||||||
tabs={
|
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
|
||||||
<HStack gap={0.5} wrap="wrap">
|
isTabsInline
|
||||||
<TabButton
|
actions={
|
||||||
label="Online Orders"
|
<SearchInput
|
||||||
icon={<ShoppingCart size={14} />}
|
label="Search sales"
|
||||||
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
value={keyword}
|
||||||
isActive={tab !== 'counter'}
|
onChange={setKeyword}
|
||||||
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
|
placeholder={SEARCH_HINT[tab]}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
tabs={
|
||||||
|
/* THREE views, not four.
|
||||||
|
|
||||||
{tab !== 'counter' ? (
|
There used to be an "Online Orders" tab in front of these carrying
|
||||||
<>
|
the combined count — 86 beside Orders 85 and Deliveries 1. It was
|
||||||
<TabButton
|
never a view of its own: it reported active whenever the page was
|
||||||
|
NOT on Counter sales, and clicking it from either of the two tabs
|
||||||
|
it contained did nothing at all. In the old flat pills that passed
|
||||||
|
unnoticed. In a segmented control it does not: a segmented control
|
||||||
|
means "one of these", and Online Orders and Orders were both drawn
|
||||||
|
selected at the same time, which is simply a broken control.
|
||||||
|
|
||||||
|
Nothing is lost by dropping it. Its count was the sum of the two
|
||||||
|
beside it, and the combined figure is already on the KPI row above.
|
||||||
|
|
||||||
|
Counter sales keeps the divider: Orders and Deliveries are two
|
||||||
|
halves of the app's own trade and the till is a different channel,
|
||||||
|
which is the grouping "Online Orders" was there to express. */
|
||||||
|
<TabBar aria-label="Sales views">
|
||||||
|
<Tab
|
||||||
label="Orders"
|
label="Orders"
|
||||||
icon={<ShoppingCart size={14} />}
|
icon={<ShoppingCart size={14} />}
|
||||||
badge={allOrders.length || undefined}
|
count={allOrders.length || undefined}
|
||||||
isActive={tab === 'orders'}
|
isActive={tab === 'orders'}
|
||||||
onClick={() => setTab('orders')}
|
onClick={() => setTab('orders')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Deliveries"
|
label="Deliveries"
|
||||||
icon={<Truck size={14} />}
|
icon={<Truck size={14} />}
|
||||||
badge={allDeliveries.length || undefined}
|
count={allDeliveries.length || undefined}
|
||||||
isActive={tab === 'deliveries'}
|
isActive={tab === 'deliveries'}
|
||||||
onClick={() => setTab('deliveries')}
|
onClick={() => setTab('deliveries')}
|
||||||
/>
|
/>
|
||||||
</>
|
<Tab
|
||||||
) : null}
|
|
||||||
|
|
||||||
<TabButton
|
|
||||||
label="Counter sales"
|
label="Counter sales"
|
||||||
icon={<Receipt size={14} />}
|
icon={<Receipt size={14} />}
|
||||||
badge={billRows.length || undefined}
|
count={billRows.length || undefined}
|
||||||
isActive={tab === 'counter'}
|
isActive={tab === 'counter'}
|
||||||
onClick={() => setTab('counter')}
|
onClick={() => setTab('counter')}
|
||||||
|
isGroupStart
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -367,9 +399,6 @@ export function SalesPage() {
|
|||||||
status={status}
|
status={status}
|
||||||
counts={tabCounts}
|
counts={tabCounts}
|
||||||
onStatus={setStatus}
|
onStatus={setStatus}
|
||||||
search={
|
|
||||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{/* Above the table, not below it and not in a drawer.
|
{/* Above the table, not below it and not in a drawer.
|
||||||
Assigning is something you do WHILE reading the day's orders —
|
Assigning is something you do WHILE reading the day's orders —
|
||||||
@@ -451,9 +480,6 @@ export function SalesPage() {
|
|||||||
status={status}
|
status={status}
|
||||||
counts={tabCounts}
|
counts={tabCounts}
|
||||||
onStatus={setStatus}
|
onStatus={setStatus}
|
||||||
search={
|
|
||||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<DeliveriesTable
|
<DeliveriesTable
|
||||||
rows={deliveryRows}
|
rows={deliveryRows}
|
||||||
@@ -514,11 +540,9 @@ export function SalesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
{/* No status strip here: a counter bill is rung and done, so it
|
{/* No status strip and no search row: a counter bill is rung and
|
||||||
has no lifecycle to filter by. The search keeps the row. */}
|
done, so it has no lifecycle to filter by, and the search is in
|
||||||
<HStack justify="end" wrap="wrap">
|
the page header with the tabs — one box for all three views. */}
|
||||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
|
||||||
</HStack>
|
|
||||||
<BillsTable
|
<BillsTable
|
||||||
rows={billRows}
|
rows={billRows}
|
||||||
queryKey={queryKey}
|
queryKey={queryKey}
|
||||||
@@ -580,35 +604,46 @@ function freshRow(
|
|||||||
* in view, the filters decide what is in view, and the table shows it. Reading
|
* in view, the filters decide what is in view, and the table shows it. Reading
|
||||||
* downward now follows that order.
|
* downward now follows that order.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The status ladder, under the view tabs.
|
||||||
|
*
|
||||||
|
* The SECONDARY size of the same control the views use — no track, smaller
|
||||||
|
* type, a brand tint on the selected one. That subordination is the point: two
|
||||||
|
* strips of tabs at equal weight on one screen read as two equally important
|
||||||
|
* choices, when this one only narrows whatever the strip above it selected.
|
||||||
|
*
|
||||||
|
* It used to be a private `StatusTab` — a 30px chip with a 12px radius, a
|
||||||
|
* hairline when idle and a solid brand fill when selected — which was a fourth
|
||||||
|
* treatment of "tab" on a page that now has one.
|
||||||
|
*
|
||||||
|
* The wrapper it sat in is gone with it. It was an `HStack justify="between"`
|
||||||
|
* holding the tabs and the search; the search moved to the page header, so the
|
||||||
|
* row was a space-between with nothing on the right of it.
|
||||||
|
*/
|
||||||
function FilterRow({
|
function FilterRow({
|
||||||
tabs,
|
tabs,
|
||||||
status,
|
status,
|
||||||
counts,
|
counts,
|
||||||
onStatus,
|
onStatus,
|
||||||
search,
|
|
||||||
}: {
|
}: {
|
||||||
/** Which ladder to show — orders and deliveries do not share one. */
|
/** Which ladder to show — orders and deliveries do not share one. */
|
||||||
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
|
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
|
||||||
status: StatusKey;
|
status: StatusKey;
|
||||||
counts: Record<StatusKey, number>;
|
counts: Record<StatusKey, number>;
|
||||||
onStatus: (key: StatusKey) => void;
|
onStatus: (key: StatusKey) => void;
|
||||||
search: React.ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
<TabBar size="sm" aria-label="Filter by status">
|
||||||
<HStack gap={0.5} wrap="wrap">
|
|
||||||
{tabs.map((entry) => (
|
{tabs.map((entry) => (
|
||||||
<StatusTab
|
<Tab
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
label={entry.label}
|
label={entry.label}
|
||||||
n={counts[entry.key]}
|
count={counts[entry.key]}
|
||||||
isActive={status === entry.key}
|
isActive={status === entry.key}
|
||||||
onClick={() => onStatus(entry.key)}
|
onClick={() => onStatus(entry.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</TabBar>
|
||||||
{search}
|
|
||||||
</HStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +684,12 @@ function OrdersTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Orders"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="orders"
|
||||||
|
footer={<TablePager paged={paged} label="orders" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -773,8 +813,7 @@ function OrdersTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="orders" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,7 +846,12 @@ function DeliveriesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Deliveries"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="deliveries"
|
||||||
|
footer={<TablePager paged={paged} label="deliveries" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -948,8 +992,7 @@ function DeliveriesTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="deliveries" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1023,12 @@ function BillsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Counter sales"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="bills"
|
||||||
|
footer={<TablePager paged={paged} label="bills" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1062,8 +1110,7 @@ function BillsTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="bills" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function Search({
|
/* The page-local search box lived here. It is `components/SearchInput`
|
||||||
value,
|
now, in the header beside the tabs, and there is one of it. */
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (next: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div style={{ width: 240 }}>
|
|
||||||
<TextInput
|
|
||||||
label="Search"
|
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
hasClear
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A status chip in the lifecycle's own colour. */
|
/** A status chip in the lifecycle's own colour. */
|
||||||
function Chip({ label, color }: { label: string | undefined; color: string }) {
|
function Chip({ label, color }: { label: string | undefined; color: string }) {
|
||||||
@@ -1222,117 +1248,15 @@ function TotalsBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusTab({
|
/* The page-local `StatusTab` lived here — it is the `sm` size of
|
||||||
label,
|
`components/TabBar` now. */
|
||||||
n,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
n: number;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 30,
|
|
||||||
padding: '0 11px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
|
|
||||||
// Transparent when idle, not white. A row of white chips on a tinted
|
|
||||||
// page reads as six raised slabs; only the selected one should carry a
|
|
||||||
// fill, and the hairline is enough to keep the rest legible as chips.
|
|
||||||
background: isActive ? 'var(--color-brand)' : 'transparent',
|
|
||||||
color: isActive ? '#fff' : 'var(--color-ink-2)',
|
|
||||||
fontSize: 12.5,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s, border-color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
fontWeight: 700,
|
|
||||||
opacity: isActive ? 0.85 : 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
/* The page-local `TabButton` lived here. It is `components/TabBar` now —
|
||||||
label,
|
one control for the six pages that had each grown their own. */
|
||||||
icon,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
badge?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 18,
|
|
||||||
height: 18,
|
|
||||||
padding: '0 5px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
|
||||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Loading({ what }: { what: string }) {
|
function Loading({ what }: { what: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading {what}…
|
Reading {what}…
|
||||||
@@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) {
|
|||||||
|
|
||||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
|||||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||||
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -198,13 +199,14 @@ export function TerminalsPage() {
|
|||||||
*
|
*
|
||||||
* A hairline and a radius, no fill. Every block here used to be a white
|
* A hairline and a radius, no fill. Every block here used to be a white
|
||||||
* `elevation="low"` card, which on a tinted page reads as a stack of raised
|
* `elevation="low"` card, which on a tinted page reads as a stack of raised
|
||||||
* slabs — and the console has been moving away from that everywhere else.
|
* slabs — this page went flat first, and the rest of the console has since
|
||||||
* Defined once so the edges cannot drift apart the next time one of these is
|
* followed: the card tokens in `index.css` are now that treatment, applied
|
||||||
* edited.
|
* everywhere. What is left here is the shared card minus the fill, so the
|
||||||
|
* page's own tint reads through.
|
||||||
*/
|
*/
|
||||||
const panelStyle: React.CSSProperties = {
|
const panelStyle: React.CSSProperties = {
|
||||||
borderRadius: 14,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||||
@@ -662,8 +664,12 @@ function FineTable({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card padding={0} variant="transparent">
|
<Panel
|
||||||
<div style={{ ...panelStyle, overflow: 'hidden' }}>
|
title="All terminals"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="terminals"
|
||||||
|
footer={<TablePager paged={paged} label="terminals" />}
|
||||||
|
>
|
||||||
<Table<FineRowData>
|
<Table<FineRowData>
|
||||||
data={paged.rows}
|
data={paged.rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -672,9 +678,7 @@ function FineTable({
|
|||||||
hasHover
|
hasHover
|
||||||
dividers="rows"
|
dividers="rows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
<TablePager paged={paged} label="terminals" />
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { Card } from '@astryxdesign/core/Card';
|
|||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import type { RiderRosterRow } from '@/api/deliveries';
|
import type { RiderRosterRow } from '@/api/deliveries';
|
||||||
import type { PosUser, StaffInfo } from '@/api/types';
|
import type { PosUser, StaffInfo } from '@/api/types';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import {
|
import {
|
||||||
usePosRoles,
|
usePosRoles,
|
||||||
@@ -23,6 +24,7 @@ import { branchLabel } from '../format';
|
|||||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||||
import { RiderDrawer } from '../RiderDrawer';
|
import { RiderDrawer } from '../RiderDrawer';
|
||||||
import { isUnplaced } from '../staffPlacement';
|
import { isUnplaced } from '../staffPlacement';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -165,21 +167,21 @@ export function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<TabBar>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Store users"
|
label="Store users"
|
||||||
icon={<UserCog size={14} />}
|
icon={<UserCog size={14} />}
|
||||||
badge={staff.data?.length || undefined}
|
count={staff.data?.length || undefined}
|
||||||
isActive={group === 'store'}
|
isActive={group === 'store'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup('store');
|
setGroup('store');
|
||||||
setRoleFilter('');
|
setRoleFilter('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Terminal accounts"
|
label="Terminal accounts"
|
||||||
icon={<Monitor size={14} />}
|
icon={<Monitor size={14} />}
|
||||||
badge={tillRows.length || undefined}
|
count={tillRows.length || undefined}
|
||||||
isActive={group === 'till'}
|
isActive={group === 'till'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup('till');
|
setGroup('till');
|
||||||
@@ -191,17 +193,17 @@ export function UsersPage() {
|
|||||||
and not a till — so listing them together would invite an edit
|
and not a till — so listing them together would invite an edit
|
||||||
that moves someone between logins that do not exist for each
|
that moves someone between logins that do not exist for each
|
||||||
other. */}
|
other. */}
|
||||||
<TabButton
|
<Tab
|
||||||
label="Riders"
|
label="Riders"
|
||||||
icon={<Bike size={14} />}
|
icon={<Bike size={14} />}
|
||||||
badge={riderRows.length || undefined}
|
count={riderRows.length || undefined}
|
||||||
isActive={group === 'rider'}
|
isActive={group === 'rider'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup('rider');
|
setGroup('rider');
|
||||||
setRoleFilter('');
|
setRoleFilter('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -226,17 +228,13 @@ export function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ width: 220 }}>
|
<SearchInput
|
||||||
<TextInput
|
|
||||||
label="Search people"
|
label="Search people"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Name, email, mobile…"
|
placeholder="Name, email, mobile…"
|
||||||
hasClear
|
width={220}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -279,6 +277,9 @@ export function UsersPage() {
|
|||||||
row={editingTill.row === 'new' ? null : editingTill.row}
|
row={editingTill.row === 'new' ? null : editingTill.row}
|
||||||
tenantid={tenantid}
|
tenantid={tenantid}
|
||||||
locationid={editingTill.locationid}
|
locationid={editingTill.locationid}
|
||||||
|
branchName={
|
||||||
|
branches.find((entry) => entry.locationid === editingTill.locationid)?.locationname ?? ''
|
||||||
|
}
|
||||||
roles={roles.data ?? []}
|
roles={roles.data ?? []}
|
||||||
shifts={shifts.data ?? []}
|
shifts={shifts.data ?? []}
|
||||||
onClose={() => setEditingTill(null)}
|
onClose={() => setEditingTill(null)}
|
||||||
@@ -325,7 +326,7 @@ function RiderTable({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading riders…
|
Reading riders…
|
||||||
@@ -337,7 +338,7 @@ function RiderTable({
|
|||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<span style={{ color: 'var(--color-ink-4)' }}>
|
<span style={{ color: 'var(--color-ink-4)' }}>
|
||||||
<Bike size={22} />
|
<Bike size={22} />
|
||||||
@@ -354,7 +355,12 @@ function RiderTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Riders"
|
||||||
|
count={rows.length}
|
||||||
|
countLabel="riders"
|
||||||
|
footer={<TablePager paged={paged} label="riders" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -411,8 +417,7 @@ function RiderTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="riders" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +450,12 @@ function StaffTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Store users"
|
||||||
|
count={rows.length}
|
||||||
|
countLabel="people"
|
||||||
|
footer={<TablePager paged={paged} label="people" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -504,8 +514,7 @@ function StaffTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="people" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,7 +543,12 @@ function TillTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Terminal accounts"
|
||||||
|
count={rows.length}
|
||||||
|
countLabel="terminal accounts"
|
||||||
|
footer={<TablePager paged={paged} label="terminal accounts" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -598,8 +612,7 @@ function TillTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="terminal accounts" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,68 +677,11 @@ function StatusChip({ status }: { status: string | undefined }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({
|
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
badge?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 18,
|
|
||||||
height: 18,
|
|
||||||
padding: '0 5px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
|
||||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Loading({ what }: { what: string }) {
|
function Loading({ what }: { what: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack padding={3}>
|
<VStack padding={3}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
Reading {what}…
|
Reading {what}…
|
||||||
@@ -737,7 +693,7 @@ function Loading({ what }: { what: string }) {
|
|||||||
|
|
||||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={1} padding={4} align="center">
|
<VStack gap={1} padding={4} align="center">
|
||||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--color-brand);
|
border: 1px solid var(--color-brand);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@
|
|||||||
.size-list {
|
.size-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.size-row {
|
.size-row {
|
||||||
@@ -139,9 +139,9 @@
|
|||||||
|
|
||||||
.size-add {
|
.size-add {
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Health score ───────────────────────────────────────────────────────── */
|
/* ── Health score ───────────────────────────────────────────────────────── */
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 5px 11px;
|
padding: 5px 11px;
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
font: 700 22px/1 var(--font-sans);
|
font: 700 22px/1 var(--font-sans);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
border: 1px solid #f2d9a8;
|
border: 1px solid #f2d9a8;
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
background: #fdf6e8;
|
background: #fdf6e8;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
font: 500 12.5px/1.4 var(--font-sans);
|
font: 500 12.5px/1.4 var(--font-sans);
|
||||||
@@ -204,8 +204,8 @@
|
|||||||
.health-allergens svg { flex: none; }
|
.health-allergens svg { flex: none; }
|
||||||
|
|
||||||
.health-facts {
|
.health-facts {
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.health-facts > div {
|
.health-facts > div {
|
||||||
@@ -259,8 +259,8 @@
|
|||||||
.stop-list {
|
.stop-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.stop-row {
|
.stop-row {
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid #f2d9a8;
|
border: 1px solid #f2d9a8;
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
background: #fdf6e8;
|
background: #fdf6e8;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,40 @@
|
|||||||
Curated palette with deep purple/indigo accents (#662582), glassmorphic elements,
|
Curated palette with deep purple/indigo accents (#662582), glassmorphic elements,
|
||||||
live telemetry cards and interactive map timelines. */
|
live telemetry cards and interactive map timelines. */
|
||||||
|
|
||||||
|
/* ── Dispatch's palette is now the console's ────────────────────────────────
|
||||||
|
|
||||||
|
These fourteen names are kept, and every one of them is an ALIAS. About
|
||||||
|
fifty rules in this file reference them, so pointing the names at the shared
|
||||||
|
tokens restyles the whole page at once — and, more to the point, makes it
|
||||||
|
impossible for Dispatch to drift away from the rest of the console again.
|
||||||
|
|
||||||
|
It had drifted. This file arrived as a port of another product and brought
|
||||||
|
that product's palette with it: `#e2e8f0` for a hairline where the console
|
||||||
|
uses `#e0e4ea`, `#1e293b` for body text where the console uses `#0f172a`,
|
||||||
|
`#f8fafc` for a sunken fill against the console's `#fafbfc`. Each is a
|
||||||
|
shade off the real token — close enough to look like a rendering
|
||||||
|
inconsistency rather than a decision, and far enough that a Dispatch card
|
||||||
|
beside a Console card were visibly two different whites.
|
||||||
|
|
||||||
|
The two shadow steps are now the card's, which is to say nothing at all.
|
||||||
|
`--dispatch-shadow-lg` is referenced by no rule in this file and is gone
|
||||||
|
rather than aliased. */
|
||||||
:root {
|
:root {
|
||||||
--dispatch-bg: #ffffff;
|
--dispatch-bg: var(--color-surface);
|
||||||
--dispatch-bg-sub: #f8fafc;
|
--dispatch-bg-sub: var(--color-surface-subtle);
|
||||||
--dispatch-card-bg: #ffffff;
|
--dispatch-card-bg: var(--card-bg);
|
||||||
--dispatch-border: #e2e8f0;
|
--dispatch-border: var(--color-line);
|
||||||
--dispatch-border-active: #662582;
|
--dispatch-border-active: var(--color-brand);
|
||||||
--dispatch-text: #1e293b;
|
--dispatch-text: var(--color-ink-1);
|
||||||
--dispatch-text-muted: #64748b;
|
--dispatch-text-muted: var(--color-ink-3);
|
||||||
--dispatch-accent: #662582;
|
--dispatch-accent: var(--color-brand);
|
||||||
--dispatch-accent-light: #9255ab;
|
--dispatch-accent-light: #9255ab;
|
||||||
--dispatch-accent-soft: rgba(102, 37, 130, 0.08);
|
--dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent);
|
||||||
--dispatch-success: #10b981;
|
--dispatch-success: var(--color-success, #1f9d55);
|
||||||
--dispatch-warning: #f59e0b;
|
--dispatch-warning: var(--color-warning, #b7860b);
|
||||||
--dispatch-error: #ef4444;
|
--dispatch-error: var(--color-error, #d64545);
|
||||||
--dispatch-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06);
|
--dispatch-shadow-sm: var(--card-shadow);
|
||||||
--dispatch-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08);
|
--dispatch-shadow-md: var(--card-shadow);
|
||||||
--dispatch-shadow-lg: 0 12px 30px rgba(102, 37, 130, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container {
|
.dispatch-container {
|
||||||
@@ -30,221 +47,28 @@
|
|||||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── */
|
/* ── Top Operations Header (xpressconsole #hdr) ─────────────────────────────
|
||||||
.dispatch-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
min-height: 44px;
|
|
||||||
background: var(--dispatch-card-bg);
|
|
||||||
border: 1px solid var(--dispatch-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: var(--dispatch-shadow-sm);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-section {
|
GONE, because it was never rendered.
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-badge {
|
This file arrived as a port of another product, and it brought that
|
||||||
width: 28px;
|
product's whole operations header with it: `.dispatch-header`, a logo lockup,
|
||||||
height: 28px;
|
a city picker, a demo/live toggle, a live-GPS chip, a running clock, and
|
||||||
border-radius: 8px;
|
`.date-chip` — a pill with prev/next arrows for stepping through days.
|
||||||
background: linear-gradient(135deg, #662582, #9255ab);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 2px 6px rgba(102, 37, 130, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-name {
|
Not one of those class names appears in any markup in this repository. The
|
||||||
font-size: 15px;
|
Dispatch page reads the date from `useDateScope()`, which is the range
|
||||||
font-weight: 800;
|
picker in the app's own top bar; two comments in `DispatchPage.tsx` already
|
||||||
color: var(--dispatch-text);
|
tell the reader to "change the date range in the top bar". So the header was
|
||||||
letter-spacing: -0.02em;
|
215 lines of CSS for a component that does not exist, and `.date-chip` was a
|
||||||
}
|
date filter nobody can see.
|
||||||
|
|
||||||
.logo-name em {
|
If the ported header is wanted later it is in the git history, but it would
|
||||||
color: var(--dispatch-accent);
|
need rebuilding against this console's own controls rather than restoring:
|
||||||
font-style: normal;
|
the date range, the branch scope and the account menu are all in the app
|
||||||
margin-left: 2px;
|
shell, and a second set of them on one page is the drift this pass has been
|
||||||
}
|
removing everywhere else. */
|
||||||
|
|
||||||
.logo-city-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-city {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--dispatch-accent-soft);
|
|
||||||
border: 1px solid rgba(102, 37, 130, 0.25);
|
|
||||||
color: var(--dispatch-accent);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-city:hover {
|
|
||||||
background: rgba(102, 37, 130, 0.14);
|
|
||||||
border-color: rgba(102, 37, 130, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-toggle-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(102, 37, 130, 0.06));
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
|
||||||
color: #065f46;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-toggle-chip:hover {
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(102, 37, 130, 0.12));
|
|
||||||
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-dot.is-demo {
|
|
||||||
background: var(--dispatch-success);
|
|
||||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
|
|
||||||
animation: pulse-ring 1.8s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-dot.is-live {
|
|
||||||
background: var(--dispatch-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hdr-stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hdr-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--dispatch-bg-sub);
|
|
||||||
border: 1px solid var(--dispatch-border);
|
|
||||||
color: var(--dispatch-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-gps-chip {
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
border-color: rgba(16, 185, 129, 0.3);
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--dispatch-success);
|
|
||||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
|
||||||
animation: pulse-ring 1.8s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strat-stat-orders {
|
|
||||||
background: var(--dispatch-accent-soft);
|
|
||||||
border-color: rgba(102, 37, 130, 0.2);
|
|
||||||
color: var(--dispatch-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.strat-stat-profit {
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
border-color: rgba(16, 185, 129, 0.25);
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date Chip */
|
|
||||||
.date-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1px 3px;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid var(--dispatch-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-nav-btn {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
color: var(--dispatch-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-nav-btn:hover {
|
|
||||||
background: var(--dispatch-bg-sub);
|
|
||||||
color: var(--dispatch-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-chip-main {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 0 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-badge {
|
|
||||||
font-size: 8.5px;
|
|
||||||
font-weight: 800;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--dispatch-accent);
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hdr-clock {
|
|
||||||
font-family: 'JetBrains Mono', monospace, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #38bdf8;
|
|
||||||
border-radius: 6px;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
|
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
|
||||||
.strat-row {
|
.strat-row {
|
||||||
@@ -256,7 +80,7 @@
|
|||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
background: var(--dispatch-card-bg);
|
background: var(--dispatch-card-bg);
|
||||||
border: 1px solid var(--dispatch-border);
|
border: 1px solid var(--dispatch-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius);
|
||||||
box-shadow: var(--dispatch-shadow-sm);
|
box-shadow: var(--dispatch-shadow-sm);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -454,23 +278,21 @@
|
|||||||
|
|
||||||
.dispatch-container .rcard,
|
.dispatch-container .rcard,
|
||||||
.rcard {
|
.rcard {
|
||||||
background: #ffffff;
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--dispatch-border);
|
border: 1px solid var(--dispatch-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover is the line, not a lift -- see the note on `.pcard` in index.css. */
|
||||||
.dispatch-container .rcard:hover,
|
.dispatch-container .rcard:hover,
|
||||||
.rcard:hover {
|
.rcard:hover {
|
||||||
transform: translateY(-2px);
|
border-color: var(--dispatch-accent);
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
|
||||||
border-color: rgba(102, 37, 130, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .rcard.is-focused,
|
.dispatch-container .rcard.is-focused,
|
||||||
@@ -645,10 +467,10 @@
|
|||||||
.dispatch-card-elevated,
|
.dispatch-card-elevated,
|
||||||
.dispatch-focus-card,
|
.dispatch-focus-card,
|
||||||
.dispatch-map-card {
|
.dispatch-map-card {
|
||||||
background: #ffffff !important;
|
background: var(--card-bg) !important;
|
||||||
border: 1px solid var(--dispatch-border) !important;
|
border: var(--card-border) !important;
|
||||||
border-radius: 16px !important;
|
border-radius: var(--card-radius) !important;
|
||||||
box-shadow: var(--dispatch-shadow-md) !important;
|
box-shadow: var(--card-shadow) !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +529,7 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--dispatch-bg-sub);
|
background: var(--dispatch-bg-sub);
|
||||||
border: 1px solid var(--dispatch-border);
|
border: 1px solid var(--dispatch-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rl-item {
|
.rl-item {
|
||||||
@@ -774,19 +596,16 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
border: 1px solid var(--dispatch-border);
|
border: 1px solid var(--dispatch-border);
|
||||||
background: #ffffff;
|
background: var(--card-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.18s ease;
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-step-card:hover,
|
.timeline-step-card:hover,
|
||||||
.stop-timeline-card:hover {
|
.stop-timeline-card:hover {
|
||||||
border-color: var(--dispatch-accent);
|
border-color: var(--dispatch-accent);
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 18px rgba(102, 37, 130, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-step-card.is-current {
|
.timeline-step-card.is-current {
|
||||||
@@ -981,7 +800,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid rgba(102, 37, 130, 0.25);
|
border: 1px solid rgba(102, 37, 130, 0.25);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
|
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
@@ -9,7 +8,7 @@ import { errorMessage } from '@/api/client';
|
|||||||
import { stockApi } from '@/api/stock';
|
import { stockApi } from '@/api/stock';
|
||||||
import type { Product } from '@/api/types';
|
import type { Product } from '@/api/types';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { DrawerButton } from '@/features/store-admin/drawerKit';
|
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,13 +177,7 @@ export function BulkRequestDialog({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{problem ? (
|
{problem ? <Alert>{problem}</Alert> : null}
|
||||||
<Card padding={2} variant="transparent">
|
|
||||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
||||||
{problem}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { queryKeys } from '@/queries/keys';
|
|||||||
import { money } from '@/features/store-admin/format';
|
import { money } from '@/features/store-admin/format';
|
||||||
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
|
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { DrawerButton } from '@/features/store-admin/drawerKit';
|
import { Alert, DrawerButton } from '@/features/store-admin/drawerKit';
|
||||||
import { Thumb } from './ui';
|
import { Thumb } from './ui';
|
||||||
|
|
||||||
const QUICK = [10, 25, 50, 100] as const;
|
const QUICK = [10, 25, 50, 100] as const;
|
||||||
@@ -141,9 +141,7 @@ export function RequestDialog({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{ask.isError ? (
|
{ask.isError ? (
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
<Alert>That did not go through. Check your connection and try again.</Alert>
|
||||||
That did not go through. Check your connection and try again.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { useMemo, useState } from 'react';
|
|||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react';
|
import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
|
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { useCustomers, useOrders } from '@/queries/hooks';
|
import { useCustomers, useOrders } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
|
import { DrawerCard, Row as DrawerRow, Section } from '@/features/store-admin/drawerKit';
|
||||||
import { count } from '@/features/store-admin/format';
|
import { count } from '@/features/store-admin/format';
|
||||||
import { Td, Th } from '../ui';
|
import { Td, Th } from '../ui';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -126,17 +128,13 @@ export function StoreCustomersPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Customers"
|
title="Customers"
|
||||||
actions={
|
actions={
|
||||||
<div style={{ width: 230 }}>
|
<SearchInput
|
||||||
<TextInput
|
|
||||||
label="Search customers"
|
label="Search customers"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Name, phone, address…"
|
placeholder="Name, phone, address…"
|
||||||
hasClear
|
width={240}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -185,7 +183,11 @@ export function StoreCustomersPage() {
|
|||||||
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
|
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
|
||||||
over their whole history
|
over their whole history
|
||||||
</Text>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="customers"
|
||||||
|
footer={<TablePager paged={paged} label="customers" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -221,8 +223,7 @@ export function StoreCustomersPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="customers" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -364,60 +365,47 @@ function CustomerDrawer({
|
|||||||
width={420}
|
width={420}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<VStack gap={1.5}>
|
{/* Built from the drawer kit, not from Astryx cards and a `Line` of its
|
||||||
<Card padding={0} elevation="low">
|
own. The local version set its label column to a fixed 74px and its
|
||||||
<VStack gap={1} padding={2}>
|
text one size down, so this drawer's rows sat at a different height
|
||||||
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
and a different weight from every other drawer's — and an address long
|
||||||
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
enough to wrap pushed its value out of the panel. */}
|
||||||
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
<Section title="How to reach them">
|
||||||
</VStack>
|
<DrawerCard>
|
||||||
</Card>
|
<DrawerRow label="Phone" value={customer.contactno || '—'} icon={<Phone size={14} />} />
|
||||||
<Card padding={0} elevation="low">
|
<DrawerRow label="Email" value={customer.email || '—'} icon={<Mail size={14} />} />
|
||||||
<VStack gap={1} padding={2}>
|
{/* Stacked: an address is prose, and right-aligning six comma-joined
|
||||||
<Line
|
parts against a 74px label is how it ended up unreadable. */}
|
||||||
icon={<ShoppingBag size={13} />}
|
<DrawerRow label="Address" value={address || '—'} icon={<MapPin size={14} />} isStacked />
|
||||||
|
</DrawerCard>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="What they have bought">
|
||||||
|
<DrawerCard>
|
||||||
|
<DrawerRow
|
||||||
label="Orders"
|
label="Orders"
|
||||||
value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`}
|
value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`}
|
||||||
|
icon={<ShoppingBag size={14} />}
|
||||||
|
{...(orders === 0 ? { tone: 'muted' as const } : {})}
|
||||||
/>
|
/>
|
||||||
{/* 97% of customers have coordinates. Shown as a reference rather
|
{/* 97% of customers have coordinates. Shown as a reference rather
|
||||||
than a map: this is a drawer, and a rider's app is where a pin
|
than a map: this is a drawer, and a rider's app is where a pin
|
||||||
actually gets used. */}
|
actually gets used. */}
|
||||||
<Line
|
<DrawerRow
|
||||||
icon={<MapPin size={13} />}
|
|
||||||
label="Map ref"
|
label="Map ref"
|
||||||
value={
|
value={
|
||||||
customer.latitude && customer.longitude
|
customer.latitude && customer.longitude
|
||||||
? `${customer.latitude}, ${customer.longitude}`
|
? `${customer.latitude}, ${customer.longitude}`
|
||||||
: 'Not placed'
|
: 'Not placed'
|
||||||
}
|
}
|
||||||
|
icon={<MapPin size={14} />}
|
||||||
|
{...(customer.latitude && customer.longitude ? {} : { tone: 'muted' as const })}
|
||||||
/>
|
/>
|
||||||
</VStack>
|
</DrawerCard>
|
||||||
</Card>
|
</Section>
|
||||||
</VStack>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function Line({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<HStack gap={1.5} align="start">
|
|
||||||
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }}>{icon}</span>
|
|
||||||
<Text type="body" size="sm" color="secondary" style={{ width: 74, flex: 'none' }}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text type="body" size="sm" style={{ minWidth: 0 }}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Initials({ name }: { name: string }) {
|
function Initials({ name }: { name: string }) {
|
||||||
const initials =
|
const initials =
|
||||||
@@ -451,7 +439,7 @@ function Initials({ name }: { name: string }) {
|
|||||||
|
|
||||||
function Note({ title, body }: { title: string; body?: string }) {
|
function Note({ title, body }: { title: string; body?: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { Card } from '@astryxdesign/core/Card';
|
|||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
|
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { stockApi } from '@/api/stock';
|
import { stockApi } from '@/api/stock';
|
||||||
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
|
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import {
|
import {
|
||||||
useAppAisles,
|
useAppAisles,
|
||||||
@@ -34,7 +35,8 @@ import {
|
|||||||
canAskAgain, requestState,
|
canAskAgain, requestState,
|
||||||
stockHealth,
|
stockHealth,
|
||||||
} from '../requestState';
|
} from '../requestState';
|
||||||
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
|
import { Chip, Td, Th, Thumb } from '../ui';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -155,41 +157,37 @@ export function StoreProductsPage() {
|
|||||||
title="Products"
|
title="Products"
|
||||||
actions={
|
actions={
|
||||||
tab === 'requests' ? null : (
|
tab === 'requests' ? null : (
|
||||||
<div style={{ width: 230 }}>
|
<SearchInput
|
||||||
<TextInput
|
|
||||||
label="Search products"
|
label="Search products"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search products…"
|
placeholder="Search products…"
|
||||||
hasClear
|
width={240}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<TabBar>
|
||||||
<TabButton
|
<Tab
|
||||||
label="Catalogue"
|
label="Catalogue"
|
||||||
icon={<PackageSearch size={14} />}
|
icon={<PackageSearch size={14} />}
|
||||||
isActive={tab === 'catalogue'}
|
isActive={tab === 'catalogue'}
|
||||||
onClick={() => setTab('catalogue')}
|
onClick={() => setTab('catalogue')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="My stock"
|
label="My stock"
|
||||||
icon={<Boxes size={14} />}
|
icon={<Boxes size={14} />}
|
||||||
isActive={tab === 'stock'}
|
isActive={tab === 'stock'}
|
||||||
onClick={() => setTab('stock')}
|
onClick={() => setTab('stock')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<Tab
|
||||||
label="My requests"
|
label="My requests"
|
||||||
icon={<ClipboardList size={14} />}
|
icon={<ClipboardList size={14} />}
|
||||||
isActive={tab === 'requests'}
|
isActive={tab === 'requests'}
|
||||||
onClick={() => setTab('requests')}
|
onClick={() => setTab('requests')}
|
||||||
badge={waiting || undefined}
|
count={waiting || undefined}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</TabBar>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -211,7 +209,16 @@ export function StoreProductsPage() {
|
|||||||
<RequestsPanel rows={requestRows} isLoading={requests.isLoading} />
|
<RequestsPanel rows={requestRows} isLoading={requests.isLoading} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
|
{open ? (
|
||||||
|
/* The row shows a Stock request chip; the drawer opened from it shows
|
||||||
|
the same one, off the same map — see `request` on ProductDrawer. */
|
||||||
|
<ProductDrawer
|
||||||
|
product={open}
|
||||||
|
canManage={false}
|
||||||
|
{...(latest.get(open.productid) ? { request: latest.get(open.productid) } : {})}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{askingMany && askingMany.length > 0 && tenantid && locationid ? (
|
{askingMany && askingMany.length > 0 && tenantid && locationid ? (
|
||||||
<BulkRequestDialog
|
<BulkRequestDialog
|
||||||
@@ -349,7 +356,12 @@ function CataloguePanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Catalogue"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="products"
|
||||||
|
footer={<TablePager paged={paged} label="products" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -398,8 +410,7 @@ function CataloguePanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="products" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -482,8 +493,14 @@ function CatalogueRow({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>{unit || '—'}</Td>
|
<Td isMuted>{unit || '—'}</Td>
|
||||||
<Td isStrong>
|
{/* An em dash for an unpriced product, not "₹0". Zero is a price a till
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>
|
would ring up; no price at all is why it cannot. The merchant's own
|
||||||
|
products table and this product's drawer both say so — this cell was
|
||||||
|
the one printing a figure that does not exist. */}
|
||||||
|
<Td isStrong={effectivePrice(product) > 0}>
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{effectivePrice(product) > 0 ? money(effectivePrice(product)) : '—'}
|
||||||
|
</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted={stock === 0}>
|
<Td isMuted={stock === 0}>
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
|
||||||
@@ -550,7 +567,12 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
|
|||||||
Today’s ledger. <strong>Opening</strong> is what was on the shelf before today — stock
|
Today’s ledger. <strong>Opening</strong> is what was on the shelf before today — stock
|
||||||
received today counts under <strong>In</strong> and becomes tomorrow’s opening.
|
received today counts under <strong>In</strong> and becomes tomorrow’s opening.
|
||||||
</Text>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="My stock"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="stock rows"
|
||||||
|
footer={<TablePager paged={paged} label="stock rows" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
||||||
@@ -622,8 +644,7 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading:
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="stock rows" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -674,7 +695,12 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="My requests"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="requests"
|
||||||
|
footer={<TablePager paged={paged} label="requests" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
|
||||||
@@ -743,8 +769,7 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="requests" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -760,7 +785,7 @@ function stamp(value: string | undefined): string {
|
|||||||
|
|
||||||
function Note({ title, body }: { title: string; body?: string }) {
|
function Note({ title, body }: { title: string; body?: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
|
|||||||
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
|
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
|
||||||
import { count } from '@/features/store-admin/format';
|
import { count } from '@/features/store-admin/format';
|
||||||
import { Td, Th } from '../ui';
|
import { Td, Th } from '../ui';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ export function StoreStaffPage() {
|
|||||||
A suspended cashier is refused by the till, and now that these rows
|
A suspended cashier is refused by the till, and now that these rows
|
||||||
are asked for rather than hidden, the page can say so. */}
|
are asked for rather than hidden, the page can say so. */}
|
||||||
{suspended > 0 ? (
|
{suspended > 0 ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<HStack gap={1.5} padding={2} align="start">
|
<HStack gap={1.5} padding={2} align="start">
|
||||||
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
|
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
|
||||||
<TriangleAlert size={16} />
|
<TriangleAlert size={16} />
|
||||||
@@ -199,7 +200,11 @@ export function StoreStaffPage() {
|
|||||||
? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet`
|
? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet`
|
||||||
: ' · no supervisor at this outlet'}
|
: ' · no supervisor at this outlet'}
|
||||||
</Text>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="people"
|
||||||
|
footer={<TablePager paged={paged} label="people" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -284,8 +289,7 @@ export function StoreStaffPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="people" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -425,7 +429,7 @@ function AccessChip({ person }: { person: PosUser }) {
|
|||||||
|
|
||||||
function Note({ title, body }: { title: string; body?: string }) {
|
function Note({ title, body }: { title: string; body?: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0}>
|
||||||
<VStack gap={0.5} padding={3}>
|
<VStack gap={0.5} padding={3}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -11,64 +11,8 @@ import { ImageOff } from 'lucide-react';
|
|||||||
* merchant pages already went through.
|
* merchant pages already went through.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A page tab, as used in the Store Admin's PageHeader tab row. */
|
/* The sixth and last copy of `TabButton` lived here, exported for the Store
|
||||||
export function TabButton({
|
user pages. Every one of them now imports `components/TabBar`. */
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
badge?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 18,
|
|
||||||
height: 18,
|
|
||||||
padding: '0 5px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-warning, #b7860b)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A header action drawn like the tabs, so the two rows read as one bar. */
|
/** A header action drawn like the tabs, so the two rows read as one bar. */
|
||||||
export function BarAction({
|
export function BarAction({
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
CircleDot,
|
CircleDot,
|
||||||
Circle,
|
Circle,
|
||||||
Download,
|
Download,
|
||||||
FileSpreadsheet,
|
|
||||||
Info,
|
Info,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -79,7 +78,28 @@ export function UploadDetailDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Upload details"
|
/* The FILE names this drawer, not the word "details".
|
||||||
|
|
||||||
|
It used to be titled "Upload details" with the header otherwise empty,
|
||||||
|
and then re-stated the filename, the status chip and the uploader in a
|
||||||
|
block of its own at the top of the body — a second header, in a
|
||||||
|
different type scale, three lines below the real one. Every other
|
||||||
|
drawer in the console names its subject in the bar; this one now does
|
||||||
|
too, and the block is gone. */
|
||||||
|
title={receipt.filename || 'Spreadsheet'}
|
||||||
|
subtitle={`${totals.rows} row${totals.rows === 1 ? '' : 's'}${
|
||||||
|
totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''
|
||||||
|
}`}
|
||||||
|
meta={
|
||||||
|
<>
|
||||||
|
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
|
||||||
|
<span className="drawer-meta-text">
|
||||||
|
{[showTenant ? receipt.tenantname : null, receipt.locationname, receipt.uploadedname]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
width={540}
|
width={540}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
isFooterFilled={products.length === 0}
|
isFooterFilled={products.length === 0}
|
||||||
@@ -109,33 +129,6 @@ export function UploadDetailDrawer({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* ── The file ──────────────────────────────────────────────────────── */}
|
|
||||||
<div className="up-head">
|
|
||||||
<span className="up-head-icon">
|
|
||||||
<FileSpreadsheet size={22} />
|
|
||||||
</span>
|
|
||||||
<div className="up-head-body">
|
|
||||||
<span className="up-head-name">{receipt.filename || 'Spreadsheet'}</span>
|
|
||||||
<span className="up-head-sub">
|
|
||||||
{totals.rows} row{totals.rows === 1 ? '' : 's'}
|
|
||||||
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
|
|
||||||
</span>
|
|
||||||
<div className="up-head-row">
|
|
||||||
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
|
|
||||||
</div>
|
|
||||||
<span className="up-head-meta">
|
|
||||||
{[
|
|
||||||
showTenant ? receipt.tenantname : null,
|
|
||||||
receipt.locationname,
|
|
||||||
receipt.uploadedname,
|
|
||||||
when(receipt.created),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Summary ───────────────────────────────────────────────────────── */}
|
{/* ── Summary ───────────────────────────────────────────────────────── */}
|
||||||
<Metrics>
|
<Metrics>
|
||||||
<Metric label="Total rows" value={totals.rows || '—'} />
|
<Metric label="Total rows" value={totals.rows || '—'} />
|
||||||
@@ -251,6 +244,11 @@ export function UploadDetailDrawer({
|
|||||||
<Section title="File details">
|
<Section title="File details">
|
||||||
<DrawerCard>
|
<DrawerCard>
|
||||||
<Row label="File name" value={receipt.filename || '—'} />
|
<Row label="File name" value={receipt.filename || '—'} />
|
||||||
|
{/* The table's Uploaded on column. It used to be in the block of
|
||||||
|
header this drawer drew for itself, and it is the one fact from
|
||||||
|
that block the bar has no room for — the meta line there carries
|
||||||
|
who and where, and a fourth item would push the title out. */}
|
||||||
|
<Row label="Uploaded on" value={when(receipt.created)} />
|
||||||
<Row label="Size" value={fileSize(file?.size_bytes)} />
|
<Row label="Size" value={fileSize(file?.size_bytes)} />
|
||||||
<Row label="Rows" value={totals.rows || '—'} />
|
<Row label="Rows" value={totals.rows || '—'} />
|
||||||
<Row label="Added" value={totals.added} />
|
<Row label="Added" value={totals.added} />
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
|
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
|
||||||
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import {
|
import {
|
||||||
fetchBatch,
|
fetchBatch,
|
||||||
@@ -53,6 +54,7 @@ import { readStoredRows } from './shelveRows';
|
|||||||
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
||||||
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
|
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
|
||||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||||
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { UploadDetailDrawer } from './UploadDetailDrawer';
|
import { UploadDetailDrawer } from './UploadDetailDrawer';
|
||||||
@@ -350,13 +352,12 @@ export function UploadsPanel({
|
|||||||
|
|
||||||
<div className="up-toolbar-spacer" />
|
<div className="up-toolbar-spacer" />
|
||||||
|
|
||||||
<input
|
<SearchInput
|
||||||
className="up-search"
|
label="Search by file name"
|
||||||
type="search"
|
|
||||||
value={search}
|
value={search}
|
||||||
placeholder="Search by file name…"
|
onChange={setSearch}
|
||||||
aria-label="Search by file name"
|
placeholder="File name…"
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
width={230}
|
||||||
/>
|
/>
|
||||||
{/* "Check again" stood here. It is gone because the page reads the
|
{/* "Check again" stood here. It is gone because the page reads the
|
||||||
moving rows by itself now — every fifteen seconds while something is
|
moving rows by itself now — every fifteen seconds while something is
|
||||||
@@ -404,7 +405,12 @@ export function UploadsPanel({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card padding={0} elevation="low">
|
<Panel
|
||||||
|
title="Uploads"
|
||||||
|
count={paged.total}
|
||||||
|
countLabel="uploads"
|
||||||
|
footer={<TablePager paged={paged} label="uploads" />}
|
||||||
|
>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="up-table">
|
<table className="up-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -484,8 +490,7 @@ export function UploadsPanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="uploads" />
|
</Panel>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
|
|||||||
@@ -53,19 +53,29 @@
|
|||||||
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
|
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
|
||||||
.up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
.up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
.up-search, .up-date {
|
/* `.up-search` was here. The toolbar uses `components/SearchInput` now, like
|
||||||
|
every other search in the console.
|
||||||
|
|
||||||
|
The date field stays, and takes the search box's measurements so the two sit
|
||||||
|
together: the same 38px, the same corner and the same hairline. It was 11px
|
||||||
|
where the search is `--card-radius-sm`, which beside each other read as two
|
||||||
|
different controls. */
|
||||||
|
.up-date {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 11px;
|
border-radius: var(--card-radius-sm);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
font: 400 13.5px/1 var(--font-sans);
|
box-shadow: var(--card-shadow);
|
||||||
color: var(--color-ink-1);
|
font: 400 13px/1 var(--font-sans);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
.up-date:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
.up-search { min-width: 220px; }
|
|
||||||
.up-date { font-variant-numeric: tabular-nums; color: var(--color-ink-2); }
|
|
||||||
.up-search:focus, .up-date:focus { outline: 2px solid var(--color-brand); outline-offset: -1px; }
|
|
||||||
.up-search::placeholder { color: var(--color-ink-4); }
|
|
||||||
|
|
||||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -114,32 +124,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drawer: the file header ────────────────────────────────────────────── */
|
/* The drawer's file header used to live here — an icon, the filename and the
|
||||||
/* This is what makes it an Upload Details workspace rather than the generic
|
status chip, drawn as a second header inside the body because the real one
|
||||||
drawer: the file itself is the subject, so it gets an icon and its own block
|
only said "Upload details". The bar names the file now, so these rules have
|
||||||
above everything. */
|
no markup left to style. */
|
||||||
.up-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; }
|
|
||||||
|
|
||||||
.up-head-icon {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: none;
|
|
||||||
background: var(--color-brand-tint);
|
|
||||||
color: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.up-head-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
||||||
.up-head-name {
|
|
||||||
font: 600 17px/1.3 var(--font-sans);
|
|
||||||
color: var(--color-ink-1);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.up-head-sub { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
|
||||||
.up-head-meta { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-4); }
|
|
||||||
.up-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* ── Drawer: the journey ────────────────────────────────────────────────── */
|
/* ── Drawer: the journey ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -238,7 +226,7 @@
|
|||||||
has to go and fix a spreadsheet. */
|
has to go and fix a spreadsheet. */
|
||||||
.up-reject {
|
.up-reject {
|
||||||
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent);
|
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: color-mix(in oklab, var(--color-error) 5%, transparent);
|
background: color-mix(in oklab, var(--color-error) 5%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
258
src/index.css
258
src/index.css
@@ -50,6 +50,56 @@
|
|||||||
--color-surface-sunken: #f2f4f7;
|
--color-surface-sunken: #f2f4f7;
|
||||||
--color-line: #e0e4ea;
|
--color-line: #e0e4ea;
|
||||||
|
|
||||||
|
/* ── The card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ONE card for the whole console: a white fill, a hairline, a 12px corner
|
||||||
|
and NO SHADOW. Separation comes from the line alone, so the page reads as
|
||||||
|
a single plane with regions marked on it rather than as a tray of raised
|
||||||
|
slabs — which is what it had become. A table inside a flat card reads as
|
||||||
|
the table's container; inside a floating one it reads as a tile that
|
||||||
|
happens to have a grid in it.
|
||||||
|
|
||||||
|
── Why these are tokens and not four numbers typed per surface ─────────
|
||||||
|
|
||||||
|
The app had grown FIVE card radii — 10, 12, 14, 16 and 18 — plus three
|
||||||
|
unrelated shadow recipes, across surfaces that are all the same object:
|
||||||
|
`.kpi`, `.panel`, `.pcard`, `.rcard`, `.ob-frame`, `.sp-card`,
|
||||||
|
`.dispatch-map-card` and a dozen more. Each was right on the day it was
|
||||||
|
written and none of them agreed. Anything card-shaped now reads these.
|
||||||
|
|
||||||
|
`--card-radius` is kept in step with Astryx's `--radius-container` BY
|
||||||
|
HAND rather than aliased to it, because the generated theme sheet is
|
||||||
|
scoped to `[data-astryx-theme]` and these tokens have to resolve outside
|
||||||
|
it too — on the login, and inside a drawer. The two numbers are the same
|
||||||
|
number; `src/theme/nearle.ts` is where the decision is recorded.
|
||||||
|
|
||||||
|
── The edge ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A hairline PLUS a 1px micro-lift at 4%. Not a blur. A wide soft shadow
|
||||||
|
makes a panel float, and a page of five floating panels is a tray of
|
||||||
|
slabs; one crisp pixel is enough to separate the panel from the canvas
|
||||||
|
and to make the hairline read as an edge rather than as a drawn line.
|
||||||
|
|
||||||
|
A bigger shadow does still exist in the app, deliberately, and belongs to
|
||||||
|
things that genuinely float ABOVE the page: the drawer panel, a map
|
||||||
|
popup, a chart tooltip. A card is not one of those.
|
||||||
|
|
||||||
|
── The edge is only half of it ─────────────────────────────────────────
|
||||||
|
|
||||||
|
These tokens paint a box. What makes the box read as designed rather than
|
||||||
|
as a default is the ANATOMY inside it — a header band that names the
|
||||||
|
panel and counts its rows, a body, a footer band that owns the pager.
|
||||||
|
That lives in `components/Panel.tsx`, and a card that is just these
|
||||||
|
tokens wrapped round a table is the bare rectangle this replaced. */
|
||||||
|
--card-radius: 14px;
|
||||||
|
--card-radius-sm: 10px; /* a card nested inside another card */
|
||||||
|
--card-border: 1px solid var(--color-line);
|
||||||
|
--card-bg: var(--color-surface);
|
||||||
|
--card-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||||
|
/* The fill for a card that is deliberately quieter than the page — an
|
||||||
|
explanatory block, a disabled region. Still flat, still hairlined. */
|
||||||
|
--card-bg-subtle: var(--color-surface-subtle);
|
||||||
|
|
||||||
/* Neutral ramp — the ink scale every surface and label reads from. */
|
/* Neutral ramp — the ink scale every surface and label reads from. */
|
||||||
--color-slate-50: #f7f7f7;
|
--color-slate-50: #f7f7f7;
|
||||||
--color-slate-100: #eff1f4;
|
--color-slate-100: #eff1f4;
|
||||||
@@ -133,6 +183,111 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Scrolling ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
One scrolling behaviour for the whole console — the page, every panel that
|
||||||
|
scrolls inside it, and every side drawer.
|
||||||
|
|
||||||
|
── What `scroll-behavior: smooth` does and does not do ─────────────────────
|
||||||
|
|
||||||
|
It animates PROGRAMMATIC scrolls: `scrollIntoView`, `scrollTo`, a jump to a
|
||||||
|
`#hash`, and the browser's own move when focus lands on something off
|
||||||
|
screen. It does NOT touch the mouse wheel, a trackpad or a touch drag —
|
||||||
|
those are the operating system's and are already smooth. So this is the
|
||||||
|
difference between a page that JUMPS when you tab into a field below the
|
||||||
|
fold, or when the uploads drawer jumps you to its rejection panel, and one
|
||||||
|
that travels there.
|
||||||
|
|
||||||
|
Declared here rather than per component so nothing is left out: a container
|
||||||
|
that scrolls and is not in the list below is the one that will feel wrong.
|
||||||
|
|
||||||
|
Accessibility is already handled — the reduced-motion block above resets
|
||||||
|
`scroll-behavior` to `auto !important` for everything, so a person who has
|
||||||
|
asked for less motion gets the instant jump back.
|
||||||
|
|
||||||
|
── The two properties on `html` that are not about smoothness ─────────────
|
||||||
|
|
||||||
|
`scroll-padding-top` is the sticky header's height. Without it, anything
|
||||||
|
scrolled to by the browser lands UNDER the 56px bar — the field you tabbed
|
||||||
|
to is behind the nav, which reads as the page having scrolled to the wrong
|
||||||
|
place.
|
||||||
|
|
||||||
|
`scrollbar-gutter: stable` reserves the scrollbar's width whether or not
|
||||||
|
there is one. It stops the whole layout shifting sideways when a short page
|
||||||
|
becomes a long one, when a filter empties a table, and — the reason it is
|
||||||
|
here — when a drawer opens and locks the page behind it. */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* 56px bar + its 1px rule + 8px so the target is not flush against it. */
|
||||||
|
scroll-padding-top: 65px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every panel that scrolls inside the page.
|
||||||
|
|
||||||
|
`:where()` keeps the specificity at zero, so any of these can still set its
|
||||||
|
own behaviour without an `!important` fight. */
|
||||||
|
:where(
|
||||||
|
.table-scroll, /* every table, sideways */
|
||||||
|
.rail-scroll, /* the catalogue's category rail */
|
||||||
|
.bulkreq-list, /* the bulk stock request */
|
||||||
|
.district-list, /* choosing a partner's district */
|
||||||
|
.kpi-strip, /* the console's KPI row, sideways */
|
||||||
|
.chart2, /* the console's bar chart, sideways */
|
||||||
|
.rail-filter-chips, /* dispatch's filter chips, sideways */
|
||||||
|
.stops-timeline-carousel
|
||||||
|
) {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
/* The side drawers are not in that list. `.drawer-body` sets its own, beside
|
||||||
|
the `overscroll-behavior` it already had — the drawer system owns its
|
||||||
|
scrolling in `drawer.css`, and splitting half of it across two files is how
|
||||||
|
one of the two gets changed alone. */
|
||||||
|
|
||||||
|
/* ── One scrollbar ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Both properties are inherited, so declaring them on `html` reaches every
|
||||||
|
scroll container in the app — including the ones inside drawers, which is
|
||||||
|
how a drawer's scrollbar stops being a 15px platform-grey slab beside a
|
||||||
|
520px panel.
|
||||||
|
|
||||||
|
The webkit block below says the same thing again for Chrome, Edge and
|
||||||
|
Safari, where it wins over the standard properties. It is not redundant:
|
||||||
|
only the pseudo-elements can round the thumb and give it a hover state, and
|
||||||
|
a bar that responds to the pointer is most of what reads as "considered"
|
||||||
|
rather than "default". */
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-line) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-line);
|
||||||
|
/* Drawn inside a transparent border, which is the only way to inset a
|
||||||
|
webkit thumb — it leaves the bar reading as a 6px rail rather than a 10px
|
||||||
|
slab, while keeping a 10px target for the pointer. */
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-ink-4);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
/* Where the two bars meet. Left at the platform default it is a small grey
|
||||||
|
square in the corner of every scrolling panel. */
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Numeric columns line up only with tabular figures. Used by every KPI value
|
/* Numeric columns line up only with tabular figures. Used by every KPI value
|
||||||
and every money/count cell in a table. */
|
and every money/count cell in a table. */
|
||||||
.tabular {
|
.tabular {
|
||||||
@@ -681,9 +836,9 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.catalogue-rail {
|
.catalogue-rail {
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 72px;
|
top: 72px;
|
||||||
/* The clip lives here, on the element that owns the radius. */
|
/* The clip lives here, on the element that owns the radius. */
|
||||||
@@ -694,10 +849,10 @@ main {
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
max-height: calc(100dvh - 98px);
|
max-height: calc(100dvh - 98px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* A thin, quiet scrollbar. The default is wide enough to read as a second
|
/* The thin, quiet scrollbar this rail used to declare for itself is now the
|
||||||
column beside a 216px rail. */
|
whole console's — both scrollbar properties are inherited, so the pair on
|
||||||
scrollbar-width: thin;
|
`html` reaches here. This rail was the only place that had been fixed, and
|
||||||
scrollbar-color: var(--color-line) transparent;
|
it is why every other panel's bar looked like a different product's. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-scroll::-webkit-scrollbar {
|
.rail-scroll::-webkit-scrollbar {
|
||||||
@@ -810,16 +965,18 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color .18s, box-shadow .18s;
|
transition: border-color .18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover is a stronger LINE, not a lift. In a flat system a card that rises on
|
||||||
|
hover is the only thing on the page with a z-axis, which reads as a bug
|
||||||
|
rather than as feedback. */
|
||||||
.pcard:hover {
|
.pcard:hover {
|
||||||
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent);
|
border-color: var(--color-brand);
|
||||||
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcard-media {
|
.pcard-media {
|
||||||
@@ -1278,9 +1435,9 @@ main {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .18s, background .18s, transform .18s;
|
transition: border-color .18s, background .18s, transform .18s;
|
||||||
}
|
}
|
||||||
@@ -1300,8 +1457,8 @@ main {
|
|||||||
read. */
|
read. */
|
||||||
.ob-drop {
|
.ob-drop {
|
||||||
border: 1.5px dashed var(--color-slate-300);
|
border: 1.5px dashed var(--color-slate-300);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
padding: 32px 20px;
|
padding: 32px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: border-color .18s, background .18s;
|
transition: border-color .18s, background .18s;
|
||||||
@@ -1359,9 +1516,9 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius-sm);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
}
|
}
|
||||||
.ob-flow-icon {
|
.ob-flow-icon {
|
||||||
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
|
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
|
||||||
@@ -1403,9 +1560,9 @@ main {
|
|||||||
body (the step's own fields) and foot (Back / Continue). The bands are
|
body (the step's own fields) and foot (Back / Continue). The bands are
|
||||||
separated by rules rather than gaps so the card reads as one object. */
|
separated by rules rather than gaps so the card reads as one object. */
|
||||||
.ob-frame {
|
.ob-frame {
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 18px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,7 +1622,7 @@ main {
|
|||||||
.ob-returnbar {
|
.ob-returnbar {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border: 1px solid var(--color-brand);
|
border: 1px solid var(--color-brand);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,9 +1656,9 @@ main {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text renders inline, so the title and its detail ran together on one line
|
/* Text renders inline, so the title and its detail ran together on one line
|
||||||
@@ -1540,7 +1697,7 @@ main {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
border: 1px solid var(--color-warning-line, #e8d9a8);
|
border: 1px solid var(--color-warning-line, #e8d9a8);
|
||||||
background: var(--color-warning-tint, #fdf7e6);
|
background: var(--color-warning-tint, #fdf7e6);
|
||||||
color: var(--color-ink-1);
|
color: var(--color-ink-1);
|
||||||
@@ -1593,9 +1750,9 @@ main {
|
|||||||
something is picked, then takes the brand tint and shows its actions. */
|
something is picked, then takes the brand tint and shows its actions. */
|
||||||
.bulkbar {
|
.bulkbar {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
transition: background .15s ease, border-color .15s ease;
|
transition: background .15s ease, border-color .15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1685,9 +1842,9 @@ main {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--card-radius-sm);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qty-step {
|
.qty-step {
|
||||||
@@ -1737,9 +1894,9 @@ main {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface-subtle);
|
background: var(--card-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-guide[data-tone='warn'] {
|
.ob-guide[data-tone='warn'] {
|
||||||
@@ -1814,7 +1971,10 @@ main {
|
|||||||
laptop and sit under the panel. The brief is explicit that the panel is not
|
laptop and sit under the panel. The brief is explicit that the panel is not
|
||||||
to be touched, and a background is not worth the risk of moving it. */
|
to be touched, and a background is not worth the risk of moving it. */
|
||||||
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff);
|
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff);
|
||||||
border-radius: 16px;
|
/* The card radius, even though this is a tinted REGION rather than a card:
|
||||||
|
it holds cards, and a 16px region around 12px cards reads as a rounding
|
||||||
|
error rather than as a nesting. */
|
||||||
|
border-radius: var(--card-radius);
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) { .sp-page { padding: 12px; } }
|
@media (max-width: 720px) { .sp-page { padding: 12px; } }
|
||||||
@@ -1827,10 +1987,9 @@ main {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 16px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The store, not the person. A shop mark rather than an avatar — this record
|
/* The store, not the person. A shop mark rather than an avatar — this record
|
||||||
@@ -1946,10 +2105,9 @@ main {
|
|||||||
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
|
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|
||||||
.sp-card {
|
.sp-card {
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sp-card[data-span='full'] { grid-column: 1 / -1; }
|
.sp-card[data-span='full'] { grid-column: 1 / -1; }
|
||||||
@@ -2078,9 +2236,9 @@ main {
|
|||||||
.district-list {
|
.district-list {
|
||||||
max-height: 208px;
|
max-height: 208px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--color-line);
|
border: var(--card-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--card-radius);
|
||||||
background: var(--color-surface);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.district-row {
|
.district-row {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/nearle.ts
|
* Source: src\theme\nearle.ts
|
||||||
* Command: astryx theme build src/theme/nearle.ts
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
* CLI: @astryxdesign/cli@0.4.5
|
* CLI: @astryxdesign/cli@0.4.5
|
||||||
* Core: @astryxdesign/core@0.4.5
|
* Core: @astryxdesign/core@0.4.5
|
||||||
*/
|
*/
|
||||||
@@ -88,9 +88,9 @@
|
|||||||
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
|
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
--radius-element: 12px;
|
--radius-element: 12px;
|
||||||
--radius-container: 16px;
|
--radius-container: 14px;
|
||||||
--radius-page: 16px;
|
--radius-page: 14px;
|
||||||
--radius-chat: 16px;
|
--radius-chat: 14px;
|
||||||
--text-body-size: 14px;
|
--text-body-size: 14px;
|
||||||
--text-label-size: 14px;
|
--text-label-size: 14px;
|
||||||
--text-large-size: 17px;
|
--text-large-size: 17px;
|
||||||
|
|||||||
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.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/nearle.ts
|
* Source: src\theme\nearle.ts
|
||||||
* Command: astryx theme build src/theme/nearle.ts
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
* CLI: @astryxdesign/cli@0.4.5
|
* CLI: @astryxdesign/cli@0.4.5
|
||||||
* Core: @astryxdesign/core@0.4.5
|
* Core: @astryxdesign/core@0.4.5
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/nearle.ts
|
* Source: src\theme\nearle.ts
|
||||||
* Command: astryx theme build src/theme/nearle.ts
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
* CLI: @astryxdesign/cli@0.4.5
|
* CLI: @astryxdesign/cli@0.4.5
|
||||||
* Core: @astryxdesign/core@0.4.5
|
* Core: @astryxdesign/core@0.4.5
|
||||||
*/
|
*/
|
||||||
@@ -21,9 +21,9 @@ export const nearleTheme = {
|
|||||||
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
|
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
|
||||||
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
||||||
"--radius-element": "12px",
|
"--radius-element": "12px",
|
||||||
"--radius-container": "16px",
|
"--radius-container": "14px",
|
||||||
"--radius-page": "16px",
|
"--radius-page": "14px",
|
||||||
"--radius-chat": "16px",
|
"--radius-chat": "14px",
|
||||||
"--text-body-size": "14px",
|
"--text-body-size": "14px",
|
||||||
"--text-label-size": "14px",
|
"--text-label-size": "14px",
|
||||||
"--text-large-size": "17px",
|
"--text-large-size": "17px",
|
||||||
|
|||||||
@@ -50,16 +50,33 @@ export const nearleTheme = defineTheme({
|
|||||||
},
|
},
|
||||||
|
|
||||||
tokens: {
|
tokens: {
|
||||||
// ── Radius, matched to KROW ──────────────────────────────────────────
|
// ── Radius ───────────────────────────────────────────────────────────
|
||||||
// KROW uses exactly three radii: 16px on cards, 12px on controls, full on
|
// ONE card radius for the whole console, and this is where it is set:
|
||||||
// pills. Astryx defaults to 8px on controls and 12px on cards, with
|
// `--radius-container` is what Astryx's `Card` rounds by, so every one of
|
||||||
// page/chat at 28px — that 28px is where the odd oversized surfaces in the
|
// the 110 cards in the app takes its corner from this line. The
|
||||||
// old console came from. `--radius-inner` (4px) is left alone: KROW does
|
// hand-rolled surfaces read the same number through `--card-radius` in
|
||||||
// use a 4px radius on its smallest inline markers.
|
// `index.css`, which is defined from this token.
|
||||||
|
//
|
||||||
|
// 14. The app had grown five card radii — 10, 12, 14, 16 and 18 — across
|
||||||
|
// surfaces that are all the same object, and none of them agreed.
|
||||||
|
//
|
||||||
|
// 14 rather than 12 because the card is a FRAMED panel now, with a tinted
|
||||||
|
// header band and a footer band inside the same clip: at 12 the band's
|
||||||
|
// corner and the panel's corner were close enough to look like a
|
||||||
|
// mis-rounding, and the header's fill reads better against a slightly
|
||||||
|
// softer edge. A plain box is happier at 12; this is not a plain box —
|
||||||
|
// see `components/Panel.tsx`.
|
||||||
|
//
|
||||||
|
// `--radius-element` stays at 12: a control is one step tighter than the
|
||||||
|
// panel that holds it, which is what keeps a button inside a card from
|
||||||
|
// looking like a small card.
|
||||||
|
//
|
||||||
|
// `--radius-inner` (4px) is left alone — Astryx uses it on the smallest
|
||||||
|
// inline markers, which are not cards.
|
||||||
'--radius-element': '12px',
|
'--radius-element': '12px',
|
||||||
'--radius-container': '16px',
|
'--radius-container': '14px',
|
||||||
'--radius-page': '16px',
|
'--radius-page': '14px',
|
||||||
'--radius-chat': '16px',
|
'--radius-chat': '14px',
|
||||||
|
|
||||||
// Control heights are NOT overridden: Astryx's `--size-element-md` is
|
// Control heights are NOT overridden: Astryx's `--size-element-md` is
|
||||||
// already 32px, which is exactly KROW's control height.
|
// already 32px, which is exactly KROW's control height.
|
||||||
|
|||||||
Reference in New Issue
Block a user