ui/ux improvement

This commit is contained in:
2026-09-18 16:57:24 +05:30
parent 115a06a02c
commit 041dc37861
43 changed files with 1697 additions and 746 deletions

View File

@@ -407,7 +407,11 @@ export function CatalogueBrowser({
isLoading={brands.isLoading}
brand={brand}
category={category}
categories={(categories.data ?? []).filter(Boolean)}
/* The platform catalogue filters on the category name itself, so
value and label are the same string here. */
categories={(categories.data ?? [])
.filter(Boolean)
.map((name) => ({ value: name, label: name }))}
isLoadingCategories={categories.isLoading}
onBrand={setBrand}
onCategory={setCategory}

View File

@@ -28,6 +28,7 @@ export function CatalogueSidebar({
category,
categories,
isLoadingCategories,
totalCount,
onBrand,
onCategory,
}: {
@@ -35,8 +36,26 @@ export function CatalogueSidebar({
isLoading: boolean;
brand: string;
category: string;
categories: string[];
/**
* Value and label kept apart.
*
* The platform catalogue filters on the category NAME, so the two are the same
* string there. A store's own products are grouped by aisle, where the filter
* is a `subcategoryid` and the name is what a reader recognises — passing one
* string for both would put "7" in the rail or filter on "Dairy".
*/
categories: { value: string; label: string }[];
isLoadingCategories: boolean;
/**
* What "All brands" counts, when it is not the sum of the rail.
*
* The platform catalogue's brands account for every row, so summing them is
* right there. A store's rows can have no brand at all: those are left out of
* the rail and still listed under "All brands", so the sum would be short by
* exactly the unbranded items and the top of the rail would contradict the
* list beside it.
*/
totalCount?: number;
onBrand: (brand: string) => void;
onCategory: (category: string) => void;
}) {
@@ -56,7 +75,9 @@ export function CatalogueSidebar({
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
All brands
<span className="rail-count">
{isLoading ? '' : brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0)}
{isLoading
? ''
: (totalCount ?? brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0))}
</span>
</button>
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
>
All categories
</button>
{categories.map((name) => (
{categories.map((entry) => (
<button
key={name}
key={entry.value}
type="button"
className="rail-category"
data-active={category === name}
onClick={() => onCategory(category === name ? '' : name)}
data-active={category === entry.value}
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
>
{name}
{entry.label}
</button>
))}
</>

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { Product } from '@/api/types';
import { matchesBrand, tenantBrands } from './tenantBrands';
const p = (productbrand?: string, productid = 0) => ({ productid, productbrand }) as Product;
test('counts only the brands the shop actually carries', () => {
const brands = tenantBrands([p('Amul'), p('Britannia'), p('Amul'), p('Amul')]);
assert.deepEqual(brands, [
{ brand: 'Amul', product_count: 3 },
{ brand: 'Britannia', product_count: 1 },
]);
});
test('one shelf, however the row was typed', () => {
// An import writes "BRITANNIA", somebody types "Britannia". A shopkeeper has
// one Britannia shelf, so splitting the rail in two would be wrong.
const brands = tenantBrands([p('Britannia'), p('BRITANNIA'), p(' britannia ')]);
assert.equal(brands.length, 1);
assert.equal(brands[0]?.product_count, 3);
assert.equal(brands[0]?.brand, 'Britannia', 'the first spelling seen is the one shown');
});
test('rows with no brand are left out of the rail', () => {
// They stay in the unfiltered list, which is why the rail is handed the real
// total rather than the sum of these counts.
const brands = tenantBrands([p('Amul'), p(''), p(undefined), p(' ')]);
assert.deepEqual(brands, [{ brand: 'Amul', product_count: 1 }]);
});
test('biggest shelf first, alphabetical within a tie', () => {
const brands = tenantBrands([p('Zebra'), p('Apple'), p('Mango'), p('Mango')]);
assert.deepEqual(
brands.map((b) => b.brand),
['Mango', 'Apple', 'Zebra'],
);
});
test('a listed brand can never filter to nothing', () => {
const rows = [p('Amul'), p('BRITANNIA'), p(undefined)];
for (const entry of tenantBrands(rows)) {
assert.ok(
rows.some((row) => matchesBrand(row, entry.brand)),
`${entry.brand} is offered but matches no row`,
);
}
});
test('no brand chosen means no filter', () => {
assert.equal(matchesBrand(p(undefined), ''), true);
assert.equal(matchesBrand(p('Amul'), ''), true);
assert.equal(matchesBrand(p(undefined), 'Amul'), false);
assert.equal(matchesBrand(p('amul'), 'Amul'), true, 'matching ignores case like the grouping does');
});

View File

@@ -0,0 +1,50 @@
import type { CatalogueBrand, Product } from '@/api/types';
/**
* The brands a SHOP actually stocks, counted from its own rows.
*
* The platform catalogue's brands are Postgres tables, discovered from
* `information_schema` by `getbrands`, which reports a row count per table. None
* of that applies here. A store's products come from `getlocationproducts`, and
* the brand is a plain `productbrand` string on each row — so feeding the rail
* `useCatalogueBrands()` would list every brand on the platform, most of which
* the shop has never carried, each with a count belonging to a different table.
* The filter would be full of dead options and the numbers beside them would be
* about somebody else's catalogue.
*
* Counting the rows in hand is the only source that answers "which brands are on
* MY shelves", and it has the property the rail needs: picking a brand can never
* return nothing, because the brand is only listed if something has it.
*
* Grouped case-insensitively — "Britannia" and "BRITANNIA" are one brand to a
* shopkeeper, and an import that disagrees with a hand-typed row should not
* split the shelf in two. The first spelling seen is the one shown, since there
* is no authority here to normalise against.
*/
export function tenantBrands(products: readonly Product[]): CatalogueBrand[] {
const byKey = new Map<string, CatalogueBrand>();
for (const product of products) {
const name = product.productbrand?.trim();
/* No brand is not a brand. These stay out of the rail and remain visible
under "All brands" — which is why the rail is given the real total
separately rather than summing these counts. */
if (!name) continue;
const key = name.toLowerCase();
const seen = byKey.get(key);
if (seen) seen.product_count += 1;
else byKey.set(key, { brand: name, product_count: 1 });
}
/* Biggest shelf first, alphabetical within a tie. A shop's own list is short
enough that this is the whole ordering question. */
return [...byKey.values()].sort(
(a, b) => b.product_count - a.product_count || a.brand.localeCompare(b.brand),
);
}
/** Whether a row belongs to the chosen brand. `''` means no brand filter. */
export function matchesBrand(product: Product, brand: string): boolean {
if (!brand) return true;
return (product.productbrand?.trim().toLowerCase() ?? '') === brand.trim().toLowerCase();
}

View File

@@ -184,59 +184,258 @@
.link-quiet { font: 500 12.5px/1 var(--font-sans); color: var(--color-brand); text-decoration: none; }
.link-quiet:hover { text-decoration: underline; }
/* ── Sales overview ─────────────────────────────────────────────────────── */
.sales-split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 720px) { .sales-split { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
/* ── Sales overview ─────────────────────────────────────────────────────────
.chart-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; }
.chart-axis {
display: flex; flex-direction: column; justify-content: space-between; height: 190px;
text-align: right; font: 400 10.5px/1 var(--font-sans); color: var(--color-ink-4);
The chart's two colours are here, and they are measured rather than chosen.
The pair before them was the brand purple and the brand at 32% on white, and
the palette checks failed three ways: #662582 sits at OKLCH L 0.406, below
the 0.43 floor for a chart mark; the 32% mix came out at chroma 0.047, which
reads as grey rather than as a colour; and it cleared 1.82:1 against white,
under the 3:1 minimum. The second channel was a mark you could not reliably
see.
These two clear every check on white with no warnings — lightness band,
chroma floor, CVD separation (ΔE 27.4 worst adjacent under protanopia),
normal-vision floor (29.4) and contrast. Purple + BLUE was the obvious
alternative and it fails: ΔE 5.8 under deuteranopia, well under the 8 target.
--chart-online is a lighter step of the Nearle purple rather than the brand
itself, because the brand is out of the band for a mark. It is used as this
series and nowhere else.
Dark mode is not reachable today (main.tsx pins mode="light"), so only the
light pair is validated. A dark theme needs its own steps and its own run of
the checks, not an automatic flip. */
:root {
--chart-online: #8b3fb5;
--chart-counter: #e0bdf0; /* Lighter tint of primary purple */
}
.chart-block { display: flex; flex-direction: column; gap: 16px; }
/* ── The plot ───────────────────────────────────────────────────────────────
Two stacked rows — plot and day labels — so the container's height INCLUDES
the x-axis band. A fixed height around the plot alone is what gives a card
its own little nested scrollbar. */
.plot {
position: relative;
display: grid;
grid-template-rows: 200px auto;
padding-left: 46px; /* the axis gutter the tick labels sit in */
}
/* The grid is drawn under the marks: hairline, solid, one step off the
surface. Never dashed — dashing reads as "threshold" when it is just a
grid. */
.plot-grid {
grid-row: 1;
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.plot-gridline {
position: relative;
border-top: 0;
}
.plot-tick {
position: absolute;
right: calc(100% + 8px);
top: -0.55em;
font: 400 10.5px/1 var(--font-sans);
color: var(--color-ink-4);
font-variant-numeric: tabular-nums; /* a column of numbers: tabular */
white-space: nowrap;
}
.plot-cols {
grid-row: 1;
grid-column: 1;
display: flex;
align-items: flex-end;
gap: 4px;
min-width: 0;
}
.plot-col {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
border: 0;
background: none;
}
.plot-col:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* The stack. Capped at 24px — a column that fills its slot leaves no air, and
the leftover band is what makes the hover target bigger than the mark. */
.plot-stack {
display: flex;
flex-direction: column-reverse; /* counter at the base, online on top */
width: 100%;
max-width: 28px;
min-height: 2px;
gap: 0; /* removed surface gap between segments */
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
border-radius: 6px 6px 0 0;
}
.plot-col[data-hover='true'] .plot-stack { opacity: 0.78; }
.plot-seg { min-height: 0; }
/* 4px rounded data-end, square at the baseline: only the top of the stack is
rounded, and only because it is the top. */
.plot-stack > .plot-seg:last-child { border-radius: 4px 4px 0 0; }
.plot-seg[data-kind='online'] { background: var(--chart-online); }
.plot-seg[data-kind='counter'] { background: var(--chart-counter); }
/* One direct label, on the tallest column. Selective by design — a number on
every column is chaos and goes unread. */
.plot-peak {
position: absolute;
left: 50%;
transform: translate(-50%, -4px);
font: 600 10.5px/1 var(--font-sans);
color: var(--color-ink-2); /* a text token, never the series colour */
white-space: nowrap;
pointer-events: none;
}
/* ── Tooltip ────────────────────────────────────────────────────────────── */
.plot-tip {
position: absolute;
/* Pinned to the top of the PLOT, not floated above the bar.
Anchored to the bar it was clipped: the panel carries `overflow: hidden`
so its header band cannot square off the card corners, and the tallest
column's tooltip ran straight through the top edge and lost its first
row. Hanging from the plot ceiling it is always inside the card, whatever
the column height, and the hovered column is tinted so the pairing is
still obvious. */
top: 0;
left: 50%;
transform: translate(-50%, 0);
z-index: 5;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 152px;
padding: 9px 11px;
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 0.18);
pointer-events: none;
}
.plot-tip-day {
font: 600 11.5px/1.2 var(--font-sans);
color: var(--color-ink-1);
padding-bottom: 3px;
border-bottom: 1px solid var(--color-line);
}
.plot-tip-row {
display: flex;
align-items: center;
gap: 7px;
font: 400 11.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
}
.plot-tip-row b {
margin-left: auto;
font-weight: 600;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.chart2 {
display: flex; align-items: flex-end; gap: 10px; height: 190px; overflow-x: auto;
border-left: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); padding: 0 6px;
}
.chart2-col { flex: 1 0 34px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; }
.chart2-bars { display: flex; align-items: flex-end; gap: 3px; height: 100%; width: 100%; justify-content: center; }
.chart2-bar { width: 11px; border-radius: 4px 4px 0 0; min-height: 2px; transition: opacity .15s ease; }
.chart2-bar[data-kind="online"] { background: var(--color-brand); }
/* The second channel is the same hue at lower weight, so the pair reads as one
family rather than two unrelated categories. */
.chart2-bar[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.chart2-col:hover .chart2-bar { opacity: .78; }
.chart2-label { font: 500 10.5px/1 var(--font-sans); color: var(--color-ink-4); white-space: nowrap; }
.chart-legend { grid-column: 2; display: flex; gap: 16px; padding-top: 6px; }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; font: 400 12px/1 var(--font-sans); color: var(--color-ink-3); }
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind="online"] { background: var(--color-brand); }
.chart-legend i[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.donut-wrap { display: flex; align-items: center; justify-content: center; }
.donut { position: relative; width: 160px; height: 160px; }
.donut svg { width: 100%; height: 100%; }
.donut-track { fill: none; stroke: var(--color-surface-subtle); stroke-width: 18; }
.donut-fill { fill: none; stroke: var(--color-brand); stroke-width: 18; transition: stroke-dasharray .4s ease; }
.donut-centre { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; }
.donut-centre span { font: 400 11px/1 var(--font-sans); color: var(--color-ink-3); }
.donut-centre strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.donut-key { width: 10px; height: 10px; border-radius: 999px; flex: none; }
.donut-key[data-kind="online"] { background: var(--color-brand); }
.donut-key[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
/* The channel split, on one line beneath the card. Two columns so the pair
sits square against each other; stacked only when there is genuinely no room
for both. */
.split-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-top: 14px;
.plot-tip-row[data-total='true'] {
padding-top: 3px;
border-top: 1px solid var(--color-line);
}
.plot-tip-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.plot-tip-row i[data-kind='online'] { background: var(--chart-online); }
.plot-tip-row i[data-kind='counter'] { background: var(--chart-counter); }
/* Flip at the edges so the first and last day's tooltip stays on the card. */
.plot-col:first-child .plot-tip { left: 0; transform: translate(0, 0); }
.plot-col:last-child .plot-tip { left: auto; right: 0; transform: translate(0, 0); }
/* ── Day labels ─────────────────────────────────────────────────────────── */
.plot-days {
grid-row: 2;
grid-column: 1;
display: flex;
gap: 4px;
padding-top: 8px;
}
.plot-day {
flex: 1 1 0;
min-width: 0;
text-align: center;
font: 400 10.5px/1.2 var(--font-sans);
color: var(--color-ink-4);
white-space: nowrap;
}
/* ── Legend and the table toggle ────────────────────────────────────────── */
.chart-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.chart-legend { display: flex; gap: 16px; }
.chart-legend span {
display: inline-flex; align-items: center; gap: 6px;
font: 400 12px/1 var(--font-sans); color: var(--color-ink-3);
}
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind='online'] { background: var(--chart-online); }
.chart-legend i[data-kind='counter'] { background: var(--chart-counter); }
.chart-toggle {
border: 0; background: none; padding: 0; cursor: pointer;
font: 600 12px/1 var(--font-sans); color: var(--color-brand);
}
.chart-toggle:hover { text-decoration: underline; }
.chart-toggle:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
/* ── Table view ─────────────────────────────────────────────────────────── */
.chart-table { max-height: 232px; overflow-y: auto; }
.chart-table table { width: 100%; border-collapse: collapse; }
.chart-table th {
position: sticky; top: 0;
text-align: left; padding: 7px 10px;
background: var(--color-surface-subtle);
font: 600 10.5px/1 var(--font-sans);
letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-ink-3);
border-bottom: 1px solid var(--color-line);
}
.chart-table td {
padding: 8px 10px;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-1);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
}
.chart-table tr:last-child td { border-bottom: 0; }
.chart-table .num { text-align: right; font-variant-numeric: tabular-nums; }
/* ── The channel split ──────────────────────────────────────────────────────
One bar, then the two figures. This is what a 160px two-slice donut was
doing, in the form that can be read rather than estimated. */
.split {
display: flex; flex-direction: column; gap: 10px;
padding-top: 14px; border-top: 1px solid var(--color-line);
}
.split-bar { display: flex; height: 10px; gap: 2px; }
.split-bar span:first-child { border-radius: 999px 0 0 999px; }
.split-bar span:last-child { border-radius: 0 999px 999px 0; }
.split-bar span[data-kind='online'] { background: var(--chart-online); }
.split-bar span[data-kind='counter'] { background: var(--chart-counter); }
.split-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@media (max-width: 520px) { .split-row { grid-template-columns: minmax(0, 1fr); } }
.share {
@@ -245,15 +444,22 @@
border: var(--card-border); border-radius: var(--card-radius-sm);
background: var(--card-bg-subtle);
}
.share .donut-key { align-self: center; }
.share-key { width: 10px; height: 10px; border-radius: 999px; flex: none; align-self: center; }
.share-key[data-kind='online'] { background: var(--chart-online); }
.share-key[data-kind='counter'] { background: var(--chart-counter); }
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
/* The percentage leads: the question the split answers is "how much of my
trade comes through each", and the rupee figure is already in the KPI card
above. */
above. Proportional figures, not tabular — this is a standalone value, not a
column of them. */
.share-pct { font: 600 18px/1 var(--font-sans); color: var(--color-ink-1); margin-left: auto; }
.share-amt {
font: 500 13px/1 var(--font-sans); color: var(--color-brand);
font-variant-numeric: tabular-nums;
.share-amt { font: 500 13px/1 var(--font-sans); color: var(--color-ink-2); font-variant-numeric: tabular-nums; }
@media (max-width: 600px) {
.plot { padding-left: 40px; grid-template-rows: 170px auto; }
}
@media (prefers-reduced-motion: reduce) {
.plot-stack { transition: none; }
}
/* ── Store health ───────────────────────────────────────────────────────── */
@@ -309,26 +515,22 @@
.heartbeat[data-tone="critical"] i { background: #b3261e; }
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.chip-row { display: inline-flex; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: 999px; cursor: pointer; border: 1px solid var(--color-line);
background: var(--color-surface); font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2);
}
.chip b { font-weight: 600; opacity: .7; }
.chip[data-active="true"] { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
.chip[data-tone="attention"][data-active="true"] { background: #8a5a00; border-color: #8a5a00; }
.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` and `.chip-row` were here — the filter strip on the Console’s Till
sync panel and on Counters. Both render `components/TabBar` at `size="sm"`
now, the same control as the Sales status ladder.
The tones went with them: green on Healthy, amber on Attention, red on
Offline. They only ever applied to the SELECTED chip, so a strip at rest
was plain pills anyway, and this console’s position is that it does not
colour-code severity — see the note where `BUCKET_COLOR` used to be in
`terminalProblems.ts`. The label and the count say which rung is bad. */
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
input version of the search box. Both use `components/SearchInput` now. */
/* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
.seg-btn { border: 0; background: none; padding: 6px 14px; border-radius: 8px; font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2); cursor: pointer; }
.seg-btn[data-active="yes"] { background: var(--color-brand); color: #fff; }
.seg-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* The `.seg` segmented control was here — an eighth treatment of "tab", used by
the Sales overview's Revenue / Orders switch. That switch is
`components/TabBar` at `size="sm"` now, like every other one in the console. */
/* ── Needs your attention ───────────────────────────────────────────────── */
.attention-head { display: flex; gap: 12px; align-items: flex-start; }
@@ -371,5 +573,7 @@
@media (prefers-reduced-motion: reduce) {
.sk-card, .sk-chart, .console-live i { animation: none; }
.chart2-bar, .donut-fill { transition: none; }
/* `.chart2-bar` and `.donut-fill` were named here. Both are gone — the chart
is stacked columns and the split is a bar, not a ring — and the column's
own transition is handled beside it, in the Sales overview block. */
}

View File

@@ -1,20 +1,37 @@
import { useMemo, useState } from 'react';
import { Tab, TabBar } from '@/components/TabBar';
import { money, count } from '@/features/store-admin/format';
import type { DaySeries } from '../useConsoleSeries';
/**
* Revenue and orders over the period: the shape on the left, the split on the
* right.
* Revenue and orders over the period.
*
* Two views of one dataset, deliberately. The columns answer "when did it
* happen"; the ring answers "where did it come from". Either alone leaves a
* shopkeeper doing arithmetic in their head — a good week of counter sales and
* a collapse in app orders look identical in a single total.
* ── What this replaced, and why each piece went ─────────────────────────────
*
* Drawn by hand rather than with a charting library. It is a few dozen columns
* and one ring; a library would be more code shipped than the chart it draws,
* and this way the bars use the same brand token as everything else on the page
* instead of a palette of its own.
* **The donut is gone.** It was a ring showing one share — online against
* counter — with the same two figures spelled out in a row directly beneath
* it. A two-slice ring is the textbook case for "the number is the chart": it
* spends 160×160px to say what "68% / 32%" says in nine characters, and it says
* it less precisely. The share is now a single slim bar above the two figures,
* which is the form for part-to-whole and doubles as the legend key.
*
* **The columns are stacked, not grouped.** Two thin bars per day meant 60
* marks across a 30-day range at ~11px each. Stacked, a column's height is the
* day's takings — the thing a shopkeeper actually reads off this chart — and
* the split is still visible inside it.
*
* **The colours are measured, not chosen.** The old pair was the brand purple
* and the brand at 32% on white, and running them through the palette checks
* failed three ways: the brand sits at OKLCH L 0.406, below the 0.43 floor for
* a chart mark; the 32% mix landed at chroma 0.047, which reads as grey rather
* than as a colour; and it cleared only 1.82:1 against white, under the 3:1
* minimum. So the second channel was a mark you could not reliably see. The
* pair below is validated — see `--chart-online` in `console.css`.
*
* **There is a real tooltip.** Values used to live in `title` attributes: a
* browser tooltip after a second's delay, unstyled, invisible on touch. And a
* table view, because with a stacked column the per-channel figure for one day
* exists nowhere else on the page — a tooltip must enhance, never gate.
*/
export interface SalesOverviewProps {
days: readonly DaySeries[];
@@ -25,6 +42,8 @@ type Measure = 'revenue' | 'orders';
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
const [measure, setMeasure] = useState<Measure>('revenue');
const [isTable, setIsTable] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const series = useMemo(
() =>
@@ -36,16 +55,41 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
[days, measure],
);
/* The axis tops out at a round number at or above the tallest column, so the
ticks read 0 / 5,000 / 10,000 rather than 0 / 4,317 / 8,634. */
const peak = useMemo(
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
() => Math.max(1, ...series.map((d) => d.online + d.counter)),
[series],
);
const axisTop = useMemo(() => niceCeiling(peak), [peak]);
const ticks = useMemo(
() => [1, 0.75, 0.5, 0.25, 0].map((step) => Math.round(axisTop * step)),
[axisTop],
);
/* Labelled directly, because it is the one column worth naming. Everything
else is carried by the axis, the tooltip and the table. */
const peakIndex = useMemo(() => {
let best = -1;
let bestTotal = 0;
series.forEach((d, i) => {
const total = d.online + d.counter;
if (total > bestTotal) {
bestTotal = total;
best = i;
}
});
return best;
}, [series]);
const online = series.reduce((sum, d) => sum + d.online, 0);
const counter = series.reduce((sum, d) => sum + d.counter, 0);
const total = online + counter;
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
const axisFormat = (value: number) =>
measure === 'revenue' ? compactMoney(value) : count(value);
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
: `Revenue and orders for the last ${days.length} days`}
</p>
</div>
<div className="seg" role="tablist" aria-label="Measure">
{(['revenue', 'orders'] as const).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={measure === m}
className="seg-btn"
data-active={measure === m ? 'yes' : 'no'}
onClick={() => setMeasure(m)}
>
{m === 'revenue' ? 'Revenue' : 'Orders'}
</button>
))}
</div>
<TabBar size="sm" aria-label="Measure">
<Tab
label="Revenue"
isActive={measure === 'revenue'}
onClick={() => setMeasure('revenue')}
/>
<Tab label="Orders" isActive={measure === 'orders'} onClick={() => setMeasure('orders')} />
</TabBar>
</header>
{total === 0 ? (
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
</div>
) : (
<div className="sales-split">
<div className="chart-wrap">
{/* The axis is drawn from the data rather than fixed, so a shop
taking ₹800 a day gets a readable scale instead of a flat line
along the bottom of a ₹20K grid. */}
<div className="chart-axis" aria-hidden>
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
<span key={step}>{format(Math.round(peak * step))}</span>
))}
</div>
<div className="chart2">
{series.map((d) => (
<div key={d.day} className="chart2-col">
<div className="chart2-bars">
<span
className="chart2-bar"
data-kind="online"
style={{ height: `${(d.online / peak) * 100}%` }}
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
/>
<span
className="chart2-bar"
data-kind="counter"
style={{ height: `${(d.counter / peak) * 100}%` }}
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
/>
<div className="chart-block">
{isTable ? (
<SeriesTable rows={series} format={format} />
) : (
<div
className="plot"
onMouseLeave={() => setHovered(null)}
role="img"
aria-label={`${measure === 'revenue' ? 'Revenue' : 'Orders'} by day, online and counter`}
>
{/* The grid is drawn first and sits under the marks: hairline,
solid, one step off the surface. Its rows and the axis labels
come from the same tick list, so they cannot drift apart. */}
<div className="plot-grid" aria-hidden>
{ticks.map((tick) => (
<div key={tick} className="plot-gridline">
<span className="plot-tick">{axisFormat(tick)}</span>
</div>
<span className="chart2-label">{shortDay(d.day)}</span>
</div>
))}
</div>
))}
</div>
<div className="chart-legend">
<span><i data-kind="online" />Online</span>
<span><i data-kind="counter" />Counter</span>
<div className="plot-cols">
{series.map((d, index) => {
const dayTotal = d.online + d.counter;
return (
<div
key={d.day}
className="plot-col"
data-hover={hovered === index}
onMouseEnter={() => setHovered(index)}
onFocus={() => setHovered(index)}
onBlur={() => setHovered(null)}
tabIndex={0}
aria-label={`${longDay(d.day)}: online ${format(d.online)}, counter ${format(d.counter)}`}
>
{/* The stack. Counter sits under online so the brand
colour caps the column, and the two are separated by a
gap in the surface rather than by a stroke. */}
<div className="plot-stack" style={{ height: `${(dayTotal / axisTop) * 100}%` }}>
<span
className="plot-seg"
data-kind="online"
style={{ flexBasis: `${pct(d.online, dayTotal)}%` }}
/>
<span
className="plot-seg"
data-kind="counter"
style={{ flexBasis: `${pct(d.counter, dayTotal)}%` }}
/>
</div>
{index === peakIndex ? (
<span className="plot-peak" style={{ bottom: `${(dayTotal / axisTop) * 100}%` }}>
{format(dayTotal)}
</span>
) : null}
{hovered === index ? (
<div className="plot-tip" role="status">
<span className="plot-tip-day">{longDay(d.day)}</span>
<span className="plot-tip-row">
<i data-kind="online" />
Online
<b>{format(d.online)}</b>
</span>
<span className="plot-tip-row">
<i data-kind="counter" />
Counter
<b>{format(d.counter)}</b>
</span>
<span className="plot-tip-row" data-total="true">
Total<b>{format(dayTotal)}</b>
</span>
</div>
) : null}
</div>
);
})}
</div>
{/* Every date is drawn — see `showLabel`. */}
<div className="plot-days" aria-hidden>
{series.map((d) => (
<span key={d.day} className="plot-day">
{showLabel() ? shortDay(d.day) : ''}
</span>
))}
</div>
</div>
)}
<div className="chart-foot">
<div className="chart-legend">
<span>
<i data-kind="online" />
Online
</span>
<span>
<i data-kind="counter" />
Counter
</span>
</div>
<button type="button" className="chart-toggle" onClick={() => setIsTable((v) => !v)}>
{isTable ? 'Show chart' : 'Show table'}
</button>
</div>
<div className="donut-wrap">
<Donut share={onlineShare} total={format(total)} />
{/* The channel split: one bar, then the figures. This is what the
donut was for, in a form that can be read rather than estimated. */}
<div className="split">
<div className="split-row">
<Share kind="online" label="Online" percent={onlineShare} amount={format(online)} />
<Share
kind="counter"
label="Counter"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
</div>
</div>
)}
{/* The channel split, on one line under the whole card.
It was a stacked column beside the ring, which put the two figures a
shopkeeper compares — online against counter — one above the other in
a narrow gutter. Side by side they can be read against each other at a
glance, and the row runs the full width rather than being squeezed
into whatever the ring left over. */}
{total > 0 ? (
<div className="split-row">
<Share
kind="online"
label="Online Sales"
percent={onlineShare}
amount={format(online)}
/>
<Share
kind="counter"
label="Counter Sales"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
) : null}
</section>
);
}
function Share({
kind, label, percent, amount,
}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) {
kind,
label,
percent,
amount,
}: {
kind: 'online' | 'counter';
label: string;
percent: number;
amount: string;
}) {
return (
<div className="share">
<span className="donut-key" data-kind={kind} />
<span className="share-key" data-kind={kind} />
<span className="share-label">{label}</span>
<strong className="share-pct">{percent}%</strong>
<span className="share-amt">{amount}</span>
@@ -170,42 +270,101 @@ function Share({
}
/**
* The channel split as a ring.
* The same numbers as a table.
*
* One circle with a dash offset rather than two arcs: a single stroke cannot
* develop a seam at the join, and the whole thing animates from one number.
* Not a nicety: in a stacked column the per-channel figure for a single day is
* otherwise only in the hover tooltip, which is no use on a touch screen, to a
* screen reader, or to anyone printing the page.
*/
function Donut({ share, total }: { share: number; total: string }) {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const filled = (share / 100) * circumference;
function SeriesTable({
rows,
format,
}: {
rows: readonly { day: string; online: number; counter: number }[];
format: (value: number) => string;
}) {
return (
<div className="donut">
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
<circle cx="70" cy="70" r={radius} className="donut-track" />
<circle
cx="70"
cy="70"
r={radius}
className="donut-fill"
strokeDasharray={`${filled} ${circumference - filled}`}
// Start at twelve o'clock; the default is three, which reads as a
// slice that has already been eaten.
transform="rotate(-90 70 70)"
/>
</svg>
<div className="donut-centre">
<span>Total Sales</span>
<strong>{total}</strong>
</div>
<div className="chart-table">
<table>
<thead>
<tr>
<th>Day</th>
<th className="num">Online</th>
<th className="num">Counter</th>
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.day}>
<td>{longDay(d.day)}</td>
<td className="num">{format(d.online)}</td>
<td className="num">{format(d.counter)}</td>
<td className="num">
<strong>{format(d.online + d.counter)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0);
/**
* The next round number at or above `value` — 1, 2 or 5 times a power of ten.
*
* An axis whose top is the tallest column produces ticks like 4,317 and 8,634,
* which nobody can read a bar against. This is what makes them 5,000 and
* 10,000.
*/
function niceCeiling(value: number): number {
if (value <= 0) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
/** "₹12.4K" for an axis tick, where the full figure would not fit the gutter. */
function compactMoney(value: number): string {
if (value >= 10_000_000) return `₹${(value / 10_000_000).toFixed(1).replace(/\.0$/, '')}Cr`;
if (value >= 100_000) return `₹${(value / 100_000).toFixed(1).replace(/\.0$/, '')}L`;
if (value >= 1_000) return `₹${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return money(value);
}
/**
* Which x labels to draw.
*
* Currently: all of them. The thinning this used to do — keep about six,
* always including the first and the last — is gone by choice, so every date
* is drawn.
*
* It keeps its own function rather than being inlined at the call site, so the
* decision has somewhere to live and thinning is one edit away if a 30-day
* range turns out to overlap. It takes NO arguments: it was passed the index
* and the length, and with the body returning a constant those became unused
* parameters, which `noUnusedParameters` fails the build on.
*/
function showLabel(): boolean {
return true;
}
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
function shortDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
}
/** "2026-09-03" → "Wed, 3 Sep" — the tooltip and table have room for the day. */
function longDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' });
}

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
@@ -99,17 +100,26 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
</div>
<div className="till-controls">
<div className="chip-row">
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
All <b>{tills.length}</b>
</button>
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
Attention <b>{needing}</b>
</button>
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
Healthy <b>{healthy}</b>
</button>
</div>
<TabBar size="sm" aria-label="Filter tills">
<Tab
label="All"
count={tills.length}
isActive={filter === 'all'}
onClick={() => setFilter('all')}
/>
<Tab
label="Attention"
count={needing}
isActive={filter === 'attention'}
onClick={() => setFilter('attention')}
/>
<Tab
label="Healthy"
count={healthy}
isActive={filter === 'healthy'}
onClick={() => setFilter('healthy')}
/>
</TabBar>
<SearchInput
label="Search tills"

View File

@@ -3,6 +3,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Clock } from 'lucide-react';
import { Tab, TabBar } from '@/components/TabBar';
import { PageHeader } from '@/components/PageHeader';
import { useAllPartners } from '@/queries/hooks';
import { DispatchBoard } from '@/features/store-admin/pages/DispatchPage';
@@ -90,36 +91,29 @@ export function NearleDispatchPage() {
</HStack>
}
tabs={
/* The store board's own tab styling — `.sbt`, defined in
`dispatch.css`, which this page already loads through
`DispatchBoard`.
/* The console's segmented control, the same one the store board and
every other page uses.
These were written against a `.mode-tab` class that exists in no
stylesheet, so they rendered as bare browser buttons directly above
a row of properly styled ones. Nothing catches that: the markup is
valid, the class is just never matched. */
<div className="nearle-view-tabs">
<button
type="button"
className={view === 'work' ? 'sbt active' : 'sbt'}
These borrowed `.sbt` from `dispatch.css` — the store board's own
tab styling, loaded here through `DispatchBoard`. Before that they
were written against a `.mode-tab` class that exists in no
stylesheet at all, so they rendered as bare browser buttons above a
row of styled ones. Nothing catches that: the markup is valid, the
class is just never matched. Both problems end the same way. */
<TabBar aria-label="Dispatch views">
<Tab
label="The work"
icon={<Bike size={14} />}
isActive={view === 'work'}
onClick={() => setView('work')}
>
<span className="sbt-icon">
<Bike size={15} />
</span>
<span>The work</span>
</button>
<button
type="button"
className={view === 'shifts' ? 'sbt active' : 'sbt'}
/>
<Tab
label="Shifts"
icon={<Clock size={14} />}
isActive={view === 'shifts'}
onClick={() => setView('shifts')}
>
<span className="sbt-icon">
<Clock size={15} />
</span>
<span>Shifts</span>
</button>
</div>
/>
</TabBar>
}
/>

View File

@@ -318,7 +318,9 @@ function FilterTab({
alignItems: 'center',
gap: 6,
padding: '5px 12px',
borderRadius: 999,
/* Matches the search box and the tab track it sits beside — see
`--radius-element` in `theme/nearle.ts`. */
borderRadius: 'var(--card-radius-sm, 10px)',
border: `1px solid ${isActive ? 'var(--color-brand, #662582)' : 'var(--color-line, #e0e4ea)'}`,
background: isActive ? 'var(--color-brand-tint, #f4eef8)' : '#ffffff',
color: isActive ? 'var(--color-brand, #662582)' : 'var(--color-ink-2, #414b5a)',

View File

@@ -279,16 +279,15 @@
margin-top: 2px;
}
/* ── The dispatch page's two view tabs ───────────────────────────────────── */
/* ── The dispatch page's two view tabs ─────────────────────────────────────
/* The buttons themselves are `.sbt` from the store board's `dispatch.css`, so
the platform's tabs and the shops' tabs are the same control rather than two
that happen to look alike. Only the row they sit in belongs here: the store
board wraps its tabs in `.strat-row`, a bordered bar that also carries an ops
cluster on the right, and there is nothing to put on the right of these two. */
.nearle-view-tabs {
display: flex;
gap: 5px;
align-items: center;
flex-wrap: wrap;
}
`.nearle-view-tabs` was the flex row that held them. Both the row and the
buttons are gone: the page renders `components/TabBar`, which is its own
track.
The intent behind the old comment here was right — it borrowed `.sbt` from
the store board's `dispatch.css` so "the platform's tabs and the shops' tabs
are the same control rather than two that happen to look alike". It just
reached across a feature boundary to get there, and `.sbt` was itself one of
seven tab treatments. Both boards import the same component now, which is
what that comment was trying to achieve. */

View File

@@ -301,7 +301,7 @@ export function OnboardingPage() {
{step === 'catalogue' ? (
<CatalogueStep
productCount={productCount}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
onUpload={() => navigate('/admin/uploads?upload=1&setup=catalogue')}
/* The catalogue tab, not the products list. This pointed at
`/admin/inventory` bare, which lands on Products — so "import from
the catalogue" showed a merchant their own empty product list. */
@@ -312,7 +312,7 @@ export function OnboardingPage() {
{step === 'inventory' ? (
<InventoryStep
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
onUpload={() => navigate('/admin/uploads?upload=1&setup=inventory')}
/>
) : null}

View File

@@ -199,8 +199,8 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
: options.length > 0
? 'Select a rider…'
: partnerid > 0
? `Nobody on duty — neither this shop's riders nor ${partnerName || "the partner's"}`
: 'Nobody on duty, and this shop has no delivery partner'
? 'No riders or partners on duty'
: 'No riders on duty (no partner)'
}
/>
</div>

View File

@@ -25,12 +25,10 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
}
case 'week': {
// Monday-first. A retail week that starts on Sunday makes Monday's
// takings the previous week's, which is not how anyone here counts.
const weekday = (today.getDay() + 6) % 7;
const monday = new Date(today);
monday.setDate(today.getDate() - weekday);
return { fromdate: isoDay(monday), todate: isoDay(today) };
// 7-day rolling window as expected by the user for "one week data"
const lastWeek = new Date(today);
lastWeek.setDate(today.getDate() - 6);
return { fromdate: isoDay(lastWeek), todate: isoDay(today) };
}
case 'month': {
const first = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -42,7 +40,6 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
}
export interface DateRangePickerProps {
preset: RangePreset;
range: DateRange;
onChange: (preset: RangePreset, range: DateRange) => void;
}
@@ -59,11 +56,25 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
return [
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
/* "Last 7 days", not "This week". `presetRange('week')` is a ROLLING
window now — today less six — so a label reading "This week" named a
calendar week the picker had stopped returning. On a Wednesday the two
differ by four days, and the label was the only thing telling the
reader which one they had asked for. */
{ label: 'Last 7 days', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
];
}, []);
/* No tomorrow.
The calendar had no upper bound, so every future date was selectable: you
could ask this console for next month's takings and it would answer, with
an empty report and no indication that the question was the problem rather
than the shop. A reporting range cannot end in the future — there is
nothing there yet — so the calendar stops at today. */
const today = isoDay(new Date()) as ISODateString;
return (
<div className="datefilter" style={{ width: 180 }}>
<DateRangeInput
@@ -73,6 +84,12 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
isLabelHidden
value={astryxRange}
presets={presets}
max={today}
/* Monday-first. Astryx defaults the grid to Sunday, and a retail week
that starts on Sunday puts Monday's takings in the previous week —
which is not how anyone here counts. This is the calendar GRID only;
it is independent of what the "Last 7 days" preset returns. */
weekStartsOn="mon"
onChange={(newRange) => {
if (!newRange) {
onChange('custom', {});

View File

@@ -1,19 +1,22 @@
import { useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Text } from '@astryxdesign/core/Text';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react';
import { Funnel, ImageOff } from 'lucide-react';
import type { Product } from '@/api/types';
import { Panel } from '@/components/Panel';
import { SelectInput } from '@/components/SelectInput';
import { CatalogueSidebar } from '@/features/catalogue/CatalogueSidebar';
import { matchesBrand, tenantBrands } from '@/features/catalogue/tenantBrands';
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { aisleLabelOf } from './appAisle';
import { useBranchScope } from './BranchScope';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
import { SheetUploadDrawer } from './SheetUploadDrawer';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -41,16 +44,13 @@ import {
* `publishedat` and the ledger balance, because no single backend column tells
* the truth on its own. They are shown per row, not as a filter.
*/
export function ProductsPanel({
isUploading,
onCloseUpload,
}: {
/** Owned by InventoryPage, so its button can sit in the tab row. */
isUploading: boolean;
onCloseUpload: () => void;
}) {
export function ProductsPanel() {
const { scoped, current, tenantid } = useBranchScope();
const [category, setCategory] = useState('');
const [brand, setBrand] = useState('');
/* Closed on arrival, as on the platform catalogue. The rail is a filter, and a
filter that opens by itself has narrowed the page before anyone asked. */
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [open, setOpen] = useState<Product | null>(null);
const [publishing, setPublishing] = useState<Product[] | null>(null);
@@ -90,6 +90,37 @@ export function ProductsPanel({
return out;
}, [aisles.data]);
/* The brands on THIS shop's shelves, counted from its own rows — the platform's
brand tables describe a different catalogue. See `tenantBrands`. */
const brands = useMemo(() => tenantBrands(products.data ?? []), [products.data]);
/**
* The rail's second level: the aisles present INSIDE the chosen brand.
*
* Listing every aisle would offer filters that return nothing — a brand that
* only stocks dairy would still show Bakery, and picking it empties the table.
* The rail is built so that anything it offers has something behind it.
*/
const railCategories = useMemo(() => {
const seen = new Map<string, string>();
for (const product of products.data ?? []) {
if (!matchesBrand(product, brand)) continue;
const id = String(product.subcategoryid ?? 0);
if (seen.has(id)) continue;
seen.set(id, id === '0' ? 'Uncategorized' : (aisleNames.get(Number(id)) ?? 'Uncategorized'));
}
return [...seen.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [products.data, brand, aisleNames]);
/* An aisle chosen inside one brand need not exist inside the next, and a
filter the new brand has never heard of quietly returns nothing — the same
reason the platform catalogue drops its category on a brand change. */
useEffect(() => {
setCategory('');
}, [brand]);
/**
* One list, ordered by what needs doing.
*
@@ -99,7 +130,9 @@ export function ProductsPanel({
*/
const rows = useMemo(() => {
const filtered = (products.data ?? []).filter(
(product) => !category || String(product.subcategoryid ?? 0) === category,
(product) =>
matchesBrand(product, brand) &&
(!category || String(product.subcategoryid ?? 0) === category),
);
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
return [...filtered].sort((a, b) => {
@@ -107,7 +140,7 @@ export function ProductsPanel({
if (byState !== 0) return byState;
return (a.productname ?? '').localeCompare(b.productname ?? '');
});
}, [products.data, category]);
}, [products.data, category, brand]);
/**
* Every product listed can be ticked, not only the unpriced ones.
@@ -138,17 +171,25 @@ export function ProductsPanel({
outside the panel it read as a page-level control that happened to be
nearby. */
const filter = (
<div style={{ width: 190 }}>
<Selector
label="Category"
isLabelHidden
<HStack gap={1} align="center">
{/* Without this, closing the rail hides the fact that a brand is still
filtering the table — the list would be short for no visible reason. */}
{brand ? <Token label={brand} size="sm" onRemove={() => setBrand('')} /> : null}
<IconButton
label={isFiltersOpen ? 'Hide brands' : 'Filter by brand'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<SelectInput
label="Category"
value={category}
onChange={setCategory}
placeholder="All categories"
options={categoryOptions}
width={190}
/>
</div>
</HStack>
);
return (
@@ -175,30 +216,36 @@ export function ProductsPanel({
</div>
) : null}
<ProductTable
rows={rows}
aisleNames={aisleNames}
selection={selection}
queryKey={category}
isLoading={products.isLoading}
actions={filter}
onOpen={setOpen}
onPrice={(product) => setPublishing([product])}
/>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? (
<CatalogueSidebar
brands={brands}
isLoading={products.isLoading}
brand={brand}
category={category}
categories={railCategories}
isLoadingCategories={aisles.isLoading}
/* Unbranded rows are listed but not railed, so the sum would be
short of what the table shows. */
totalCount={(products.data ?? []).length}
onBrand={setBrand}
onCategory={setCategory}
/>
) : null}
<ProductTable
rows={rows}
aisleNames={aisleNames}
selection={selection}
queryKey={`${brand}:${category}`}
isLoading={products.isLoading}
actions={filter}
onOpen={setOpen}
onPrice={(product) => setPublishing([product])}
/>
</div>
{open ? <ProductDrawer product={open} onClose={() => setOpen(null)} /> : null}
{isUploading ? (
<SheetUploadDrawer
tenantid={tenantid || undefined}
/* `current`, never a fallback to the first branch. An upload becomes
opening stock on ONE shelf, so under "All branches" there is no
answer to guess — passing nothing leaves the drawer's own branch
picker to ask, and it blocks the upload until it is answered.
Defaulting here would load a shelf nobody chose. */
locationid={current?.locationid}
onClose={onCloseUpload}
/>
) : null}
{publishing && tenantid ? (
<PublishDialog
products={publishing}

View File

@@ -2,7 +2,7 @@ import { Drawer } from './Drawer';
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
/**
* Upload a product spreadsheet, from the Products tab.
* Upload a product spreadsheet, from the Uploads page.
*
* The same panel the Nearle Admin uses, with one difference that matters: the
* tenant and branch come from the SESSION rather than being asked for. A shop

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput';
import { useDateScope } from '@/components/shell/DateScope';
import { Activity, AlertTriangle, IndianRupee, Monitor } from 'lucide-react';
@@ -113,13 +114,20 @@ export function CountersPage() {
<section className="panel">
<div className="counter-controls">
<div className="chip-row">
<Chip label="All" n={counts.all} active={chip === 'all'} onClick={() => setChip('all')} />
<Chip label="Active" n={counts.active} tone="healthy" active={chip === 'active'} onClick={() => setChip('active')} />
<Chip label="Attention" n={counts.attention} tone="attention" active={chip === 'attention'} onClick={() => setChip('attention')} />
<Chip label="Never used" n={counts.never} active={chip === 'never'} onClick={() => setChip('never')} />
<Chip label="Offline" n={counts.offline} tone="critical" active={chip === 'offline'} onClick={() => setChip('offline')} />
</div>
{/* The tones these carried — green on Active, amber on Attention, red
on Offline — are not reproduced, and their absence is the point.
They only applied to the SELECTED chip, so the strip at rest was
five plain pills anyway, and this console's stated position is
that it does not colour-code severity: see the note where
`BUCKET_COLOR` used to be in `terminalProblems.ts`. The labels
and the counts say which rung is the bad one. */}
<TabBar size="sm" aria-label="Filter counters">
<Tab label="All" count={counts.all} isActive={chip === 'all'} onClick={() => setChip('all')} />
<Tab label="Active" count={counts.active} isActive={chip === 'active'} onClick={() => setChip('active')} />
<Tab label="Attention" count={counts.attention} isActive={chip === 'attention'} onClick={() => setChip('attention')} />
<Tab label="Never used" count={counts.never} isActive={chip === 'never'} onClick={() => setChip('never')} />
<Tab label="Offline" count={counts.offline} isActive={chip === 'offline'} onClick={() => setChip('offline')} />
</TabBar>
</div>
<div className="counter-controls">
@@ -279,21 +287,8 @@ function Kpi({
);
}
function Chip({
label, n, tone, active, onClick,
}: { label: string; n: number; tone?: string; active: boolean; onClick: () => void }) {
return (
<button
type="button"
className="chip"
{...(tone ? { 'data-tone': tone } : {})}
data-active={active}
onClick={onClick}
>
{label} <b>{n}</b>
</button>
);
}
/* The page-local `Chip` lived here — it is the `sm` size of
`components/TabBar` now. */
/**
* The counters somebody has to walk to.

View File

@@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar';
import { useSelection, type Selection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
@@ -231,80 +232,69 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
return (
<div className="dispatch-container">
{/* ── Unified Strategy & Operations Toolbar (xpressconsole #strat-row style) ── */}
<div id="strat-row" className="strat-row">
<div className="strat-tabs">
<button
type="button"
className={`sbt ${mode === 'riders' ? 'active' : ''}`}
onClick={() => changeMode('riders')}
>
<span className="sbt-icon"><Bike size={15} /></span>
<span>By Rider</span>
<span className="sbt-badge">{totals.riders}</span>
</button>
{/* The view switcher, on the console's own segmented control.
These six were `.sbt` — a seventh treatment of "tab" in this app:
bordered when idle, a solid brand fill with a purple glow when
selected. They now read exactly like the view tabs on Sales, Users,
Inventory and the rest.
The `.strat-row` card around them is gone too. It was a bordered,
shadowed toolbar whose only remaining child was this strip — the
operations cluster beside it was removed earlier — and a card holding
nothing but a tab track is a box drawn round a box. Every other page
stands its switcher directly on the page. */}
<TabBar aria-label="Dispatch views">
<Tab
label="By rider"
icon={<Bike size={14} />}
count={totals.riders || undefined}
isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/>
<Tab
label="By store"
icon={<Store size={14} />}
count={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
{/* A shop's customer book is its own. Grouping a partner's rounds by
customer would mean reading every merchant's book to put a name to
a phone number, so the tab is not offered on a partner board. */}
{scope.kind === 'tenant' ? (
<Tab
label="By customer"
icon={<Users size={14} />}
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
) : null}
<button
type="button"
className={`sbt ${mode === 'stores' ? 'active' : ''}`}
onClick={() => changeMode('stores')}
>
<span className="sbt-icon"><Store size={15} /></span>
<span>By Store</span>
<span className="sbt-badge">{branches.length}</span>
</button>
{/* A shop's customer book is its own. Grouping a partner's rounds by
customer would mean reading every merchant's book to put a name to
a phone number, so the tab is not offered on a partner board. */}
{scope.kind === 'tenant' ? (
<button
type="button"
className={`sbt ${mode === 'customers' ? 'active' : ''}`}
onClick={() => changeMode('customers')}
>
<span className="sbt-icon"><Users size={15} /></span>
<span>By Customer</span>
</button>
) : null}
<button
type="button"
className={`sbt ${mode === 'active' ? 'active' : ''}`}
onClick={() => changeMode('active')}
>
<span className="sbt-icon"><Radio size={15} /></span>
<span>Active Fleet</span>
<span className="sbt-dot-live" />
</button>
<button
type="button"
className={`sbt ${mode === 'timing' ? 'active' : ''}`}
onClick={() => changeMode('timing')}
>
<span className="sbt-icon"><Timer size={15} /></span>
<span>Plan vs Actual</span>
</button>
<button
type="button"
className={`sbt ${mode === 'profit' ? 'active' : ''}`}
onClick={() => changeMode('profit')}
>
<span className="sbt-icon"><IndianRupee size={15} /></span>
<span>Unit Economics</span>
</button>
</div>
{/* No operations cluster.
What stood here was a hub picker with one hard-coded hub, a demo /
live toggle, a rider count read off the mock fleet, and a profit
chip computed as `value * 0.28` labelled "(84%)" — a number with no
source. All four described the page rather than the day's work, and
three of them were not true. The KPI row below carries the figures
that come from the data. */}
</div>
{/* The three below group the day differently from the three above:
those slice the same stops by whose they are, these report on how
the day went. The rule in the track says so. */}
<Tab
label="Active fleet"
icon={<Radio size={14} />}
isActive={mode === 'active'}
onClick={() => changeMode('active')}
isGroupStart
isLive
/>
<Tab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<Tab
label="Unit economics"
icon={<IndianRupee size={14} />}
isActive={mode === 'profit'}
onClick={() => changeMode('profit')}
/>
</TabBar>
{/* The same four-card row every other page uses — same `kpi-grid`, same

View File

@@ -14,7 +14,6 @@ import {
CheckCheck,
ClipboardList,
DownloadCloud,
UploadCloud,
IndianRupee,
Info,
Layers,
@@ -96,10 +95,6 @@ export function InventoryPage() {
const setupProducts = useLocationProducts(tenantid || undefined, undefined, 0, {
allBranches: true,
});
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
);
@@ -129,29 +124,20 @@ export function InventoryPage() {
<PageHeader
isTabsInline
title="Inventory"
/* Upload sheet lives here, not in the tab row, so its position is
fixed — it no longer appears and disappears as the tab changes, and
nothing below shifts sideways when it does.
/* No actions.
"Upload sheet" used to sit here and now lives only on Uploads. Two
doors into the same drawer meant the receipt it produces was listed on
a page the uploader had never been to — somebody who imported from
Inventory had no reason to know Uploads existed, and no way to see
whether the import had worked. One door, on the page that shows the
outcome.
"Import products" used to sit beside it and is gone: once browsing
the catalogue became a tab, the button was a second door to a room
already on the wall, and it read as a different action than the tab
it opened.
The "N awaiting approval" badge that used to sit here is gone: the
Stock requests tab already carries the same count, and a number
stated twice on one screen invites the reader to check whether the
two agree. */
actions={
<HStack gap={1} align="center" wrap="wrap">
<BarAction
label="Upload sheet"
icon={<UploadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
</HStack>
}
"Import products" went earlier, when browsing the catalogue became a
tab: the button was a second door to a room already on the wall. The
"N awaiting approval" badge went with it, because the Stock requests
tab already carries that count and a number stated twice on one screen
invites the reader to check whether the two agree. */
tabs={
/* The tab row carries the Products action on its right, rather than
giving it a row of its own below. It only appears on that tab —
@@ -189,8 +175,6 @@ export function InventoryPage() {
{tab === 'products' ? (
<ProductsPanel
isUploading={isUploading}
onCloseUpload={() => setUploading(false)}
/>
) : tab === 'catalogue' ? (
<CataloguePanel />
@@ -1169,56 +1153,6 @@ function StockPanel() {
/* ── Primitives ──────────────────────────────────────────────────────────── */
/**
* An action on the tab bar, drawn as the tabs are drawn.
*
* Same height, radius, type size and ink as `TabButton`, with no border and no
* fill — a filled pill on the right of a row of flat tabs reads as a different
* control system sitting in the same strip. It takes a hover tint instead of a
* resting one, which is what separates an action from a tab that is simply not
* selected.
*/
function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'default' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({

View File

@@ -6,6 +6,7 @@ import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
ArrowDownUp,
AlertTriangle,
BadgePercent,
BarChart3,
Boxes,
@@ -24,7 +25,7 @@ import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { SectionHeader } from '@/components/SectionHeader';
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format';
@@ -52,8 +53,13 @@ export function ReportsPage() {
/* The order rows, not `getlocationsummary`. That endpoint carries no money at
all and ignores the date picker — see `branchStats.ts`. These are the rows
Sales totals, so the two pages now agree. */
const orders = useOrders(tenantid ? { tenantid, ...dates.range, pagesize: 500 } : undefined);
const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
/* Every order in the window, not the first 500 of them. Each figure below is
a SUM, and a sum over one page stops being the total the moment a tenant
trades past it — silently, because the old read discarded the envelope and
nothing downstream could tell a partial answer from a complete one. */
const orders = useAllOrders(tenantid ? { tenantid, ...dates.range } : undefined);
const orderRows = orders.data?.rows;
const byBranch = useMemo(() => branchOrderStats(orderRows ?? []), [orderRows]);
const posSales = usePosSalesByBranch(branchIds, dates.range);
const rows = useMemo(
@@ -133,6 +139,8 @@ export function ReportsPage() {
}
/>
{orders.data?.truncated && tab !== 'inventory' ? <PartialTotals /> : null}
{tab === 'revenue' ? (
<VStack gap={3}>
<div className="kpi-grid">
@@ -336,61 +344,117 @@ function Bar({
);
}
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/**
* One bar per trading DAY, from `PosSalesSummary.byday`.
*
* It used to fold everything into seven fixed weekday slots — `(getDay() + 6) % 7`
* into a `Mon…Sun` array. That is right for exactly one window width and wrong
* for every other: pick "This month" and all four or five Mondays are summed into
* a single bar still labelled "Mon", so a figure covering five days reads as one.
* Nothing on screen said so, which is what made it worth fixing rather than
* documenting.
*
* Keyed on the date itself now, so the bar count follows the range instead of the
* calendar, and the axis switches from weekday names to dates once a week is
* exceeded — "Mon" is only unambiguous while at most one Monday is in view.
*/
/**
* Parsed as a LOCAL date, not through `new Date(string)`.
*
* `new Date('2026-09-12')` is parsed as UTC midnight, which is the previous
* evening anywhere west of Greenwich — the day a bar belongs to would shift under
* the reader depending on their clock. Splitting the parts sidesteps the question
* entirely: a trading day is a wall-clock day at the till.
*/
function parseTradingDay(raw: string): Date | null {
const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
if (parts) return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]));
const loose = new Date(raw);
return isNaN(loose.getTime()) ? null : loose;
}
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
const chartData = useMemo(() => {
const totals = new Array(7).fill(0);
let hasData = false;
const points = useMemo(() => {
const byDay = new Map<string, { date: Date; amount: number }>();
for (const row of rows) {
for (const entry of row.byDay) {
if (!entry.day) continue;
const d = new Date(entry.day);
if (!isNaN(d.getTime())) {
const dayIndex = (d.getDay() + 6) % 7;
totals[dayIndex] += (entry.amount ?? 0);
hasData = true;
}
const date = parseTradingDay(entry.day);
if (!date) continue;
const key = dayKey(date);
const seen = byDay.get(key);
if (seen) seen.amount += entry.amount ?? 0;
else byDay.set(key, { date, amount: entry.amount ?? 0 });
}
}
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
/* Chronological. Map order is insertion order, which is whatever sequence the
branches came back in — the bars would otherwise be in no order at all. */
const sorted = [...byDay.values()].sort((a, b) => a.date.getTime() - b.date.getTime());
/* Weekday names only while at most one of each is on screen. Past a week they
stop identifying a bar and the date has to carry it. */
const asWeekdays = sorted.length <= 7;
return sorted.map(({ date, amount }) => ({
name: asWeekdays
? date.toLocaleDateString('en-IN', { weekday: 'short' })
: date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }),
/* The tooltip always names the full date, whatever the axis had room for. */
full: date.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' }),
amount,
}));
}, [rows]);
if (chartData.length === 0) return null;
if (points.length === 0) return null;
/* Enough room per bar to stay legible, and a scroll when the range outgrows the
card — a month of bars squeezed into 400px is a texture, not a chart. */
const plotWidth = Math.max(400, points.length * 44);
/* Recharts draws every tick unless told otherwise; past a dozen they collide. */
const tickInterval = points.length > 12 ? Math.ceil(points.length / 10) - 1 : 0;
return (
<VStack gap={1.5}>
<SectionHeader title="Counter takings by day" />
<Card padding={0}>
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
<div style={{ minWidth: 400, height: '100%' }}>
<div style={{ minWidth: plotWidth, height: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
<RechartsBarChart data={points} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
interval={tickInterval}
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
dy={10}
/>
<Tooltip
<Tooltip
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
formatter={(value: any) => [money(value as number), 'Takings']}
contentStyle={{
borderRadius: 12,
border: '1px solid var(--color-border)',
formatter={(value) => [money(Number(value) || 0), 'Takings']}
/* Recharts hands the axis label back; swap in the full date the
tick had no room for. */
labelFormatter={(label, payload) => payload?.[0]?.payload?.full ?? label}
contentStyle={{
borderRadius: 12,
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-low)',
background: 'var(--color-background-surface)',
padding: '8px 12px'
padding: '8px 12px',
}}
itemStyle={{ color: 'var(--color-text-primary)' }}
/>
<RechartsBar
dataKey="amount"
fill="var(--color-brand)"
<RechartsBar
dataKey="amount"
fill="var(--color-brand)"
radius={[6, 6, 6, 6]}
barSize={36}
maxBarSize={36}
/>
</RechartsBarChart>
</ResponsiveContainer>
@@ -402,24 +466,70 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
}
/**
* Inventory reporting, per branch.
* Stock across every branch in scope, not whichever one sorted first.
*
* Fast and slow movers come from the stock statement's `debit` — what actually
* left the shelf — rather than from a sales table, because that is the one
* figure that counts both channels without double-counting either.
* This read `scoped[0]` and presented the result beside figures covering the
* whole tenant — inventory value for one shop against revenue for the chain. The
* branch name sat in one KPI's note, so the mismatch was disclosed in the single
* place a reader was least likely to check it against.
*
* The rows are summed PER PRODUCT rather than concatenated: the same SKU has a
* statement row in each branch that carries it, and a list of those rows would
* count one product as several and split its movement across duplicate entries in
* the mover lists.
*/
function InventoryReport() {
const { scoped, tenantid } = useBranchScope();
const branch = scoped[0];
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const statements = useStockStatementByBranch(tenantid || undefined, branchIds, {
pagesize: 200,
});
const rows = statement.data ?? [];
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
/* Not memoised, deliberately. `useQueries` hands back a fresh array every
render, so any dependency list over it either recomputes anyway or has to be
faked with a fingerprint that can go stale while the data changes underneath
it. A few hundred rows through a Map is cheaper than that bug. */
const rows: {
productid: number;
productname?: string;
debit: number;
closing: number;
retailprice: number;
}[] = [];
const byProduct = new Map<number, (typeof rows)[number]>();
for (const query of statements) {
for (const row of query.data ?? []) {
const seen = byProduct.get(row.productid);
if (seen) {
seen.debit += row.debit ?? 0;
seen.closing += row.closing ?? 0;
/* Price belongs to the product, not to the branch's row — keep the first
real one rather than summing prices across shops. */
if (!seen.retailprice) seen.retailprice = row.retailprice ?? 0;
if (!seen.productname) seen.productname = row.productname;
} else {
const entry = {
productid: row.productid,
productname: row.productname,
debit: row.debit ?? 0,
closing: row.closing ?? 0,
retailprice: row.retailprice ?? 0,
};
byProduct.set(row.productid, entry);
rows.push(entry);
}
}
}
const moving = [...rows].sort((a, b) => b.debit - a.debit);
const fast = moving.slice(0, 5);
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
const slow = moving.filter((row) => row.closing > 0).slice(-5).reverse();
const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0);
/* Every tile on this tab now covers the same thing, so say what that is on all
of them rather than on one. */
const coverage =
scoped.length === 1 ? (scoped[0]?.locationname ?? '—') : `across ${count(scoped.length)} branches`;
return (
<VStack gap={3}>
@@ -427,21 +537,21 @@ function InventoryReport() {
<KpiCard
label="Inventory value"
value={money(value)}
note={branch?.locationname ?? '—'}
note={coverage}
tone="accent"
icon={<Warehouse size={15} />}
/>
<KpiCard
label="Products"
value={count(rows.length)}
note="with a stock record"
note={`with a stock record · ${coverage}`}
tone="neutral"
icon={<Package size={15} />}
/>
<KpiCard
label="Units moved"
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
note="off the shelf this period"
value={count(rows.reduce((sum, row) => sum + row.debit, 0))}
note={`off the shelf this period · ${coverage}`}
tone="neutral"
icon={<ArrowDownUp size={15} />}
/>
@@ -550,3 +660,33 @@ function Td({
</td>
);
}
/**
* Said out loud, because a partial total looks exactly like a complete one.
*
* `ordersAll` stops at its page bound, and past that bound every App figure on
* this page is a floor rather than a total. A number that is quietly short is
* worse than no number: it is read, trusted and acted on. The way out is the
* date picker — a narrower window fits inside the bound and the figures go back
* to being exact — so the notice names it rather than just apologising.
*/
function PartialTotals() {
return (
<HStack
gap={1.5}
align="center"
style={{
padding: '12px 16px',
borderRadius: 'var(--card-radius)',
border: '1px solid var(--color-warning-line, #F0DFB5)',
background: 'var(--color-warning-wash, #FFFBF0)',
}}
>
<AlertTriangle size={16} style={{ color: 'var(--color-warning, #b7860b)', flexShrink: 0 }} />
<Text size="sm" style={{ color: 'var(--color-slate-700)' }}>
This window holds more orders than one read returns, so the App figures below are a
minimum, not a total. Narrow the date range to see exact numbers.
</Text>
</HStack>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
@@ -6,6 +7,13 @@ import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '../BranchScope';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
import { SheetUploadDrawer } from '../SheetUploadDrawer';
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
import {
PROGRESS_STEPS,
STEP_LABEL,
type StepId,
} from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks';
/**
* This merchant's uploads.
@@ -21,8 +29,27 @@ import { SheetUploadDrawer } from '../SheetUploadDrawer';
*/
export function UploadsPage() {
const { user } = useAuth();
const { selected } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const { selected, tenantid } = useBranchScope();
const [params] = useSearchParams();
/**
* Opened by `?upload=1`, so onboarding can send somebody straight to the sheet
* drawer.
*
* That link used to point at Inventory, which carried its own "Upload sheet"
* button. Two doors into one drawer put the receipt on a page the uploader had
* never visited: a merchant who imported from Inventory had no reason to know
* Uploads existed, and so no way to find out whether the import had worked.
* The door is here now, on the page that shows the outcome — and the deep link
* follows it rather than being dropped, because the setup step depends on it.
*/
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
/* Onboarding sends people here mid-setup. Without this they arrive on a page
with no way back to the step they left. */
const fromSetup = params.get('setup') as StepId | null;
const setupProducts = useLocationProducts(tenantid || undefined, undefined, 0, {
allBranches: true,
});
/*
No date filter by default, unlike the reporting pages.
@@ -36,6 +63,21 @@ export function UploadsPage() {
return (
<VStack gap={3}>
{/* Above the header, because it is about the errand rather than the page:
it is the only thing on screen that knows setup is unfinished. */}
{fromSetup ? (
<SetupReturnBar
position={PROGRESS_STEPS.indexOf(fromSetup) + 1}
total={PROGRESS_STEPS.length}
stepLabel={STEP_LABEL[fromSetup]}
isDone={(setupProducts.data ?? []).length > 0}
doneTitle={`${(setupProducts.data ?? []).length} product${
(setupProducts.data ?? []).length === 1 ? '' : 's'
} added`}
href={`/admin/onboarding?advance=${fromSetup}`}
/>
) : null}
<PageHeader
title="Uploads"
/>
@@ -46,9 +88,9 @@ export function UploadsPage() {
onUpload={() => setUploading(true)}
{...(selected === null ? {} : { locationid: selected })}
/>
{/* The same drawer Inventory opens. Uploading from the page that lists
uploads is the obvious place for it, and routing somebody to another
screen to press a second button is not a flow, it is a detour. */}
{/* The only door to this drawer. Uploading from the page that lists
uploads is the obvious place for it, and it is now the single place —
Inventory's copy is gone. */}
{isUploading ? (
<SheetUploadDrawer
tenantid={user?.tenantid}

View File

@@ -1,7 +1,12 @@
/* ══ Counters ══════════════════════════════════════════════════════════════
Everything here builds on the Console's tokens and components — .panel,
.kpi, .chip, .pill, .grid-table, .tile — so the two pages read as one
product. Only what is particular to this page is defined. */
.pill, .grid-table, .tile — so the two pages read as one product. Only what
is particular to this page is defined.
`.kpi` and `.chip` used to be on that list and are not any more, because
they are no longer CSS at all: the tiles are `components/KpiCard` and the
status filter is `components/TabBar` at `size="sm"`, shared with every other
page rather than with the Console alone. */
.counter-controls {
display: flex;

View File

@@ -42,8 +42,7 @@
the part that gets clipped, capped so it does not push the buttons off. */
.assign-bar-picker {
min-width: 220px;
max-width: 300px;
flex: 1 1 220px;
flex: 1 1 300px;
}
/* Pushed to the far end so the result reads as an outcome rather than as

View File

@@ -70,96 +70,21 @@
removing everywhere else. */
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
.strat-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 12px;
min-height: 42px;
background: var(--dispatch-card-bg);
border: 1px solid var(--dispatch-border);
border-radius: var(--card-radius);
box-shadow: var(--dispatch-shadow-sm);
flex-wrap: wrap;
}
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ─────────────────────
.strat-tabs {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
GONE. `.strat-row`, `.strat-tabs`, `.strat-ops-cluster`, `.sbt` and its
icon / badge / live-dot parts were a bordered, shadowed toolbar holding a
seventh treatment of "tab": outlined when idle, a solid brand fill with a
purple glow when selected.
.strat-ops-cluster {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
Both boards render `components/TabBar` now — the store board's six views
and the platform board's two, which had been borrowing `.sbt` from this
file across a feature boundary. The card around the strip went with it:
its only remaining child was the tab row, and every other page in the
console stands its switcher directly on the page.
.sbt {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 8px;
border: 1px solid var(--dispatch-border);
background: var(--dispatch-bg);
color: var(--dispatch-text-muted);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
}
The live dot on Active Fleet survives as `Tab`'s `isLive`. */
.sbt:hover {
background: var(--dispatch-bg-sub);
color: var(--dispatch-text);
border-color: rgba(102, 37, 130, 0.3);
}
.sbt.active {
background: var(--dispatch-accent);
border-color: var(--dispatch-accent);
color: #ffffff;
box-shadow: 0 3px 10px rgba(102, 37, 130, 0.25);
}
.sbt-icon {
display: inline-flex;
align-items: center;
}
.sbt-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--dispatch-bg-sub);
font-size: 10px;
font-weight: 700;
color: var(--dispatch-text);
}
.sbt.active .sbt-badge {
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
.sbt-dot-live {
width: 5px;
height: 5px;
border-radius: 50%;
background: #22c55e;
animation: pulse-ring 1.5s infinite;
}
/* Wave / Batch Slots */
/* No KPI overrides.

View File

@@ -59,12 +59,19 @@ export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
/* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a
rail down each card and a dot beside each heading.
It is gone rather than moved. The console does not colour-code severity:
`KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour
ramp existed on this page and nowhere else in the product. Urgency is already
carried twice over — by the group's name, in plain words, and by the order the
groups appear in — so the colour was a third telling, in the loudest register
available, on the one page a supervisor opens when they are already worried. */
It is gone rather than moved. The console does not paint a severity RAMP:
urgency here is already carried twice over — by the group's name, in plain
words, and by the order the groups appear in — so a red/amber/green scale was
a third telling, in the loudest register available, on the one page a
supervisor opens when they are already worried.
This note used to cite `KpiCard` mapping every tone to `--color-brand` as the
supporting evidence. That is no longer true and the citation would mislead:
`KpiCard`'s tone now tints its icon tile, and `success` is used on five
tiles. The distinction that survives is between a single accent marking one
state and a graded scale applied across a whole strip — the filter chips on
this page carried the second and were swept into `components/TabBar`, which
has no tone at all. */
export interface ProblemContext {
/** Evaluation instant, so a whole board is judged against one clock. */

View File

@@ -25,7 +25,10 @@ import { DateScopeProvider } from '@/components/shell/DateScope';
*/
const NAV: readonly NavEntry[] = [
{ to: '/store/console', label: 'Console' },
{ to: '/store/products', label: 'Products' },
/* "Inventory", not "Products". The path is left alone: a URL is an address,
and renaming it would break every link and bookmark already pointing here
to change a word nobody reads off the address bar. */
{ to: '/store/products', label: 'Inventory' },
{ to: '/store/sales', label: 'Sales' },
{ to: '/store/dispatch', label: 'Dispatch' },
{ to: '/store/reports', label: 'Reports' },

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Text } from '@astryxdesign/core/Text';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
import { Boxes, ClipboardList, Funnel, PackageSearch } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { SelectInput } from '@/components/SelectInput';
import { stockApi } from '@/api/stock';
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
@@ -19,6 +21,8 @@ import {
useStockRequests,
useStockStatement,
} from '@/queries/hooks';
import { CatalogueSidebar } from '@/features/catalogue/CatalogueSidebar';
import { matchesBrand, tenantBrands } from '@/features/catalogue/tenantBrands';
import { StoreSetupReturn } from '@/features/setup/StoreSetupReturn';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
@@ -63,6 +67,10 @@ export function StoreProductsPage() {
const [tab, setTab] = useState<Tab>('catalogue');
const [category, setCategory] = useState('');
const [brand, setBrand] = useState('');
/* Closed on arrival, as on the platform catalogue — a filter that opens by
itself has narrowed the shelf before the shopkeeper asked. */
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [open, setOpen] = useState<Product | null>(null);
const [asking, setAsking] = useState<{ product: Product; qty: number } | null>(null);
@@ -96,10 +104,49 @@ export function StoreProductsPage() {
* `WHERE publishedat IS NOT NULL`. And `status` is not the flag — it is
* rewritten to available/outofstock on every stock movement.
*/
/**
* This shop's shelf: released and priced, before any filter is applied.
*
* The rail is counted from THIS rather than from every row the tenant owns.
* A brand that exists only on unreleased products is not on the shelf, and
* listing it would offer a filter that empties the page — the rail is built so
* that everything it offers has something behind it.
*/
const released = useMemo(
() => (products.data ?? []).filter((product) => isPublished(product) && effectivePrice(product) > 0),
[products.data],
);
const brands = useMemo(() => tenantBrands(released), [released]);
/* The aisles stocked INSIDE the chosen brand — the rail's second level. */
const railCategories = useMemo(() => {
const seen = new Map<string, string>();
const names = new Map<number, string>();
for (const row of aisles.data ?? []) {
if (row.subcatid > 0 && row.subcatname) names.set(row.subcatid, row.subcatname);
}
for (const product of released) {
if (!matchesBrand(product, brand)) continue;
const id = String(product.subcategoryid ?? 0);
if (seen.has(id)) continue;
seen.set(id, id === '0' ? 'Uncategorized' : (names.get(Number(id)) ?? 'Uncategorized'));
}
return [...seen.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [released, brand, aisles.data]);
/* An aisle stocked by one brand need not be stocked by the next, and a filter
the new brand has never heard of quietly returns nothing. */
useEffect(() => {
setCategory('');
}, [brand]);
const catalogue = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (products.data ?? [])
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
return released
.filter((product) => matchesBrand(product, brand))
.filter((product) => !category || String(product.subcategoryid ?? 0) === category)
.filter((product) =>
term === ''
@@ -109,7 +156,7 @@ export function StoreProductsPage() {
.includes(term),
)
.sort((a, b) => (a.productname ?? '').localeCompare(b.productname ?? ''));
}, [products.data, category, keyword]);
}, [released, brand, category, keyword]);
const stockRows = useMemo(() => {
const term = keyword.trim().toLowerCase();
@@ -154,16 +201,32 @@ export function StoreProductsPage() {
<PageHeader
isTabsInline
title="Products"
title="Inventory"
actions={
tab === 'requests' ? null : (
<SearchInput
label="Search products"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width={240}
/>
<HStack gap={1} align="center">
{/* Shown whenever a brand is narrowing the shelf, so closing the
rail cannot leave the list short for no visible reason. */}
{tab === 'catalogue' && brand ? (
<Token label={brand} size="sm" onRemove={() => setBrand('')} />
) : null}
{tab === 'catalogue' ? (
<IconButton
label={isFiltersOpen ? 'Hide brands' : 'Filter by brand'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
) : null}
<SearchInput
label="Search products"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width={240}
/>
</HStack>
)
}
tabs={
@@ -192,17 +255,35 @@ export function StoreProductsPage() {
/>
{tab === 'catalogue' ? (
<CataloguePanel
rows={catalogue}
isLoading={products.isLoading}
latest={latest}
categoryOptions={categoryOptions}
category={category}
onCategory={setCategory}
onOpen={setOpen}
onAsk={(product, qty) => setAsking({ product, qty })}
onAskMany={setAskingMany}
/>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? (
<CatalogueSidebar
brands={brands}
isLoading={products.isLoading}
brand={brand}
category={category}
categories={railCategories}
isLoadingCategories={aisles.isLoading}
/* Released rows with no brand are on the shelf but not in the rail,
so the sum would be short of what the shopkeeper can see. */
totalCount={released.length}
onBrand={setBrand}
onCategory={setCategory}
/>
) : null}
<CataloguePanel
rows={catalogue}
isLoading={products.isLoading}
latest={latest}
categoryOptions={categoryOptions}
category={category}
onCategory={setCategory}
onOpen={setOpen}
onAsk={(product, qty) => setAsking({ product, qty })}
onAskMany={setAskingMany}
/>
</div>
) : tab === 'stock' ? (
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
) : (
@@ -297,17 +378,13 @@ function CataloguePanel({
? 'Reading your catalogue…'
: `${count(rows.length)} product${rows.length === 1 ? '' : 's'} released to this shop`}
</Text>
<div style={{ width: 190 }}>
<Selector
label="Category"
isLabelHidden
size="sm"
value={category}
onChange={onCategory}
placeholder="All categories"
options={categoryOptions}
/>
</div>
<SelectInput
label="Category"
value={category}
onChange={onCategory}
options={categoryOptions}
width={190}
/>
</HStack>
{isLoading ? (

View File

@@ -165,7 +165,7 @@ export function StoreSetupPage() {
icon: <Boxes size={20} />,
title: 'See your shelf',
body: 'What is priced, what is in stock, and what is out.',
cta: 'Open products',
cta: 'Open inventory',
onClick: () => navigate('/store/products'),
},
{

View File

@@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput';
import { errorMessage } from '@/api/client';
import {
@@ -333,22 +334,17 @@ export function UploadsPanel({
<VStack gap={2}>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="up-toolbar">
<div className="up-tabs" role="tablist" aria-label="Filter uploads">
<TabBar size="sm" aria-label="Filter uploads">
{TABS.map((entry) => (
<button
<Tab
key={entry.id}
type="button"
role="tab"
className="up-tab"
aria-selected={tab === entry.id}
data-active={tab === entry.id}
label={entry.label}
count={counts[entry.id]}
isActive={tab === entry.id}
onClick={() => setTab(entry.id)}
>
{entry.label}
<span>{counts[entry.id]}</span>
</button>
/>
))}
</div>
</TabBar>
<div className="up-toolbar-spacer" />

View File

@@ -15,43 +15,9 @@
}
.up-toolbar-spacer { flex: 1 1 auto; }
.up-tabs {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 12px;
background: var(--color-surface-sunken);
}
.up-tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--color-ink-3);
font: 500 13px/1 var(--font-sans);
cursor: pointer;
}
.up-tab:hover { color: var(--color-ink-1); }
.up-tab[data-active='true'] {
background: var(--color-surface);
color: var(--color-brand);
font-weight: 600;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.06);
}
.up-tab span {
min-width: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-surface-sunken);
font: 700 11px/18px var(--font-sans);
font-variant-numeric: tabular-nums;
}
.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-tabs` and `.up-tab` were here — this page’s own segmented control,
at a 12px track and a 9px tab where the shared one is `--card-radius-sm`.
It renders `components/TabBar` at `size="sm"` now. */
/* `.up-search` was here. The toolbar uses `components/SearchInput` now, like
every other search in the console.