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`}

-
- {(['revenue', 'orders'] as const).map((m) => ( - - ))} -
+ + 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. */} -
- {[1, 0.75, 0.5, 0.25, 0].map((step) => ( - {format(Math.round(peak * step))} - ))} -
- -
- {series.map((d) => ( -
-
- - +
+ {isTable ? ( + + ) : ( +
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. */} +
+ {ticks.map((tick) => ( +
+ {axisFormat(tick)}
- {shortDay(d.day)} -
- ))} -
+ ))} +
-
- Online - Counter +
+ {series.map((d, index) => { + const dayTotal = d.online + d.counter; + return ( +
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. */} +
+ + +
+ + {index === peakIndex ? ( + + {format(dayTotal)} + + ) : null} + + {hovered === index ? ( +
+ {longDay(d.day)} + + + Online + {format(d.online)} + + + + Counter + {format(d.counter)} + + + Total{format(dayTotal)} + +
+ ) : null} +
+ ); + })} +
+ + {/* Every date is drawn — see `showLabel`. */} +
+ {series.map((d) => ( + + {showLabel() ? shortDay(d.day) : ''} + + ))} +
+ )} + +
+
+ + + Online + + + + Counter + +
+
-
- + {/* 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 ? ( -
- - -
- ) : null} ); } function Share({ - kind, label, percent, amount, -}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) { + kind, + label, + percent, + amount, +}: { + kind: 'online' | 'counter'; + label: string; + percent: number; + amount: string; +}) { return (
- + {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} -
+
+ + + + + + + + + + + {rows.map((d) => ( + + + + + + + ))} + +
DayOnlineCounterTotal
{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)} + /> + -
+ ); return ( @@ -175,30 +216,36 @@ export function ProductsPanel({
) : null} - setPublishing([product])} - /> +
+ {isFiltersOpen ? ( + + ) : null} + + setPublishing([product])} + /> +
{open ? setOpen(null)} /> : null} - {isUploading ? ( - - ) : null} {publishing && tenantid ? (
-
- setChip('all')} /> - setChip('active')} /> - setChip('attention')} /> - setChip('never')} /> - setChip('offline')} /> -
+ {/* 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 (
- {/* ── Unified Strategy & Operations Toolbar (xpressconsole #strat-row style) ── */} -
-
- + {/* 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} - /> +
+ {isFiltersOpen ? ( + + ) : null} + + 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',