From 041dc378611f41648a9ad32b34b2dd2c10497e1f Mon Sep 17 00:00:00 2001
From: abhishek
Date: Fri, 18 Sep 2026 16:57:24 +0530
Subject: [PATCH] ui/ux improvement
---
src/api/insights.ts | 35 ++
src/components/ErrorBoundary.tsx | 18 +-
src/components/SelectInput.tsx | 28 ++
src/components/TabBar.tsx | 14 +-
src/components/shell/DateScope.tsx | 76 +++-
src/components/tabBar.css | 22 +
src/components/usePaged.ts | 25 +-
src/features/catalogue/CatalogueBrowser.tsx | 6 +-
src/features/catalogue/CatalogueSidebar.tsx | 35 +-
src/features/catalogue/tenantBrands.test.ts | 55 +++
src/features/catalogue/tenantBrands.ts | 50 +++
src/features/console/console.css | 344 ++++++++++++----
.../console/sections/SalesOverview.tsx | 387 ++++++++++++------
src/features/console/sections/TillSync.tsx | 32 +-
.../nearle-admin/pages/DispatchPage.tsx | 48 +--
.../nearle-admin/pages/PartnersPage.tsx | 4 +-
src/features/nearle-admin/pages/fleet.css | 23 +-
src/features/onboarding/OnboardingPage.tsx | 4 +-
src/features/store-admin/AssignBar.tsx | 4 +-
src/features/store-admin/DateRangePicker.tsx | 33 +-
src/features/store-admin/ProductsPanel.tsx | 131 ++++--
.../store-admin/SheetUploadDrawer.tsx | 2 +-
.../store-admin/pages/CountersPage.tsx | 39 +-
.../store-admin/pages/DispatchPage.tsx | 136 +++---
.../store-admin/pages/InventoryPage.tsx | 92 +----
.../store-admin/pages/ReportsPage.tsx | 230 +++++++++--
.../store-admin/pages/UploadsPage.tsx | 52 ++-
src/features/store-admin/pages/counters.css | 9 +-
src/features/store-admin/pages/deliveries.css | 3 +-
src/features/store-admin/pages/dispatch.css | 97 +----
src/features/store-admin/terminalProblems.ts | 19 +-
src/features/store-user/StoreUserShell.tsx | 5 +-
.../store-user/pages/StoreProductsPage.tsx | 149 +++++--
.../store-user/pages/StoreSetupPage.tsx | 2 +-
src/features/uploads/UploadsPanel.tsx | 20 +-
src/features/uploads/uploads.css | 40 +-
src/index.css | 2 +-
src/lib/staleChunk.test.ts | 46 ++-
src/lib/staleChunk.ts | 48 ++-
src/queries/hooks.ts | 52 +++
src/theme/nearle.css | 2 +-
src/theme/nearle.js | 2 +-
src/theme/nearle.ts | 22 +-
43 files changed, 1697 insertions(+), 746 deletions(-)
create mode 100644 src/components/SelectInput.tsx
create mode 100644 src/features/catalogue/tenantBrands.test.ts
create mode 100644 src/features/catalogue/tenantBrands.ts
diff --git a/src/api/insights.ts b/src/api/insights.ts
index 239340e..06f0453 100644
--- a/src/api/insights.ts
+++ b/src/api/insights.ts
@@ -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.
*
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index a47fdb7..3c2be91 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -85,8 +85,19 @@ export class ErrorBoundary extends Component {
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
+ {/*
+ 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.'}
{
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,
diff --git a/src/components/SelectInput.tsx b/src/components/SelectInput.tsx
new file mode 100644
index 0000000..fc852a0
--- /dev/null
+++ b/src/components/SelectInput.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index bd31800..d112e59 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -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 (
);
}
diff --git a/src/components/shell/DateScope.tsx b/src/components/shell/DateScope.tsx
index 4947787..29c7909 100644
--- a/src/components/shell/DateScope.tsx
+++ b/src/components/shell/DateScope.tsx
@@ -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(null);
export function DateScopeProvider({ children }: { children: ReactNode }) {
- const [preset, setPreset] = useState(DEFAULT_RANGE_PRESET);
- const [range, setRange] = useState(() => presetRange(DEFAULT_RANGE_PRESET));
+ /* What the user picked. Empty until they do — this is what the picker shows. */
+ const [preset, setPreset] = useState('custom');
+ const [chosen, setChosen] = useState({});
- const value = useMemo(
- () => ({
+ const value = useMemo(() => {
+ 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 {children};
}
@@ -81,5 +127,5 @@ export function useDateScope(): DateScopeValue {
/** The control itself. Rendered once, in the top bar. */
export function DateScopePicker() {
const dates = useDateScope();
- return ;
+ return ;
}
diff --git a/src/components/tabBar.css b/src/components/tabBar.css
index a11d9f0..9bf1dbb 100644
--- a/src/components/tabBar.css
+++ b/src/components/tabBar.css
@@ -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
diff --git a/src/components/usePaged.ts b/src/components/usePaged.ts
index 330aafb..761b7c4 100644
--- a/src/components/usePaged.ts
+++ b/src/components/usePaged.ts
@@ -43,10 +43,29 @@ export interface Paged {
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(
rows: readonly T[],
diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx
index 474bcf3..20595a9 100644
--- a/src/features/catalogue/CatalogueBrowser.tsx
+++ b/src/features/catalogue/CatalogueBrowser.tsx
@@ -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}
diff --git a/src/features/catalogue/CatalogueSidebar.tsx b/src/features/catalogue/CatalogueSidebar.tsx
index 4fd25fd..1e85ce7 100644
--- a/src/features/catalogue/CatalogueSidebar.tsx
+++ b/src/features/catalogue/CatalogueSidebar.tsx
@@ -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({
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
>
All categories
- {categories.map((name) => (
+ {categories.map((entry) => (
))}
>
diff --git a/src/features/catalogue/tenantBrands.test.ts b/src/features/catalogue/tenantBrands.test.ts
new file mode 100644
index 0000000..fe0cba8
--- /dev/null
+++ b/src/features/catalogue/tenantBrands.test.ts
@@ -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');
+});
diff --git a/src/features/catalogue/tenantBrands.ts b/src/features/catalogue/tenantBrands.ts
new file mode 100644
index 0000000..4022338
--- /dev/null
+++ b/src/features/catalogue/tenantBrands.ts
@@ -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();
+
+ 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();
+}
diff --git a/src/features/console/console.css b/src/features/console/console.css
index eb9b5e5..f146ec9 100644
--- a/src/features/console/console.css
+++ b/src/features/console/console.css
@@ -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. */
}
diff --git a/src/features/console/sections/SalesOverview.tsx b/src/features/console/sections/SalesOverview.tsx
index 5ad7f7a..49c549e 100644
--- a/src/features/console/sections/SalesOverview.tsx
+++ b/src/features/console/sections/SalesOverview.tsx
@@ -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('revenue');
+ const [isTable, setIsTable] = useState(false);
+ const [hovered, setHovered] = useState(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 ;
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
: `Revenue and orders for the last ${days.length} days`}
+
+ setMeasure('revenue')}
+ />
+ setMeasure('orders')} />
+
{total === 0 ? (
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
Pick a wider date range, or check back once the shop has taken an order.
) : (
-
-
- {/* 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. */}
-
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. */}
+
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. */}
+
-
+ {/* 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. */}
+
+
+
+
+
)}
-
- {/* 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 ? (
-
-
+
{label}{percent}%{amount}
@@ -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 (
-
-
-
- Total Sales
- {total}
-
+
+
+
+
+
Day
+
Online
+
Counter
+
Total
+
+
+
+ {rows.map((d) => (
+
+
{longDay(d.day)}
+
{format(d.online)}
+
{format(d.counter)}
+
+ {format(d.online + d.counter)}
+
+
+ ))}
+
+
);
}
+/* ── 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' });
+}
diff --git a/src/features/console/sections/TillSync.tsx b/src/features/console/sections/TillSync.tsx
index 7672293..6e681a5 100644
--- a/src/features/console/sections/TillSync.tsx
+++ b/src/features/console/sections/TillSync.tsx
@@ -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) {
-
-
-
-
-
+
+ setFilter('all')}
+ />
+ setFilter('attention')}
+ />
+ setFilter('healthy')}
+ />
+
}
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. */
-
+ />
+
}
/>
diff --git a/src/features/nearle-admin/pages/PartnersPage.tsx b/src/features/nearle-admin/pages/PartnersPage.tsx
index b24d264..32588d0 100644
--- a/src/features/nearle-admin/pages/PartnersPage.tsx
+++ b/src/features/nearle-admin/pages/PartnersPage.tsx
@@ -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)',
diff --git a/src/features/nearle-admin/pages/fleet.css b/src/features/nearle-admin/pages/fleet.css
index b02ba1e..9acf358 100644
--- a/src/features/nearle-admin/pages/fleet.css
+++ b/src/features/nearle-admin/pages/fleet.css
@@ -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. */
diff --git a/src/features/onboarding/OnboardingPage.tsx b/src/features/onboarding/OnboardingPage.tsx
index 57f386e..0bc9a4e 100644
--- a/src/features/onboarding/OnboardingPage.tsx
+++ b/src/features/onboarding/OnboardingPage.tsx
@@ -301,7 +301,7 @@ export function OnboardingPage() {
{step === 'catalogue' ? (
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' ? (
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}
diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx
index d5ae01f..d6edcd8 100644
--- a/src/features/store-admin/AssignBar.tsx
+++ b/src/features/store-admin/AssignBar.tsx
@@ -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)'
}
/>
diff --git a/src/features/store-admin/DateRangePicker.tsx b/src/features/store-admin/DateRangePicker.tsx
index 4f13d32..938b967 100644
--- a/src/features/store-admin/DateRangePicker.tsx
+++ b/src/features/store-admin/DateRangePicker.tsx
@@ -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 (
{
if (!newRange) {
onChange('custom', {});
diff --git a/src/features/store-admin/ProductsPanel.tsx b/src/features/store-admin/ProductsPanel.tsx
index 4abaee7..3e102a7 100644
--- a/src/features/store-admin/ProductsPanel.tsx
+++ b/src/features/store-admin/ProductsPanel.tsx
@@ -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(null);
const [publishing, setPublishing] = useState(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();
+ 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 = { 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 = (
-
-
+ {/* 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 ? setBrand('')} /> : null}
+ }
+ variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
+ onClick={() => setIsFiltersOpen((open) => !open)}
+ />
+
-
+ {/* 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. */}
+
+ setChip('all')} />
+ setChip('active')} />
+ setChip('attention')} />
+ setChip('never')} />
+ setChip('offline')} />
+
@@ -279,21 +287,8 @@ function Kpi({
);
}
-function Chip({
- label, n, tone, active, onClick,
-}: { label: string; n: number; tone?: string; active: boolean; onClick: () => void }) {
- return (
-
- );
-}
+/* The page-local `Chip` lived here — it is the `sm` size of
+ `components/TabBar` now. */
/**
* The counters somebody has to walk to.
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
index e28f7b6..a85b475 100644
--- a/src/features/store-admin/pages/DispatchPage.tsx
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -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 (
-
+ {/* 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. */}
+
+ }
+ count={totals.riders || undefined}
+ isActive={mode === 'riders'}
+ onClick={() => changeMode('riders')}
+ />
+ }
+ 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' ? (
+ }
+ isActive={mode === 'customers'}
+ onClick={() => changeMode('customers')}
+ />
+ ) : null}
-
-
- {/* 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' ? (
-
- ) : null}
-
-
-
-
-
-
-
-
- {/* 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. */}
-
+ {/* 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. */}
+ }
+ isActive={mode === 'active'}
+ onClick={() => changeMode('active')}
+ isGroupStart
+ isLive
+ />
+ }
+ isActive={mode === 'timing'}
+ onClick={() => changeMode('timing')}
+ />
+ }
+ isActive={mode === 'profit'}
+ onClick={() => changeMode('profit')}
+ />
+
{/* The same four-card row every other page uses — same `kpi-grid`, same
diff --git a/src/features/store-admin/pages/InventoryPage.tsx b/src/features/store-admin/pages/InventoryPage.tsx
index b4d7111..93ec9f0 100644
--- a/src/features/store-admin/pages/InventoryPage.tsx
+++ b/src/features/store-admin/pages/InventoryPage.tsx
@@ -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() {
- }
- isDisabled={!tenantid}
- onClick={() => setUploading(true)}
- />
-
- }
+ "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' ? (
setUploading(false)}
/>
) : tab === 'catalogue' ? (
@@ -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 (
-
- );
-}
-
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({
diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx
index b2b2955..798076c 100644
--- a/src/features/store-admin/pages/ReportsPage.tsx
+++ b/src/features/store-admin/pages/ReportsPage.tsx
@@ -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' ? : null}
+
{tab === 'revenue' ? (
@@ -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();
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 (
-
+
-
-
+
- [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)' }}
/>
-
@@ -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();
+ 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 (
@@ -427,21 +537,21 @@ function InventoryReport() {
}
/>
}
/>
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={}
/>
@@ -550,3 +660,33 @@ function 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 (
+
+
+
+ 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.
+
+
+ );
+}
diff --git a/src/features/store-admin/pages/UploadsPage.tsx b/src/features/store-admin/pages/UploadsPage.tsx
index 5a57c79..e253643 100644
--- a/src/features/store-admin/pages/UploadsPage.tsx
+++ b/src/features/store-admin/pages/UploadsPage.tsx
@@ -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 (
+ {/* 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 ? (
+ 0}
+ doneTitle={`${(setupProducts.data ?? []).length} product${
+ (setupProducts.data ?? []).length === 1 ? '' : 's'
+ } added`}
+ href={`/admin/onboarding?advance=${fromSetup}`}
+ />
+ ) : null}
+
@@ -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 ? (
= { 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. */
diff --git a/src/features/store-user/StoreUserShell.tsx b/src/features/store-user/StoreUserShell.tsx
index 4134f51..a2a1705 100644
--- a/src/features/store-user/StoreUserShell.tsx
+++ b/src/features/store-user/StoreUserShell.tsx
@@ -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' },
diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx
index f465d6d..2a46ef6 100644
--- a/src/features/store-user/pages/StoreProductsPage.tsx
+++ b/src/features/store-user/pages/StoreProductsPage.tsx
@@ -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('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(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();
+ const names = new Map();
+ 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() {
+
+ {/* Shown whenever a brand is narrowing the shelf, so closing the
+ rail cannot leave the list short for no visible reason. */}
+ {tab === 'catalogue' && brand ? (
+ setBrand('')} />
+ ) : null}
+ {tab === 'catalogue' ? (
+ }
+ variant={isFiltersOpen ? 'secondary' : 'ghost'}
+ size="sm"
+ onClick={() => setIsFiltersOpen((open) => !open)}
+ />
+ ) : null}
+
+
)
}
tabs={
@@ -192,17 +255,35 @@ export function StoreProductsPage() {
/>
{tab === 'catalogue' ? (
- setAsking({ product, qty })}
- onAskMany={setAskingMany}
- />
+
) : tab === 'stock' ? (
) : (
@@ -297,17 +378,13 @@ function CataloguePanel({
? 'Reading your catalogue…'
: `${count(rows.length)} product${rows.length === 1 ? '' : 's'} released to this shop`}
-
-
-
+
{isLoading ? (
diff --git a/src/features/store-user/pages/StoreSetupPage.tsx b/src/features/store-user/pages/StoreSetupPage.tsx
index 9e390a3..7887984 100644
--- a/src/features/store-user/pages/StoreSetupPage.tsx
+++ b/src/features/store-user/pages/StoreSetupPage.tsx
@@ -165,7 +165,7 @@ export function StoreSetupPage() {
icon: ,
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'),
},
{
diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx
index 3086866..2880683 100644
--- a/src/features/uploads/UploadsPanel.tsx
+++ b/src/features/uploads/UploadsPanel.tsx
@@ -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({
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
-
+
{TABS.map((entry) => (
-
+ />
))}
-
+
diff --git a/src/features/uploads/uploads.css b/src/features/uploads/uploads.css
index b65f24e..e45b695 100644
--- a/src/features/uploads/uploads.css
+++ b/src/features/uploads/uploads.css
@@ -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.
diff --git a/src/index.css b/src/index.css
index 46bfa60..b714327 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
diff --git a/src/lib/staleChunk.test.ts b/src/lib/staleChunk.test.ts
index f950f17..d163e29 100644
--- a/src/lib/staleChunk.test.ts
+++ b/src/lib/staleChunk.test.ts
@@ -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,
diff --git a/src/lib/staleChunk.ts b/src/lib/staleChunk.ts
index c1a44f8..2b8b9a6 100644
--- a/src/lib/staleChunk.ts
+++ b/src/lib/staleChunk.ts
@@ -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 {
- writeMarker(Date.now());
+export function reloadForStaleChunk(error?: unknown): Promise {
+ writeMarker(moduleKeyOf(error), Date.now());
window.location.reload();
return new Promise(() => {});
}
@@ -105,7 +137,7 @@ export function reloadForStaleChunk(): Promise {
export function withStaleChunkRecovery(load: () => Promise): () => Promise {
return () =>
load().catch((error: unknown) => {
- if (canRecoverByReloading(error)) return reloadForStaleChunk();
+ if (canRecoverByReloading(error)) return reloadForStaleChunk(error);
throw error;
});
}
diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts
index 059820f..c0c1d42 100644
--- a/src/queries/hooks.ts
+++ b/src/queries/hooks.ts
@@ -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),
+ all: true,
+ pagesize,
+ }),
+ queryFn: () => insightsApi.ordersAll(query as OrderQuery, { pagesize }),
+ enabled: Boolean(query?.tenantid || query?.partnerid),
+ ...live,
+ });
+}
+
/**
* Counter bills, per branch.
*
diff --git a/src/theme/nearle.css b/src/theme/nearle.css
index 44ef053..89a3602 100644
--- a/src/theme/nearle.css
+++ b/src/theme/nearle.css
@@ -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;
diff --git a/src/theme/nearle.js b/src/theme/nearle.js
index bc8dca8..435108b 100644
--- a/src/theme/nearle.js
+++ b/src/theme/nearle.js
@@ -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",
diff --git a/src/theme/nearle.ts b/src/theme/nearle.ts
index 6805d7b..8838e52 100644
--- a/src/theme/nearle.ts
+++ b/src/theme/nearle.ts
@@ -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',