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

@@ -73,6 +73,41 @@ export const insightsApi = {
pagesize: query.pagesize ?? 50,
}),
/**
* Every order in a window, not the first page of them.
*
* Reports totals its figures from order ROWS — `getlocationsummary` carries no
* money and ignores the date picker, so the rows are the only source that both
* has revenue and respects the range. Reducing over a single `pagesize: 500`
* read made every one of those figures a silent lie the moment a tenant traded
* more than five hundred orders in the window: the page showed a total, gave no
* sign it was a partial one, and `api.list` discards the envelope so nothing
* downstream could even detect the cut.
*
* Paging stops on a SHORT PAGE rather than on a count the list endpoint does
* not return — the same rule `catalogue.idsByImageId` follows, and for the same
* reason: a total we would have to trust is worse than a page we can measure.
*
* `maxPages` is a real bound, not a formality. Something has to stop a loop
* pointed at production, and a window wide enough to exceed it is a window the
* reader should be told about rather than one we quietly keep fetching. Hence
* `truncated`, which the caller is expected to surface — the whole point of
* this function is that a partial total never again passes for a complete one.
*/
ordersAll: async (
query: OrderQuery,
{ pagesize = 500, maxPages = 10 }: { pagesize?: number; maxPages?: number } = {},
): Promise<{ rows: OrderRow[]; truncated: boolean }> => {
const rows: OrderRow[] = [];
/* `pageno` is 1-based on this endpoint — the controller floors <= 0 to 1. */
for (let page = 1; page <= maxPages; page += 1) {
const batch = await insightsApi.orders({ ...query, pageno: page, pagesize });
rows.push(...batch);
if (batch.length < pagesize) return { rows, truncated: false };
}
return { rows, truncated: true };
},
/**
* The delivery jobs.
*

View File

@@ -85,8 +85,19 @@ export class ErrorBoundary extends Component<Props, State> {
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
</h2>
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
{/*
In DEVELOPMENT nothing was deployed, and saying so sends a developer
looking for a release that never happened. The same failure has a
different cause here: the dev server restarted, or Vite re-optimised
its dependencies after a config change, and either invalidates the
module URLs this tab is holding. Same fix, different sentence —
`import.meta.env.DEV` is compiled out of the production bundle, so
this costs a deployed console nothing.
*/}
{isStale
? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
? import.meta.env.DEV
? 'The dev server restarted or re-optimised its dependencies, so the module URL this tab was holding no longer resolves. Nothing is wrong with your code — reloading picks up the current module graph.'
: 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
</p>
<pre
@@ -109,7 +120,10 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={isStale ? this.reload : this.reset}
style={{
padding: '9px 18px',
borderRadius: 999,
/* The same corner as every other control on the console. A stadium
here made the one button a person sees on a broken screen the one
button shaped unlike the rest of the product. */
borderRadius: 'var(--card-radius-sm, 10px)',
border: '1px solid #D9C0C0',
background: '#FFFFFF',
fontSize: 14,

View File

@@ -0,0 +1,28 @@
import { Selector } from '@astryxdesign/core/Selector';
export interface SelectInputOption {
value: string;
label: string;
}
export interface SelectInputProps {
value: string;
onChange: (value: string) => void;
label: string;
options: SelectInputOption[];
width?: number | 'full';
}
export function SelectInput({ value, onChange, label, options, width = 260 }: SelectInputProps) {
return (
<div {...(width === 'full' ? { 'data-full': 'true', style: { width: '100%' } } : { style: { width } })}>
<Selector
label={label}
isLabelHidden
value={value}
onChange={onChange}
options={options}
/>
</div>
);
}

View File

@@ -68,9 +68,18 @@ export interface TabProps {
* without spending a tab on a heading.
*/
isGroupStart?: boolean;
/**
* A pulsing dot after the label, for a view that updates by itself.
*
* Dispatch's Active Fleet is the one: it reads rider positions live, and the
* dot is how the board says so. A named prop rather than a general-purpose
* slot — a `trailing` that took any node would be the seam this component
* exists to close, and there is exactly one live view in the console.
*/
isLive?: boolean;
}
export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: TabProps) {
export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) {
return (
<button
type="button"
@@ -83,6 +92,9 @@ export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: Tab
{icon ? <span className="tabbar-icon">{icon}</span> : null}
<span className="tabbar-label">{label}</span>
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
{/* Labelled, not decorative: a green dot with no accessible name is a
colour that means something to sighted readers only. */}
{isLive ? <span className="tabbar-live" role="img" aria-label="updating live" /> : null}
</button>
);
}

View File

@@ -28,39 +28,85 @@ import {
export interface DateScopeValue {
preset: RangePreset;
/**
* What the PAGES read — always a real window.
*
* When nobody has picked anything this is the default week, not an empty
* object. Left empty, every read on the console became unbounded and each
* part of a page bounded it differently: the Console's chart drew however
* many days the last 500 orders happened to span (nine, in practice) while
* the KPI tiles beside it totalled all of them, and the counter figures came
* from a POS summary with no window at all. One page, three answers.
*/
range: DateRange;
/**
* What the PICKER shows — empty until somebody chooses.
*
* This is the half that keeps the filter unselected on arrival. The two are
* separate on purpose: "no filter has been set" is a fact about the control,
* and "which week are we looking at" is a fact about the data, and collapsing
* them into one value is what forced a choice between a filter nobody set and
* a page with no window.
*/
chosen: DateRange;
set: (preset: RangePreset, range: DateRange) => void;
/** Back to no filter at all — what an empty state offers as a way out. */
clear: () => void;
/** True when a range is actually narrowing the page. */
/** True when the USER has narrowed the page, not merely that a window exists. */
isFiltered: boolean;
}
/** Where the console starts. Month to date is what most pages want. */
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
/**
* The window the console shows when nobody has picked one.
*
* A WEEK — while the picker still shows nothing selected. Those are two
* separate statements and both are deliberate.
*
* The filter opens unselected because the console used to narrow every page
* before anyone asked: somebody signing in to see how trade is going was shown
* a slice with no sign that it was one, and the range then followed them across
* the whole console.
*
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
* capped only by `pagesize`, and each part of a page then bounded itself
* differently — the Console's chart drew however many days the last 500 orders
* happened to span (nine), the KPI tiles beside it totalled all of those
* orders, and the counter figures came from a POS summary with no window
* whatsoever. One page, three different answers to "when".
*
* A default window is not a filter. Nothing is hidden from the reader, the
* control is empty, and one choice replaces it.
*/
const DEFAULT_WINDOW: RangePreset = 'week';
const DateScopeContext = createContext<DateScopeValue | null>(null);
export function DateScopeProvider({ children }: { children: ReactNode }) {
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
/* What the user picked. Empty until they do — this is what the picker shows. */
const [preset, setPreset] = useState<RangePreset>('custom');
const [chosen, setChosen] = useState<DateRange>({});
const value = useMemo<DateScopeValue>(
() => ({
const value = useMemo<DateScopeValue>(() => {
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
return {
preset,
range,
chosen,
/* The window the pages actually read: what was picked, or the default
week. Resolved here, once, so every read on a page shares it — the
orders, the POS summaries and the chart cannot end up describing
different spans. */
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
set: (nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
setChosen(nextRange);
},
clear: () => {
setPreset('custom');
setRange({});
setChosen({});
},
isFiltered: Boolean(range.fromdate || range.todate),
}),
[preset, range],
);
isFiltered: hasChoice,
};
}, [preset, chosen]);
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
}
@@ -81,5 +127,5 @@ export function useDateScope(): DateScopeValue {
/** The control itself. Rendered once, in the top bar. */
export function DateScopePicker() {
const dates = useDateScope();
return <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
}

View File

@@ -93,6 +93,28 @@
background: var(--color-brand-tint);
}
/* ── Live dot ───────────────────────────────────────────────────────────────
For a view that updates by itself. Green rather than the brand, because it
reports a STATE — the data is moving — and the brand in this console carries
action and identity. It keeps its colour on the selected tab for the same
reason. */
.tabbar-live {
flex: none;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-success, #1f9d55);
animation: tabbar-pulse 1.8s ease-in-out infinite;
}
@keyframes tabbar-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* A pulse is decoration; the dot itself is the information, so it stays. */
@media (prefers-reduced-motion: reduce) {
.tabbar-live { animation: none; }
}
/* ── Group divider ──────────────────────────────────────────────────────────
A hairline inside the track, for a tab that belongs to a different family
from the one before it — Counter sales beside the app's own Orders and

View File

@@ -43,10 +43,29 @@ export interface Paged<T> {
to: number;
}
export const DEFAULT_PAGE_SIZE = 25;
/**
* Fifteen rows, everywhere, until somebody says otherwise.
*
* Every table on the console takes this — no call site overrides it — so this
* constant IS the console's answer to "how long is a page", and changing it
* here changes all of them at once. That is the point: a default that drifts per
* screen makes the pager something to re-read on every page rather than
* something learnt once.
*
* It is a default, not a limit. The selector in `TablePager` offers the sizes
* below and `setPageSize` keeps whatever is chosen for as long as the table is
* mounted, so a person working through a long list can widen it and stay widened.
*/
export const DEFAULT_PAGE_SIZE = 15;
/** The choices offered in the page-size selector. */
export const PAGE_SIZES = [10, 25, 50, 100];
/**
* The choices offered in the page-size selector.
*
* 15 is here because the default has to be selectable — a selector whose current
* value is not one of its options has nothing to show as chosen. 10 stays: it is
* the "show me less" end, and the default moving up is no reason to take it away.
*/
export const PAGE_SIZES = [10, 15, 25, 50, 100];
export function usePaged<T>(
rows: readonly T[],

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.

View File

@@ -1250,7 +1250,7 @@ main {
flex: none;
padding: 6px 11px;
border: 1px solid var(--color-line);
border-radius: 999px;
border-radius: var(--card-radius-sm);
background: var(--color-surface);
color: var(--color-ink-2, var(--color-ink-1));
font: inherit;

View File

@@ -45,25 +45,61 @@ test('does not mistake a real bug for a missing chunk', () => {
assert.equal(isStaleChunkError(undefined), false);
});
test('reloads once, then refuses so it cannot loop', () => {
test('reloads once, then refuses so it cannot loop on the same module', () => {
store.clear();
const error = new Error('Failed to fetch dynamically imported module: /assets/a.js');
assert.equal(canRecoverByReloading(error), true, 'first failure should reload');
// Stamp a reload as having just happened.
store.set('nearle:stale-chunk-reload-at', String(Date.now()));
// Stamp a reload as having just happened FOR THAT MODULE. The marker is
// keyed by filename now, not one timestamp for the whole tab — see the next
// test for why that had to change.
store.set('nearle:stale-chunk-reload-at:a.js', String(Date.now()));
assert.equal(
canRecoverByReloading(error),
false,
'a second failure inside the cooldown must surface, not reload again',
'a second failure of the same module inside the cooldown must surface, not reload again',
);
});
/**
* The bug this guard had: one deploy replaces EVERY chunk, so somebody
* clicking through the console hits a different missing module on each page.
* A single tab-wide timestamp meant the first page recovered and every page
* after it — within thirty seconds, each needing the same reload — was refused
* one and shown the error instead. Which is exactly how it was reported: "in
* many pages I am getting this error".
*/
test('a different module still gets its own reload', () => {
store.clear();
store.set('nearle:stale-chunk-reload-at:a.js', String(Date.now()));
assert.equal(
canRecoverByReloading(new Error('Failed to fetch dynamically imported module: /assets/a.js')),
false,
'the module we just reloaded for stays refused',
);
assert.equal(
canRecoverByReloading(new Error('Failed to fetch dynamically imported module: /assets/b.js')),
true,
'a different chunk has not been tried yet and must be allowed its reload',
);
});
test('the dev server URL form is keyed the same way', () => {
store.clear();
const dev = new Error(
'Failed to fetch dynamically imported module: http://localhost:3100/src/features/store-admin/pages/ShopProfilePage.tsx',
);
assert.equal(canRecoverByReloading(dev), true);
store.set('nearle:stale-chunk-reload-at:ShopProfilePage.tsx', String(Date.now()));
assert.equal(canRecoverByReloading(dev), false, 'keyed on the filename, hash or not');
});
test('recovers again from a later deploy', () => {
store.clear();
// A reload well outside the 30s window — a different deploy, hours later.
store.set('nearle:stale-chunk-reload-at', String(Date.now() - 60 * 60_000));
store.set('nearle:stale-chunk-reload-at:b.js', String(Date.now() - 60 * 60_000));
assert.equal(
canRecoverByReloading(new Error('Failed to fetch dynamically imported module: /assets/b.js')),
true,

View File

@@ -28,6 +28,37 @@
const MARKER = 'nearle:stale-chunk-reload-at';
const COOLDOWN_MS = 30_000;
/**
* The chunk an error is about, so the guard counts PER MODULE.
*
* This is the fix for "I get this error on many pages". The marker used to be
* a single timestamp for the whole tab, so the first failing route reloaded
* and recovered — and then every OTHER route that failed within the next
* thirty seconds was refused a reload and shown the error instead, even though
* a reload is exactly what each of them needed. One deploy replaces every
* chunk, so a person clicking through three pages hit three different missing
* modules and the guard only helped with the first.
*
* A second failure of the SAME module still refuses, which is the loop the
* guard exists to prevent: after a reload that module either loads or it is
* genuinely gone, and reloading again would not change that.
*
* The filename is enough to identify it and keeps the key short. Content
* hashes change with every deploy, so these markers expire on their own rather
* than needing to be cleaned up.
*/
function moduleKeyOf(error: unknown): string {
const message = error instanceof Error ? error.message : String(error ?? '');
const url = /https?:\/\/\S+|\/[^\s:]+\.(?:js|mjs|tsx?|css)/i.exec(message)?.[0];
if (!url) {
/* Safari says only "Importing a module script failed." with no URL. Those
share one key and therefore one reload per cooldown — the old behaviour,
kept because there is nothing to tell them apart by. */
return 'unknown';
}
return url.split('?')[0]?.split('/').pop() || 'unknown';
}
/**
* Whether a thrown value is a chunk that would not load.
*
@@ -51,17 +82,17 @@ export function isStaleChunkError(error: unknown): boolean {
}
/** Private-mode Safari throws on `sessionStorage`; a failure here must not mask the real error. */
function readMarker(): number {
function readMarker(key: string): number {
try {
return Number(window.sessionStorage.getItem(MARKER)) || 0;
return Number(window.sessionStorage.getItem(`${MARKER}:${key}`)) || 0;
} catch {
return 0;
}
}
function writeMarker(at: number): void {
function writeMarker(key: string, at: number): void {
try {
window.sessionStorage.setItem(MARKER, String(at));
window.sessionStorage.setItem(`${MARKER}:${key}`, String(at));
} catch {
/* Storage unavailable. The reload still happens; only the loop guard is
lost, and a browser that cannot store this cannot loop through it
@@ -77,7 +108,8 @@ function writeMarker(at: number): void {
* arrived just throws the same error again, which is what the old button did.
*/
export function canRecoverByReloading(error: unknown): boolean {
return isStaleChunkError(error) && Date.now() - readMarker() > COOLDOWN_MS;
if (!isStaleChunkError(error)) return false;
return Date.now() - readMarker(moduleKeyOf(error)) > COOLDOWN_MS;
}
/**
@@ -89,8 +121,8 @@ export function canRecoverByReloading(error: unknown): boolean {
* the navigation happens, so the last frame is the spinner rather than a flash
* of an error the user cannot act on.
*/
export function reloadForStaleChunk(): Promise<never> {
writeMarker(Date.now());
export function reloadForStaleChunk(error?: unknown): Promise<never> {
writeMarker(moduleKeyOf(error), Date.now());
window.location.reload();
return new Promise<never>(() => {});
}
@@ -105,7 +137,7 @@ export function reloadForStaleChunk(): Promise<never> {
export function withStaleChunkRecovery<T>(load: () => Promise<T>): () => Promise<T> {
return () =>
load().catch((error: unknown) => {
if (canRecoverByReloading(error)) return reloadForStaleChunk();
if (canRecoverByReloading(error)) return reloadForStaleChunk(error);
throw error;
});
}

View File

@@ -436,6 +436,34 @@ export function useStockStatement(
});
}
/**
* The stock statement for EVERY branch in scope, one request each.
*
* `getstockstatement` takes a single `locationid` and there is no tenant-wide
* stock read, so a multi-branch view fans out — the same constraint, and the same
* shape of answer, as `usePosSalesByBranch`.
*
* The Reports inventory tab read `scoped[0]` before this: whatever branch
* happened to sort first stood in for the whole tenant, on a page where every
* other figure covers all of them. A reader comparing inventory value against
* this period's revenue was comparing one shop against the chain.
*/
export function useStockStatementByBranch(
tenantid: number | undefined,
locationIds: readonly number[],
params: { keyword?: string; pagesize?: number } = {},
) {
return useQueries({
queries: locationIds.map((locationid) => ({
queryKey: queryKeys.stock.statement(tenantid ?? 0, locationid, params),
queryFn: () =>
stockApi.statement({ tenantid: tenantid as number, locationid, ...params }),
enabled: Boolean(tenantid),
...live,
})),
});
}
/* ── Store Admin: order and bill rows ────────────────────────────────────── */
/**
@@ -457,6 +485,30 @@ export function useOrders(query: OrderQuery | undefined) {
});
}
/**
* Every order in the window, paged until the rows run out.
*
* For the screens that TOTAL orders rather than list them. `useOrders` returns
* one page and is right for a table with a pager under it; a sum built from one
* page is simply wrong past that page, and wrong without saying so.
*
* Returns the flag as well as the rows: at the page bound the figures ARE partial
* and the screen has to say so. A caller that ignores `truncated` has recreated
* the bug this exists to fix.
*/
export function useAllOrders(query: OrderQuery | undefined, pagesize = 500) {
return useQuery({
queryKey: queryKeys.insights.orderList({
...((query ?? {}) as unknown as Record<string, unknown>),
all: true,
pagesize,
}),
queryFn: () => insightsApi.ordersAll(query as OrderQuery, { pagesize }),
enabled: Boolean(query?.tenantid || query?.partnerid),
...live,
});
}
/**
* Counter bills, per branch.
*

View File

@@ -87,7 +87,7 @@
--font-family-body: Inter, ui-sans-serif, system-ui, sans-serif;
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--radius-element: 12px;
--radius-element: 10px;
--radius-container: 14px;
--radius-page: 14px;
--radius-chat: 14px;

View File

@@ -20,7 +20,7 @@ export const nearleTheme = {
"--font-family-body": "Inter, ui-sans-serif, system-ui, sans-serif",
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
"--radius-element": "12px",
"--radius-element": "10px",
"--radius-container": "14px",
"--radius-page": "14px",
"--radius-chat": "14px",

View File

@@ -67,13 +67,27 @@ export const nearleTheme = defineTheme({
// softer edge. A plain box is happier at 12; this is not a plain box —
// see `components/Panel.tsx`.
//
// `--radius-element` stays at 12: a control is one step tighter than the
// panel that holds it, which is what keeps a button inside a card from
// looking like a small card.
// `--radius-element` is 10 — the same corner as the search box and the tab
// track, NOT a number of its own.
//
// It was 12, on the reasoning that a control should sit one step tighter
// than the panel around it. That holds when a control's only neighbour is
// the card it sits in. It stopped holding once the console grew rows where
// buttons sit directly beside a search box and a segmented tab track, both
// of which are built by hand at `--card-radius-sm`: three controls on one
// line, the same height, two of them rounded 10 and the one between them
// rounded 12. Nobody reads that as a deliberate step — it reads as a corner
// that did not get set.
//
// So the rule is now the simpler one: anything a person operates is 10, any
// surface that holds things is 14. `searchInput.css`, `tabBar.css` and
// `dateRangePicker.css` all already say `--card-radius-sm`; this makes
// Astryx's own controls say it too, which is what removes the last mismatch
// rather than papering over it per page.
//
// `--radius-inner` (4px) is left alone — Astryx uses it on the smallest
// inline markers, which are not cards.
'--radius-element': '12px',
'--radius-element': '10px',
'--radius-container': '14px',
'--radius-page': '14px',
'--radius-chat': '14px',