ui/ux improvement
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -85,8 +85,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
||||
{/*
|
||||
In DEVELOPMENT nothing was deployed, and saying so sends a developer
|
||||
looking for a release that never happened. The same failure has a
|
||||
different cause here: the dev server restarted, or Vite re-optimised
|
||||
its dependencies after a config change, and either invalidates the
|
||||
module URLs this tab is holding. Same fix, different sentence —
|
||||
`import.meta.env.DEV` is compiled out of the production bundle, so
|
||||
this costs a deployed console nothing.
|
||||
*/}
|
||||
{isStale
|
||||
? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
|
||||
? import.meta.env.DEV
|
||||
? 'The dev server restarted or re-optimised its dependencies, so the module URL this tab was holding no longer resolves. Nothing is wrong with your code — reloading picks up the current module graph.'
|
||||
: 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
|
||||
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
|
||||
</p>
|
||||
<pre
|
||||
@@ -109,7 +120,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
onClick={isStale ? this.reload : this.reset}
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 999,
|
||||
/* The same corner as every other control on the console. A stadium
|
||||
here made the one button a person sees on a broken screen the one
|
||||
button shaped unlike the rest of the product. */
|
||||
borderRadius: 'var(--card-radius-sm, 10px)',
|
||||
border: '1px solid #D9C0C0',
|
||||
background: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
|
||||
28
src/components/SelectInput.tsx
Normal file
28
src/components/SelectInput.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
export interface SelectInputOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
options: SelectInputOption[];
|
||||
width?: number | 'full';
|
||||
}
|
||||
|
||||
export function SelectInput({ value, onChange, label, options, width = 260 }: SelectInputProps) {
|
||||
return (
|
||||
<div {...(width === 'full' ? { 'data-full': 'true', style: { width: '100%' } } : { style: { width } })}>
|
||||
<Selector
|
||||
label={label}
|
||||
isLabelHidden
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,9 +68,18 @@ export interface TabProps {
|
||||
* without spending a tab on a heading.
|
||||
*/
|
||||
isGroupStart?: boolean;
|
||||
/**
|
||||
* A pulsing dot after the label, for a view that updates by itself.
|
||||
*
|
||||
* Dispatch's Active Fleet is the one: it reads rider positions live, and the
|
||||
* dot is how the board says so. A named prop rather than a general-purpose
|
||||
* slot — a `trailing` that took any node would be the seam this component
|
||||
* exists to close, and there is exactly one live view in the console.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: TabProps) {
|
||||
export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -83,6 +92,9 @@ export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: Tab
|
||||
{icon ? <span className="tabbar-icon">{icon}</span> : null}
|
||||
<span className="tabbar-label">{label}</span>
|
||||
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
|
||||
{/* Labelled, not decorative: a green dot with no accessible name is a
|
||||
colour that means something to sighted readers only. */}
|
||||
{isLive ? <span className="tabbar-live" role="img" aria-label="updating live" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,39 +28,85 @@ import {
|
||||
|
||||
export interface DateScopeValue {
|
||||
preset: RangePreset;
|
||||
/**
|
||||
* What the PAGES read — always a real window.
|
||||
*
|
||||
* When nobody has picked anything this is the default week, not an empty
|
||||
* object. Left empty, every read on the console became unbounded and each
|
||||
* part of a page bounded it differently: the Console's chart drew however
|
||||
* many days the last 500 orders happened to span (nine, in practice) while
|
||||
* the KPI tiles beside it totalled all of them, and the counter figures came
|
||||
* from a POS summary with no window at all. One page, three answers.
|
||||
*/
|
||||
range: DateRange;
|
||||
/**
|
||||
* What the PICKER shows — empty until somebody chooses.
|
||||
*
|
||||
* This is the half that keeps the filter unselected on arrival. The two are
|
||||
* separate on purpose: "no filter has been set" is a fact about the control,
|
||||
* and "which week are we looking at" is a fact about the data, and collapsing
|
||||
* them into one value is what forced a choice between a filter nobody set and
|
||||
* a page with no window.
|
||||
*/
|
||||
chosen: DateRange;
|
||||
set: (preset: RangePreset, range: DateRange) => void;
|
||||
/** Back to no filter at all — what an empty state offers as a way out. */
|
||||
clear: () => void;
|
||||
/** True when a range is actually narrowing the page. */
|
||||
/** True when the USER has narrowed the page, not merely that a window exists. */
|
||||
isFiltered: boolean;
|
||||
}
|
||||
|
||||
/** Where the console starts. Month to date is what most pages want. */
|
||||
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
|
||||
/**
|
||||
* The window the console shows when nobody has picked one.
|
||||
*
|
||||
* A WEEK — while the picker still shows nothing selected. Those are two
|
||||
* separate statements and both are deliberate.
|
||||
*
|
||||
* The filter opens unselected because the console used to narrow every page
|
||||
* before anyone asked: somebody signing in to see how trade is going was shown
|
||||
* a slice with no sign that it was one, and the range then followed them across
|
||||
* the whole console.
|
||||
*
|
||||
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
|
||||
* capped only by `pagesize`, and each part of a page then bounded itself
|
||||
* differently — the Console's chart drew however many days the last 500 orders
|
||||
* happened to span (nine), the KPI tiles beside it totalled all of those
|
||||
* orders, and the counter figures came from a POS summary with no window
|
||||
* whatsoever. One page, three different answers to "when".
|
||||
*
|
||||
* A default window is not a filter. Nothing is hidden from the reader, the
|
||||
* control is empty, and one choice replaces it.
|
||||
*/
|
||||
const DEFAULT_WINDOW: RangePreset = 'week';
|
||||
|
||||
const DateScopeContext = createContext<DateScopeValue | null>(null);
|
||||
|
||||
export function DateScopeProvider({ children }: { children: ReactNode }) {
|
||||
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
|
||||
/* What the user picked. Empty until they do — this is what the picker shows. */
|
||||
const [preset, setPreset] = useState<RangePreset>('custom');
|
||||
const [chosen, setChosen] = useState<DateRange>({});
|
||||
|
||||
const value = useMemo<DateScopeValue>(
|
||||
() => ({
|
||||
const value = useMemo<DateScopeValue>(() => {
|
||||
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
|
||||
return {
|
||||
preset,
|
||||
range,
|
||||
chosen,
|
||||
/* The window the pages actually read: what was picked, or the default
|
||||
week. Resolved here, once, so every read on a page shares it — the
|
||||
orders, the POS summaries and the chart cannot end up describing
|
||||
different spans. */
|
||||
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
|
||||
set: (nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
setChosen(nextRange);
|
||||
},
|
||||
clear: () => {
|
||||
setPreset('custom');
|
||||
setRange({});
|
||||
setChosen({});
|
||||
},
|
||||
isFiltered: Boolean(range.fromdate || range.todate),
|
||||
}),
|
||||
[preset, range],
|
||||
);
|
||||
isFiltered: hasChoice,
|
||||
};
|
||||
}, [preset, chosen]);
|
||||
|
||||
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
||||
}
|
||||
@@ -81,5 +127,5 @@ export function useDateScope(): DateScopeValue {
|
||||
/** The control itself. Rendered once, in the top bar. */
|
||||
export function DateScopePicker() {
|
||||
const dates = useDateScope();
|
||||
return <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
|
||||
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,10 +43,29 @@ export interface Paged<T> {
|
||||
to: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 25;
|
||||
/**
|
||||
* Fifteen rows, everywhere, until somebody says otherwise.
|
||||
*
|
||||
* Every table on the console takes this — no call site overrides it — so this
|
||||
* constant IS the console's answer to "how long is a page", and changing it
|
||||
* here changes all of them at once. That is the point: a default that drifts per
|
||||
* screen makes the pager something to re-read on every page rather than
|
||||
* something learnt once.
|
||||
*
|
||||
* It is a default, not a limit. The selector in `TablePager` offers the sizes
|
||||
* below and `setPageSize` keeps whatever is chosen for as long as the table is
|
||||
* mounted, so a person working through a long list can widen it and stay widened.
|
||||
*/
|
||||
export const DEFAULT_PAGE_SIZE = 15;
|
||||
|
||||
/** The choices offered in the page-size selector. */
|
||||
export const PAGE_SIZES = [10, 25, 50, 100];
|
||||
/**
|
||||
* The choices offered in the page-size selector.
|
||||
*
|
||||
* 15 is here because the default has to be selectable — a selector whose current
|
||||
* value is not one of its options has nothing to show as chosen. 10 stays: it is
|
||||
* the "show me less" end, and the default moving up is no reason to take it away.
|
||||
*/
|
||||
export const PAGE_SIZES = [10, 15, 25, 50, 100];
|
||||
|
||||
export function usePaged<T>(
|
||||
rows: readonly T[],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function CatalogueSidebar({
|
||||
category,
|
||||
categories,
|
||||
isLoadingCategories,
|
||||
totalCount,
|
||||
onBrand,
|
||||
onCategory,
|
||||
}: {
|
||||
@@ -35,8 +36,26 @@ export function CatalogueSidebar({
|
||||
isLoading: boolean;
|
||||
brand: string;
|
||||
category: string;
|
||||
categories: string[];
|
||||
/**
|
||||
* Value and label kept apart.
|
||||
*
|
||||
* The platform catalogue filters on the category NAME, so the two are the same
|
||||
* string there. A store's own products are grouped by aisle, where the filter
|
||||
* is a `subcategoryid` and the name is what a reader recognises — passing one
|
||||
* string for both would put "7" in the rail or filter on "Dairy".
|
||||
*/
|
||||
categories: { value: string; label: string }[];
|
||||
isLoadingCategories: boolean;
|
||||
/**
|
||||
* What "All brands" counts, when it is not the sum of the rail.
|
||||
*
|
||||
* The platform catalogue's brands account for every row, so summing them is
|
||||
* right there. A store's rows can have no brand at all: those are left out of
|
||||
* the rail and still listed under "All brands", so the sum would be short by
|
||||
* exactly the unbranded items and the top of the rail would contradict the
|
||||
* list beside it.
|
||||
*/
|
||||
totalCount?: number;
|
||||
onBrand: (brand: string) => void;
|
||||
onCategory: (category: string) => void;
|
||||
}) {
|
||||
@@ -56,7 +75,9 @@ export function CatalogueSidebar({
|
||||
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
|
||||
All brands
|
||||
<span className="rail-count">
|
||||
{isLoading ? '' : brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0)}
|
||||
{isLoading
|
||||
? ''
|
||||
: (totalCount ?? brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
|
||||
>
|
||||
All categories
|
||||
</button>
|
||||
{categories.map((name) => (
|
||||
{categories.map((entry) => (
|
||||
<button
|
||||
key={name}
|
||||
key={entry.value}
|
||||
type="button"
|
||||
className="rail-category"
|
||||
data-active={category === name}
|
||||
onClick={() => onCategory(category === name ? '' : name)}
|
||||
data-active={category === entry.value}
|
||||
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
|
||||
>
|
||||
{name}
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
|
||||
55
src/features/catalogue/tenantBrands.test.ts
Normal file
55
src/features/catalogue/tenantBrands.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { Product } from '@/api/types';
|
||||
import { matchesBrand, tenantBrands } from './tenantBrands';
|
||||
|
||||
const p = (productbrand?: string, productid = 0) => ({ productid, productbrand }) as Product;
|
||||
|
||||
test('counts only the brands the shop actually carries', () => {
|
||||
const brands = tenantBrands([p('Amul'), p('Britannia'), p('Amul'), p('Amul')]);
|
||||
assert.deepEqual(brands, [
|
||||
{ brand: 'Amul', product_count: 3 },
|
||||
{ brand: 'Britannia', product_count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('one shelf, however the row was typed', () => {
|
||||
// An import writes "BRITANNIA", somebody types "Britannia". A shopkeeper has
|
||||
// one Britannia shelf, so splitting the rail in two would be wrong.
|
||||
const brands = tenantBrands([p('Britannia'), p('BRITANNIA'), p(' britannia ')]);
|
||||
assert.equal(brands.length, 1);
|
||||
assert.equal(brands[0]?.product_count, 3);
|
||||
assert.equal(brands[0]?.brand, 'Britannia', 'the first spelling seen is the one shown');
|
||||
});
|
||||
|
||||
test('rows with no brand are left out of the rail', () => {
|
||||
// They stay in the unfiltered list, which is why the rail is handed the real
|
||||
// total rather than the sum of these counts.
|
||||
const brands = tenantBrands([p('Amul'), p(''), p(undefined), p(' ')]);
|
||||
assert.deepEqual(brands, [{ brand: 'Amul', product_count: 1 }]);
|
||||
});
|
||||
|
||||
test('biggest shelf first, alphabetical within a tie', () => {
|
||||
const brands = tenantBrands([p('Zebra'), p('Apple'), p('Mango'), p('Mango')]);
|
||||
assert.deepEqual(
|
||||
brands.map((b) => b.brand),
|
||||
['Mango', 'Apple', 'Zebra'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a listed brand can never filter to nothing', () => {
|
||||
const rows = [p('Amul'), p('BRITANNIA'), p(undefined)];
|
||||
for (const entry of tenantBrands(rows)) {
|
||||
assert.ok(
|
||||
rows.some((row) => matchesBrand(row, entry.brand)),
|
||||
`${entry.brand} is offered but matches no row`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no brand chosen means no filter', () => {
|
||||
assert.equal(matchesBrand(p(undefined), ''), true);
|
||||
assert.equal(matchesBrand(p('Amul'), ''), true);
|
||||
assert.equal(matchesBrand(p(undefined), 'Amul'), false);
|
||||
assert.equal(matchesBrand(p('amul'), 'Amul'), true, 'matching ignores case like the grouping does');
|
||||
});
|
||||
50
src/features/catalogue/tenantBrands.ts
Normal file
50
src/features/catalogue/tenantBrands.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CatalogueBrand, Product } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The brands a SHOP actually stocks, counted from its own rows.
|
||||
*
|
||||
* The platform catalogue's brands are Postgres tables, discovered from
|
||||
* `information_schema` by `getbrands`, which reports a row count per table. None
|
||||
* of that applies here. A store's products come from `getlocationproducts`, and
|
||||
* the brand is a plain `productbrand` string on each row — so feeding the rail
|
||||
* `useCatalogueBrands()` would list every brand on the platform, most of which
|
||||
* the shop has never carried, each with a count belonging to a different table.
|
||||
* The filter would be full of dead options and the numbers beside them would be
|
||||
* about somebody else's catalogue.
|
||||
*
|
||||
* Counting the rows in hand is the only source that answers "which brands are on
|
||||
* MY shelves", and it has the property the rail needs: picking a brand can never
|
||||
* return nothing, because the brand is only listed if something has it.
|
||||
*
|
||||
* Grouped case-insensitively — "Britannia" and "BRITANNIA" are one brand to a
|
||||
* shopkeeper, and an import that disagrees with a hand-typed row should not
|
||||
* split the shelf in two. The first spelling seen is the one shown, since there
|
||||
* is no authority here to normalise against.
|
||||
*/
|
||||
export function tenantBrands(products: readonly Product[]): CatalogueBrand[] {
|
||||
const byKey = new Map<string, CatalogueBrand>();
|
||||
|
||||
for (const product of products) {
|
||||
const name = product.productbrand?.trim();
|
||||
/* No brand is not a brand. These stay out of the rail and remain visible
|
||||
under "All brands" — which is why the rail is given the real total
|
||||
separately rather than summing these counts. */
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
const seen = byKey.get(key);
|
||||
if (seen) seen.product_count += 1;
|
||||
else byKey.set(key, { brand: name, product_count: 1 });
|
||||
}
|
||||
|
||||
/* Biggest shelf first, alphabetical within a tie. A shop's own list is short
|
||||
enough that this is the whole ordering question. */
|
||||
return [...byKey.values()].sort(
|
||||
(a, b) => b.product_count - a.product_count || a.brand.localeCompare(b.brand),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a row belongs to the chosen brand. `''` means no brand filter. */
|
||||
export function matchesBrand(product: Product, brand: string): boolean {
|
||||
if (!brand) return true;
|
||||
return (product.productbrand?.trim().toLowerCase() ?? '') === brand.trim().toLowerCase();
|
||||
}
|
||||
@@ -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. */
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { money, count } from '@/features/store-admin/format';
|
||||
import type { DaySeries } from '../useConsoleSeries';
|
||||
|
||||
/**
|
||||
* Revenue and orders over the period: the shape on the left, the split on the
|
||||
* right.
|
||||
* Revenue and orders over the period.
|
||||
*
|
||||
* Two views of one dataset, deliberately. The columns answer "when did it
|
||||
* happen"; the ring answers "where did it come from". Either alone leaves a
|
||||
* shopkeeper doing arithmetic in their head — a good week of counter sales and
|
||||
* a collapse in app orders look identical in a single total.
|
||||
* ── What this replaced, and why each piece went ─────────────────────────────
|
||||
*
|
||||
* Drawn by hand rather than with a charting library. It is a few dozen columns
|
||||
* and one ring; a library would be more code shipped than the chart it draws,
|
||||
* and this way the bars use the same brand token as everything else on the page
|
||||
* instead of a palette of its own.
|
||||
* **The donut is gone.** It was a ring showing one share — online against
|
||||
* counter — with the same two figures spelled out in a row directly beneath
|
||||
* it. A two-slice ring is the textbook case for "the number is the chart": it
|
||||
* spends 160×160px to say what "68% / 32%" says in nine characters, and it says
|
||||
* it less precisely. The share is now a single slim bar above the two figures,
|
||||
* which is the form for part-to-whole and doubles as the legend key.
|
||||
*
|
||||
* **The columns are stacked, not grouped.** Two thin bars per day meant 60
|
||||
* marks across a 30-day range at ~11px each. Stacked, a column's height is the
|
||||
* day's takings — the thing a shopkeeper actually reads off this chart — and
|
||||
* the split is still visible inside it.
|
||||
*
|
||||
* **The colours are measured, not chosen.** The old pair was the brand purple
|
||||
* and the brand at 32% on white, and running them through the palette checks
|
||||
* failed three ways: the brand sits at OKLCH L 0.406, below the 0.43 floor for
|
||||
* a chart mark; the 32% mix landed at chroma 0.047, which reads as grey rather
|
||||
* than as a colour; and it cleared only 1.82:1 against white, under the 3:1
|
||||
* minimum. So the second channel was a mark you could not reliably see. The
|
||||
* pair below is validated — see `--chart-online` in `console.css`.
|
||||
*
|
||||
* **There is a real tooltip.** Values used to live in `title` attributes: a
|
||||
* browser tooltip after a second's delay, unstyled, invisible on touch. And a
|
||||
* table view, because with a stacked column the per-channel figure for one day
|
||||
* exists nowhere else on the page — a tooltip must enhance, never gate.
|
||||
*/
|
||||
export interface SalesOverviewProps {
|
||||
days: readonly DaySeries[];
|
||||
@@ -25,6 +42,8 @@ type Measure = 'revenue' | 'orders';
|
||||
|
||||
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
const [measure, setMeasure] = useState<Measure>('revenue');
|
||||
const [isTable, setIsTable] = useState(false);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const series = useMemo(
|
||||
() =>
|
||||
@@ -36,16 +55,41 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
[days, measure],
|
||||
);
|
||||
|
||||
/* The axis tops out at a round number at or above the tallest column, so the
|
||||
ticks read 0 / 5,000 / 10,000 rather than 0 / 4,317 / 8,634. */
|
||||
const peak = useMemo(
|
||||
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
|
||||
() => Math.max(1, ...series.map((d) => d.online + d.counter)),
|
||||
[series],
|
||||
);
|
||||
const axisTop = useMemo(() => niceCeiling(peak), [peak]);
|
||||
const ticks = useMemo(
|
||||
() => [1, 0.75, 0.5, 0.25, 0].map((step) => Math.round(axisTop * step)),
|
||||
[axisTop],
|
||||
);
|
||||
|
||||
/* Labelled directly, because it is the one column worth naming. Everything
|
||||
else is carried by the axis, the tooltip and the table. */
|
||||
const peakIndex = useMemo(() => {
|
||||
let best = -1;
|
||||
let bestTotal = 0;
|
||||
series.forEach((d, i) => {
|
||||
const total = d.online + d.counter;
|
||||
if (total > bestTotal) {
|
||||
bestTotal = total;
|
||||
best = i;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}, [series]);
|
||||
|
||||
const online = series.reduce((sum, d) => sum + d.online, 0);
|
||||
const counter = series.reduce((sum, d) => sum + d.counter, 0);
|
||||
const total = online + counter;
|
||||
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
|
||||
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
|
||||
const axisFormat = (value: number) =>
|
||||
measure === 'revenue' ? compactMoney(value) : count(value);
|
||||
|
||||
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
|
||||
|
||||
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
: `Revenue and orders for the last ${days.length} days`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="seg" role="tablist" aria-label="Measure">
|
||||
{(['revenue', 'orders'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={measure === m}
|
||||
className="seg-btn"
|
||||
data-active={measure === m ? 'yes' : 'no'}
|
||||
onClick={() => setMeasure(m)}
|
||||
>
|
||||
{m === 'revenue' ? 'Revenue' : 'Orders'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TabBar size="sm" aria-label="Measure">
|
||||
<Tab
|
||||
label="Revenue"
|
||||
isActive={measure === 'revenue'}
|
||||
onClick={() => setMeasure('revenue')}
|
||||
/>
|
||||
<Tab label="Orders" isActive={measure === 'orders'} onClick={() => setMeasure('orders')} />
|
||||
</TabBar>
|
||||
</header>
|
||||
|
||||
{total === 0 ? (
|
||||
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sales-split">
|
||||
<div className="chart-wrap">
|
||||
{/* The axis is drawn from the data rather than fixed, so a shop
|
||||
taking ₹800 a day gets a readable scale instead of a flat line
|
||||
along the bottom of a ₹20K grid. */}
|
||||
<div className="chart-axis" aria-hidden>
|
||||
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
|
||||
<span key={step}>{format(Math.round(peak * step))}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chart2">
|
||||
{series.map((d) => (
|
||||
<div key={d.day} className="chart2-col">
|
||||
<div className="chart2-bars">
|
||||
<span
|
||||
className="chart2-bar"
|
||||
data-kind="online"
|
||||
style={{ height: `${(d.online / peak) * 100}%` }}
|
||||
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
|
||||
/>
|
||||
<span
|
||||
className="chart2-bar"
|
||||
data-kind="counter"
|
||||
style={{ height: `${(d.counter / peak) * 100}%` }}
|
||||
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
|
||||
/>
|
||||
<div className="chart-block">
|
||||
{isTable ? (
|
||||
<SeriesTable rows={series} format={format} />
|
||||
) : (
|
||||
<div
|
||||
className="plot"
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
role="img"
|
||||
aria-label={`${measure === 'revenue' ? 'Revenue' : 'Orders'} by day, online and counter`}
|
||||
>
|
||||
{/* The grid is drawn first and sits under the marks: hairline,
|
||||
solid, one step off the surface. Its rows and the axis labels
|
||||
come from the same tick list, so they cannot drift apart. */}
|
||||
<div className="plot-grid" aria-hidden>
|
||||
{ticks.map((tick) => (
|
||||
<div key={tick} className="plot-gridline">
|
||||
<span className="plot-tick">{axisFormat(tick)}</span>
|
||||
</div>
|
||||
<span className="chart2-label">{shortDay(d.day)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chart-legend">
|
||||
<span><i data-kind="online" />Online</span>
|
||||
<span><i data-kind="counter" />Counter</span>
|
||||
<div className="plot-cols">
|
||||
{series.map((d, index) => {
|
||||
const dayTotal = d.online + d.counter;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="plot-col"
|
||||
data-hover={hovered === index}
|
||||
onMouseEnter={() => setHovered(index)}
|
||||
onFocus={() => setHovered(index)}
|
||||
onBlur={() => setHovered(null)}
|
||||
tabIndex={0}
|
||||
aria-label={`${longDay(d.day)}: online ${format(d.online)}, counter ${format(d.counter)}`}
|
||||
>
|
||||
{/* The stack. Counter sits under online so the brand
|
||||
colour caps the column, and the two are separated by a
|
||||
gap in the surface rather than by a stroke. */}
|
||||
<div className="plot-stack" style={{ height: `${(dayTotal / axisTop) * 100}%` }}>
|
||||
<span
|
||||
className="plot-seg"
|
||||
data-kind="online"
|
||||
style={{ flexBasis: `${pct(d.online, dayTotal)}%` }}
|
||||
/>
|
||||
<span
|
||||
className="plot-seg"
|
||||
data-kind="counter"
|
||||
style={{ flexBasis: `${pct(d.counter, dayTotal)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{index === peakIndex ? (
|
||||
<span className="plot-peak" style={{ bottom: `${(dayTotal / axisTop) * 100}%` }}>
|
||||
{format(dayTotal)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{hovered === index ? (
|
||||
<div className="plot-tip" role="status">
|
||||
<span className="plot-tip-day">{longDay(d.day)}</span>
|
||||
<span className="plot-tip-row">
|
||||
<i data-kind="online" />
|
||||
Online
|
||||
<b>{format(d.online)}</b>
|
||||
</span>
|
||||
<span className="plot-tip-row">
|
||||
<i data-kind="counter" />
|
||||
Counter
|
||||
<b>{format(d.counter)}</b>
|
||||
</span>
|
||||
<span className="plot-tip-row" data-total="true">
|
||||
Total<b>{format(dayTotal)}</b>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Every date is drawn — see `showLabel`. */}
|
||||
<div className="plot-days" aria-hidden>
|
||||
{series.map((d) => (
|
||||
<span key={d.day} className="plot-day">
|
||||
{showLabel() ? shortDay(d.day) : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chart-foot">
|
||||
<div className="chart-legend">
|
||||
<span>
|
||||
<i data-kind="online" />
|
||||
Online
|
||||
</span>
|
||||
<span>
|
||||
<i data-kind="counter" />
|
||||
Counter
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="chart-toggle" onClick={() => setIsTable((v) => !v)}>
|
||||
{isTable ? 'Show chart' : 'Show table'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="donut-wrap">
|
||||
<Donut share={onlineShare} total={format(total)} />
|
||||
{/* The channel split: one bar, then the figures. This is what the
|
||||
donut was for, in a form that can be read rather than estimated. */}
|
||||
<div className="split">
|
||||
<div className="split-row">
|
||||
<Share kind="online" label="Online" percent={onlineShare} amount={format(online)} />
|
||||
<Share
|
||||
kind="counter"
|
||||
label="Counter"
|
||||
percent={100 - onlineShare}
|
||||
amount={format(counter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The channel split, on one line under the whole card.
|
||||
It was a stacked column beside the ring, which put the two figures a
|
||||
shopkeeper compares — online against counter — one above the other in
|
||||
a narrow gutter. Side by side they can be read against each other at a
|
||||
glance, and the row runs the full width rather than being squeezed
|
||||
into whatever the ring left over. */}
|
||||
{total > 0 ? (
|
||||
<div className="split-row">
|
||||
<Share
|
||||
kind="online"
|
||||
label="Online Sales"
|
||||
percent={onlineShare}
|
||||
amount={format(online)}
|
||||
/>
|
||||
<Share
|
||||
kind="counter"
|
||||
label="Counter Sales"
|
||||
percent={100 - onlineShare}
|
||||
amount={format(counter)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Share({
|
||||
kind, label, percent, amount,
|
||||
}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) {
|
||||
kind,
|
||||
label,
|
||||
percent,
|
||||
amount,
|
||||
}: {
|
||||
kind: 'online' | 'counter';
|
||||
label: string;
|
||||
percent: number;
|
||||
amount: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="share">
|
||||
<span className="donut-key" data-kind={kind} />
|
||||
<span className="share-key" data-kind={kind} />
|
||||
<span className="share-label">{label}</span>
|
||||
<strong className="share-pct">{percent}%</strong>
|
||||
<span className="share-amt">{amount}</span>
|
||||
@@ -170,42 +270,101 @@ function Share({
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel split as a ring.
|
||||
* The same numbers as a table.
|
||||
*
|
||||
* One circle with a dash offset rather than two arcs: a single stroke cannot
|
||||
* develop a seam at the join, and the whole thing animates from one number.
|
||||
* Not a nicety: in a stacked column the per-channel figure for a single day is
|
||||
* otherwise only in the hover tooltip, which is no use on a touch screen, to a
|
||||
* screen reader, or to anyone printing the page.
|
||||
*/
|
||||
function Donut({ share, total }: { share: number; total: string }) {
|
||||
const radius = 54;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const filled = (share / 100) * circumference;
|
||||
|
||||
function SeriesTable({
|
||||
rows,
|
||||
format,
|
||||
}: {
|
||||
rows: readonly { day: string; online: number; counter: number }[];
|
||||
format: (value: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="donut">
|
||||
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
|
||||
<circle cx="70" cy="70" r={radius} className="donut-track" />
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r={radius}
|
||||
className="donut-fill"
|
||||
strokeDasharray={`${filled} ${circumference - filled}`}
|
||||
// Start at twelve o'clock; the default is three, which reads as a
|
||||
// slice that has already been eaten.
|
||||
transform="rotate(-90 70 70)"
|
||||
/>
|
||||
</svg>
|
||||
<div className="donut-centre">
|
||||
<span>Total Sales</span>
|
||||
<strong>{total}</strong>
|
||||
</div>
|
||||
<div className="chart-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th className="num">Online</th>
|
||||
<th className="num">Counter</th>
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
<tr key={d.day}>
|
||||
<td>{longDay(d.day)}</td>
|
||||
<td className="num">{format(d.online)}</td>
|
||||
<td className="num">{format(d.counter)}</td>
|
||||
<td className="num">
|
||||
<strong>{format(d.online + d.counter)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
|
||||
/**
|
||||
* The next round number at or above `value` — 1, 2 or 5 times a power of ten.
|
||||
*
|
||||
* An axis whose top is the tallest column produces ticks like 4,317 and 8,634,
|
||||
* which nobody can read a bar against. This is what makes them 5,000 and
|
||||
* 10,000.
|
||||
*/
|
||||
function niceCeiling(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
const normalised = value / magnitude;
|
||||
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
|
||||
return step * magnitude;
|
||||
}
|
||||
|
||||
/** "₹12.4K" for an axis tick, where the full figure would not fit the gutter. */
|
||||
function compactMoney(value: number): string {
|
||||
if (value >= 10_000_000) return `₹${(value / 10_000_000).toFixed(1).replace(/\.0$/, '')}Cr`;
|
||||
if (value >= 100_000) return `₹${(value / 100_000).toFixed(1).replace(/\.0$/, '')}L`;
|
||||
if (value >= 1_000) return `₹${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||
return money(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which x labels to draw.
|
||||
*
|
||||
* Currently: all of them. The thinning this used to do — keep about six,
|
||||
* always including the first and the last — is gone by choice, so every date
|
||||
* is drawn.
|
||||
*
|
||||
* It keeps its own function rather than being inlined at the call site, so the
|
||||
* decision has somewhere to live and thinning is one edit away if a 30-day
|
||||
* range turns out to overlap. It takes NO arguments: it was passed the index
|
||||
* and the length, and with the body returning a constant those became unused
|
||||
* parameters, which `noUnusedParameters` fails the build on.
|
||||
*/
|
||||
function showLabel(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
|
||||
function shortDay(day: string): string {
|
||||
const parsed = new Date(day);
|
||||
if (Number.isNaN(parsed.getTime())) return day;
|
||||
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** "2026-09-03" → "Wed, 3 Sep" — the tooltip and table have room for the day. */
|
||||
function longDay(day: string): string {
|
||||
const parsed = new Date(day);
|
||||
if (Number.isNaN(parsed.getTime())) return day;
|
||||
return parsed.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { branchLabel, count, money } from '@/features/store-admin/format';
|
||||
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
|
||||
@@ -99,17 +100,26 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
|
||||
</div>
|
||||
|
||||
<div className="till-controls">
|
||||
<div className="chip-row">
|
||||
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
All <b>{tills.length}</b>
|
||||
</button>
|
||||
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
|
||||
Attention <b>{needing}</b>
|
||||
</button>
|
||||
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
|
||||
Healthy <b>{healthy}</b>
|
||||
</button>
|
||||
</div>
|
||||
<TabBar size="sm" aria-label="Filter tills">
|
||||
<Tab
|
||||
label="All"
|
||||
count={tills.length}
|
||||
isActive={filter === 'all'}
|
||||
onClick={() => setFilter('all')}
|
||||
/>
|
||||
<Tab
|
||||
label="Attention"
|
||||
count={needing}
|
||||
isActive={filter === 'attention'}
|
||||
onClick={() => setFilter('attention')}
|
||||
/>
|
||||
<Tab
|
||||
label="Healthy"
|
||||
count={healthy}
|
||||
isActive={filter === 'healthy'}
|
||||
onClick={() => setFilter('healthy')}
|
||||
/>
|
||||
</TabBar>
|
||||
|
||||
<SearchInput
|
||||
label="Search tills"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Clock } from 'lucide-react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAllPartners } from '@/queries/hooks';
|
||||
import { DispatchBoard } from '@/features/store-admin/pages/DispatchPage';
|
||||
@@ -90,36 +91,29 @@ export function NearleDispatchPage() {
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
/* The store board's own tab styling — `.sbt`, defined in
|
||||
`dispatch.css`, which this page already loads through
|
||||
`DispatchBoard`.
|
||||
/* The console's segmented control, the same one the store board and
|
||||
every other page uses.
|
||||
|
||||
These were written against a `.mode-tab` class that exists in no
|
||||
stylesheet, so they rendered as bare browser buttons directly above
|
||||
a row of properly styled ones. Nothing catches that: the markup is
|
||||
valid, the class is just never matched. */
|
||||
<div className="nearle-view-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={view === 'work' ? 'sbt active' : 'sbt'}
|
||||
These borrowed `.sbt` from `dispatch.css` — the store board's own
|
||||
tab styling, loaded here through `DispatchBoard`. Before that they
|
||||
were written against a `.mode-tab` class that exists in no
|
||||
stylesheet at all, so they rendered as bare browser buttons above a
|
||||
row of styled ones. Nothing catches that: the markup is valid, the
|
||||
class is just never matched. Both problems end the same way. */
|
||||
<TabBar aria-label="Dispatch views">
|
||||
<Tab
|
||||
label="The work"
|
||||
icon={<Bike size={14} />}
|
||||
isActive={view === 'work'}
|
||||
onClick={() => setView('work')}
|
||||
>
|
||||
<span className="sbt-icon">
|
||||
<Bike size={15} />
|
||||
</span>
|
||||
<span>The work</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={view === 'shifts' ? 'sbt active' : 'sbt'}
|
||||
/>
|
||||
<Tab
|
||||
label="Shifts"
|
||||
icon={<Clock size={14} />}
|
||||
isActive={view === 'shifts'}
|
||||
onClick={() => setView('shifts')}
|
||||
>
|
||||
<span className="sbt-icon">
|
||||
<Clock size={15} />
|
||||
</span>
|
||||
<span>Shifts</span>
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</TabBar>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -301,7 +301,7 @@ export function OnboardingPage() {
|
||||
{step === 'catalogue' ? (
|
||||
<CatalogueStep
|
||||
productCount={productCount}
|
||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
|
||||
onUpload={() => navigate('/admin/uploads?upload=1&setup=catalogue')}
|
||||
/* The catalogue tab, not the products list. This pointed at
|
||||
`/admin/inventory` bare, which lands on Products — so "import from
|
||||
the catalogue" showed a merchant their own empty product list. */
|
||||
@@ -312,7 +312,7 @@ export function OnboardingPage() {
|
||||
{step === 'inventory' ? (
|
||||
<InventoryStep
|
||||
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
|
||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
|
||||
onUpload={() => navigate('/admin/uploads?upload=1&setup=inventory')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -199,8 +199,8 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
: options.length > 0
|
||||
? 'Select a rider…'
|
||||
: partnerid > 0
|
||||
? `Nobody on duty — neither this shop's riders nor ${partnerName || "the partner's"}`
|
||||
: 'Nobody on duty, and this shop has no delivery partner'
|
||||
? 'No riders or partners on duty'
|
||||
: 'No riders on duty (no partner)'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,10 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
|
||||
}
|
||||
case 'week': {
|
||||
// Monday-first. A retail week that starts on Sunday makes Monday's
|
||||
// takings the previous week's, which is not how anyone here counts.
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - weekday);
|
||||
return { fromdate: isoDay(monday), todate: isoDay(today) };
|
||||
// 7-day rolling window as expected by the user for "one week data"
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 6);
|
||||
return { fromdate: isoDay(lastWeek), todate: isoDay(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
@@ -42,7 +40,6 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
preset: RangePreset;
|
||||
range: DateRange;
|
||||
onChange: (preset: RangePreset, range: DateRange) => void;
|
||||
}
|
||||
@@ -59,11 +56,25 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
return [
|
||||
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
/* "Last 7 days", not "This week". `presetRange('week')` is a ROLLING
|
||||
window now — today less six — so a label reading "This week" named a
|
||||
calendar week the picker had stopped returning. On a Wednesday the two
|
||||
differ by four days, and the label was the only thing telling the
|
||||
reader which one they had asked for. */
|
||||
{ label: 'Last 7 days', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
];
|
||||
}, []);
|
||||
|
||||
/* No tomorrow.
|
||||
|
||||
The calendar had no upper bound, so every future date was selectable: you
|
||||
could ask this console for next month's takings and it would answer, with
|
||||
an empty report and no indication that the question was the problem rather
|
||||
than the shop. A reporting range cannot end in the future — there is
|
||||
nothing there yet — so the calendar stops at today. */
|
||||
const today = isoDay(new Date()) as ISODateString;
|
||||
|
||||
return (
|
||||
<div className="datefilter" style={{ width: 180 }}>
|
||||
<DateRangeInput
|
||||
@@ -73,6 +84,12 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
isLabelHidden
|
||||
value={astryxRange}
|
||||
presets={presets}
|
||||
max={today}
|
||||
/* Monday-first. Astryx defaults the grid to Sunday, and a retail week
|
||||
that starts on Sunday puts Monday's takings in the previous week —
|
||||
which is not how anyone here counts. This is the calendar GRID only;
|
||||
it is independent of what the "Last 7 days" preset returns. */
|
||||
weekStartsOn="mon"
|
||||
onChange={(newRange) => {
|
||||
if (!newRange) {
|
||||
onChange('custom', {});
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Token } from '@astryxdesign/core/Token';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
import { Funnel, ImageOff } from 'lucide-react';
|
||||
import type { Product } from '@/api/types';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { SelectInput } from '@/components/SelectInput';
|
||||
import { CatalogueSidebar } from '@/features/catalogue/CatalogueSidebar';
|
||||
import { matchesBrand, tenantBrands } from '@/features/catalogue/tenantBrands';
|
||||
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
||||
import { aisleLabelOf } from './appAisle';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { count, money } from './format';
|
||||
import { ProductDrawer } from './ProductDrawer';
|
||||
import { PublishDialog } from './PublishDialog';
|
||||
import { SheetUploadDrawer } from './SheetUploadDrawer';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
@@ -41,16 +44,13 @@ import {
|
||||
* `publishedat` and the ledger balance, because no single backend column tells
|
||||
* the truth on its own. They are shown per row, not as a filter.
|
||||
*/
|
||||
export function ProductsPanel({
|
||||
isUploading,
|
||||
onCloseUpload,
|
||||
}: {
|
||||
/** Owned by InventoryPage, so its button can sit in the tab row. */
|
||||
isUploading: boolean;
|
||||
onCloseUpload: () => void;
|
||||
}) {
|
||||
export function ProductsPanel() {
|
||||
const { scoped, current, tenantid } = useBranchScope();
|
||||
const [category, setCategory] = useState('');
|
||||
const [brand, setBrand] = useState('');
|
||||
/* Closed on arrival, as on the platform catalogue. The rail is a filter, and a
|
||||
filter that opens by itself has narrowed the page before anyone asked. */
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
const [open, setOpen] = useState<Product | null>(null);
|
||||
const [publishing, setPublishing] = useState<Product[] | null>(null);
|
||||
|
||||
@@ -90,6 +90,37 @@ export function ProductsPanel({
|
||||
return out;
|
||||
}, [aisles.data]);
|
||||
|
||||
/* The brands on THIS shop's shelves, counted from its own rows — the platform's
|
||||
brand tables describe a different catalogue. See `tenantBrands`. */
|
||||
const brands = useMemo(() => tenantBrands(products.data ?? []), [products.data]);
|
||||
|
||||
/**
|
||||
* The rail's second level: the aisles present INSIDE the chosen brand.
|
||||
*
|
||||
* Listing every aisle would offer filters that return nothing — a brand that
|
||||
* only stocks dairy would still show Bakery, and picking it empties the table.
|
||||
* The rail is built so that anything it offers has something behind it.
|
||||
*/
|
||||
const railCategories = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const product of products.data ?? []) {
|
||||
if (!matchesBrand(product, brand)) continue;
|
||||
const id = String(product.subcategoryid ?? 0);
|
||||
if (seen.has(id)) continue;
|
||||
seen.set(id, id === '0' ? 'Uncategorized' : (aisleNames.get(Number(id)) ?? 'Uncategorized'));
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [products.data, brand, aisleNames]);
|
||||
|
||||
/* An aisle chosen inside one brand need not exist inside the next, and a
|
||||
filter the new brand has never heard of quietly returns nothing — the same
|
||||
reason the platform catalogue drops its category on a brand change. */
|
||||
useEffect(() => {
|
||||
setCategory('');
|
||||
}, [brand]);
|
||||
|
||||
/**
|
||||
* One list, ordered by what needs doing.
|
||||
*
|
||||
@@ -99,7 +130,9 @@ export function ProductsPanel({
|
||||
*/
|
||||
const rows = useMemo(() => {
|
||||
const filtered = (products.data ?? []).filter(
|
||||
(product) => !category || String(product.subcategoryid ?? 0) === category,
|
||||
(product) =>
|
||||
matchesBrand(product, brand) &&
|
||||
(!category || String(product.subcategoryid ?? 0) === category),
|
||||
);
|
||||
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
|
||||
return [...filtered].sort((a, b) => {
|
||||
@@ -107,7 +140,7 @@ export function ProductsPanel({
|
||||
if (byState !== 0) return byState;
|
||||
return (a.productname ?? '').localeCompare(b.productname ?? '');
|
||||
});
|
||||
}, [products.data, category]);
|
||||
}, [products.data, category, brand]);
|
||||
|
||||
/**
|
||||
* Every product listed can be ticked, not only the unpriced ones.
|
||||
@@ -138,17 +171,25 @@ export function ProductsPanel({
|
||||
outside the panel it read as a page-level control that happened to be
|
||||
nearby. */
|
||||
const filter = (
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Category"
|
||||
isLabelHidden
|
||||
<HStack gap={1} align="center">
|
||||
{/* Without this, closing the rail hides the fact that a brand is still
|
||||
filtering the table — the list would be short for no visible reason. */}
|
||||
{brand ? <Token label={brand} size="sm" onRemove={() => setBrand('')} /> : null}
|
||||
<IconButton
|
||||
label={isFiltersOpen ? 'Hide brands' : 'Filter by brand'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<SelectInput
|
||||
label="Category"
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
placeholder="All categories"
|
||||
options={categoryOptions}
|
||||
width={190}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -175,30 +216,36 @@ export function ProductsPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ProductTable
|
||||
rows={rows}
|
||||
aisleNames={aisleNames}
|
||||
selection={selection}
|
||||
queryKey={category}
|
||||
isLoading={products.isLoading}
|
||||
actions={filter}
|
||||
onOpen={setOpen}
|
||||
onPrice={(product) => setPublishing([product])}
|
||||
/>
|
||||
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
||||
{isFiltersOpen ? (
|
||||
<CatalogueSidebar
|
||||
brands={brands}
|
||||
isLoading={products.isLoading}
|
||||
brand={brand}
|
||||
category={category}
|
||||
categories={railCategories}
|
||||
isLoadingCategories={aisles.isLoading}
|
||||
/* Unbranded rows are listed but not railed, so the sum would be
|
||||
short of what the table shows. */
|
||||
totalCount={(products.data ?? []).length}
|
||||
onBrand={setBrand}
|
||||
onCategory={setCategory}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ProductTable
|
||||
rows={rows}
|
||||
aisleNames={aisleNames}
|
||||
selection={selection}
|
||||
queryKey={`${brand}:${category}`}
|
||||
isLoading={products.isLoading}
|
||||
actions={filter}
|
||||
onOpen={setOpen}
|
||||
onPrice={(product) => setPublishing([product])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open ? <ProductDrawer product={open} onClose={() => setOpen(null)} /> : null}
|
||||
{isUploading ? (
|
||||
<SheetUploadDrawer
|
||||
tenantid={tenantid || undefined}
|
||||
/* `current`, never a fallback to the first branch. An upload becomes
|
||||
opening stock on ONE shelf, so under "All branches" there is no
|
||||
answer to guess — passing nothing leaves the drawer's own branch
|
||||
picker to ask, and it blocks the upload until it is answered.
|
||||
Defaulting here would load a shelf nobody chose. */
|
||||
locationid={current?.locationid}
|
||||
onClose={onCloseUpload}
|
||||
/>
|
||||
) : null}
|
||||
{publishing && tenantid ? (
|
||||
<PublishDialog
|
||||
products={publishing}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Drawer } from './Drawer';
|
||||
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
* Upload a product spreadsheet, from the Products tab.
|
||||
* Upload a product spreadsheet, from the Uploads page.
|
||||
*
|
||||
* The same panel the Nearle Admin uses, with one difference that matters: the
|
||||
* tenant and branch come from the SESSION rather than being asked for. A shop
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Activity, AlertTriangle, IndianRupee, Monitor } from 'lucide-react';
|
||||
@@ -113,13 +114,20 @@ export function CountersPage() {
|
||||
|
||||
<section className="panel">
|
||||
<div className="counter-controls">
|
||||
<div className="chip-row">
|
||||
<Chip label="All" n={counts.all} active={chip === 'all'} onClick={() => setChip('all')} />
|
||||
<Chip label="Active" n={counts.active} tone="healthy" active={chip === 'active'} onClick={() => setChip('active')} />
|
||||
<Chip label="Attention" n={counts.attention} tone="attention" active={chip === 'attention'} onClick={() => setChip('attention')} />
|
||||
<Chip label="Never used" n={counts.never} active={chip === 'never'} onClick={() => setChip('never')} />
|
||||
<Chip label="Offline" n={counts.offline} tone="critical" active={chip === 'offline'} onClick={() => setChip('offline')} />
|
||||
</div>
|
||||
{/* The tones these carried — green on Active, amber on Attention, red
|
||||
on Offline — are not reproduced, and their absence is the point.
|
||||
They only applied to the SELECTED chip, so the strip at rest was
|
||||
five plain pills anyway, and this console's stated position is
|
||||
that it does not colour-code severity: see the note where
|
||||
`BUCKET_COLOR` used to be in `terminalProblems.ts`. The labels
|
||||
and the counts say which rung is the bad one. */}
|
||||
<TabBar size="sm" aria-label="Filter counters">
|
||||
<Tab label="All" count={counts.all} isActive={chip === 'all'} onClick={() => setChip('all')} />
|
||||
<Tab label="Active" count={counts.active} isActive={chip === 'active'} onClick={() => setChip('active')} />
|
||||
<Tab label="Attention" count={counts.attention} isActive={chip === 'attention'} onClick={() => setChip('attention')} />
|
||||
<Tab label="Never used" count={counts.never} isActive={chip === 'never'} onClick={() => setChip('never')} />
|
||||
<Tab label="Offline" count={counts.offline} isActive={chip === 'offline'} onClick={() => setChip('offline')} />
|
||||
</TabBar>
|
||||
</div>
|
||||
|
||||
<div className="counter-controls">
|
||||
@@ -279,21 +287,8 @@ function Kpi({
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
label, n, tone, active, onClick,
|
||||
}: { label: string; n: number; tone?: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
{...(tone ? { 'data-tone': tone } : {})}
|
||||
data-active={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label} <b>{n}</b>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/* The page-local `Chip` lived here — it is the `sm` size of
|
||||
`components/TabBar` now. */
|
||||
|
||||
/**
|
||||
* The counters somebody has to walk to.
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { useSelection, type Selection } from '@/components/useSelection';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
@@ -231,80 +232,69 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
|
||||
return (
|
||||
<div className="dispatch-container">
|
||||
{/* ── Unified Strategy & Operations Toolbar (xpressconsole #strat-row style) ── */}
|
||||
<div id="strat-row" className="strat-row">
|
||||
<div className="strat-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'riders' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('riders')}
|
||||
>
|
||||
<span className="sbt-icon"><Bike size={15} /></span>
|
||||
<span>By Rider</span>
|
||||
<span className="sbt-badge">{totals.riders}</span>
|
||||
</button>
|
||||
{/* The view switcher, on the console's own segmented control.
|
||||
|
||||
These six were `.sbt` — a seventh treatment of "tab" in this app:
|
||||
bordered when idle, a solid brand fill with a purple glow when
|
||||
selected. They now read exactly like the view tabs on Sales, Users,
|
||||
Inventory and the rest.
|
||||
|
||||
The `.strat-row` card around them is gone too. It was a bordered,
|
||||
shadowed toolbar whose only remaining child was this strip — the
|
||||
operations cluster beside it was removed earlier — and a card holding
|
||||
nothing but a tab track is a box drawn round a box. Every other page
|
||||
stands its switcher directly on the page. */}
|
||||
<TabBar aria-label="Dispatch views">
|
||||
<Tab
|
||||
label="By rider"
|
||||
icon={<Bike size={14} />}
|
||||
count={totals.riders || undefined}
|
||||
isActive={mode === 'riders'}
|
||||
onClick={() => changeMode('riders')}
|
||||
/>
|
||||
<Tab
|
||||
label="By store"
|
||||
icon={<Store size={14} />}
|
||||
count={branches.length || undefined}
|
||||
isActive={mode === 'stores'}
|
||||
onClick={() => changeMode('stores')}
|
||||
/>
|
||||
{/* A shop's customer book is its own. Grouping a partner's rounds by
|
||||
customer would mean reading every merchant's book to put a name to
|
||||
a phone number, so the tab is not offered on a partner board. */}
|
||||
{scope.kind === 'tenant' ? (
|
||||
<Tab
|
||||
label="By customer"
|
||||
icon={<Users size={14} />}
|
||||
isActive={mode === 'customers'}
|
||||
onClick={() => changeMode('customers')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'stores' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('stores')}
|
||||
>
|
||||
<span className="sbt-icon"><Store size={15} /></span>
|
||||
<span>By Store</span>
|
||||
<span className="sbt-badge">{branches.length}</span>
|
||||
</button>
|
||||
|
||||
{/* A shop's customer book is its own. Grouping a partner's rounds by
|
||||
customer would mean reading every merchant's book to put a name to
|
||||
a phone number, so the tab is not offered on a partner board. */}
|
||||
{scope.kind === 'tenant' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'customers' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('customers')}
|
||||
>
|
||||
<span className="sbt-icon"><Users size={15} /></span>
|
||||
<span>By Customer</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'active' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('active')}
|
||||
>
|
||||
<span className="sbt-icon"><Radio size={15} /></span>
|
||||
<span>Active Fleet</span>
|
||||
<span className="sbt-dot-live" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'timing' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('timing')}
|
||||
>
|
||||
<span className="sbt-icon"><Timer size={15} /></span>
|
||||
<span>Plan vs Actual</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`sbt ${mode === 'profit' ? 'active' : ''}`}
|
||||
onClick={() => changeMode('profit')}
|
||||
>
|
||||
<span className="sbt-icon"><IndianRupee size={15} /></span>
|
||||
<span>Unit Economics</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* No operations cluster.
|
||||
What stood here was a hub picker with one hard-coded hub, a demo /
|
||||
live toggle, a rider count read off the mock fleet, and a profit
|
||||
chip computed as `value * 0.28` labelled "(84%)" — a number with no
|
||||
source. All four described the page rather than the day's work, and
|
||||
three of them were not true. The KPI row below carries the figures
|
||||
that come from the data. */}
|
||||
</div>
|
||||
{/* The three below group the day differently from the three above:
|
||||
those slice the same stops by whose they are, these report on how
|
||||
the day went. The rule in the track says so. */}
|
||||
<Tab
|
||||
label="Active fleet"
|
||||
icon={<Radio size={14} />}
|
||||
isActive={mode === 'active'}
|
||||
onClick={() => changeMode('active')}
|
||||
isGroupStart
|
||||
isLive
|
||||
/>
|
||||
<Tab
|
||||
label="Plan vs actual"
|
||||
icon={<Timer size={14} />}
|
||||
isActive={mode === 'timing'}
|
||||
onClick={() => changeMode('timing')}
|
||||
/>
|
||||
<Tab
|
||||
label="Unit economics"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={mode === 'profit'}
|
||||
onClick={() => changeMode('profit')}
|
||||
/>
|
||||
</TabBar>
|
||||
|
||||
|
||||
{/* The same four-card row every other page uses — same `kpi-grid`, same
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
CheckCheck,
|
||||
ClipboardList,
|
||||
DownloadCloud,
|
||||
UploadCloud,
|
||||
IndianRupee,
|
||||
Info,
|
||||
Layers,
|
||||
@@ -96,10 +95,6 @@ export function InventoryPage() {
|
||||
const setupProducts = useLocationProducts(tenantid || undefined, undefined, 0, {
|
||||
allBranches: true,
|
||||
});
|
||||
// Owned here so the button can live in the tab row while the drawer it opens
|
||||
// stays inside the panel that uses it.
|
||||
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
|
||||
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||
);
|
||||
@@ -129,29 +124,20 @@ export function InventoryPage() {
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Inventory"
|
||||
/* Upload sheet lives here, not in the tab row, so its position is
|
||||
fixed — it no longer appears and disappears as the tab changes, and
|
||||
nothing below shifts sideways when it does.
|
||||
/* No actions.
|
||||
|
||||
"Upload sheet" used to sit here and now lives only on Uploads. Two
|
||||
doors into the same drawer meant the receipt it produces was listed on
|
||||
a page the uploader had never been to — somebody who imported from
|
||||
Inventory had no reason to know Uploads existed, and no way to see
|
||||
whether the import had worked. One door, on the page that shows the
|
||||
outcome.
|
||||
|
||||
"Import products" used to sit beside it and is gone: once browsing
|
||||
the catalogue became a tab, the button was a second door to a room
|
||||
already on the wall, and it read as a different action than the tab
|
||||
it opened.
|
||||
|
||||
The "N awaiting approval" badge that used to sit here is gone: the
|
||||
Stock requests tab already carries the same count, and a number
|
||||
stated twice on one screen invites the reader to check whether the
|
||||
two agree. */
|
||||
actions={
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<BarAction
|
||||
label="Upload sheet"
|
||||
icon={<UploadCloud size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
"Import products" went earlier, when browsing the catalogue became a
|
||||
tab: the button was a second door to a room already on the wall. The
|
||||
"N awaiting approval" badge went with it, because the Stock requests
|
||||
tab already carries that count and a number stated twice on one screen
|
||||
invites the reader to check whether the two agree. */
|
||||
tabs={
|
||||
/* The tab row carries the Products action on its right, rather than
|
||||
giving it a row of its own below. It only appears on that tab —
|
||||
@@ -189,8 +175,6 @@ export function InventoryPage() {
|
||||
|
||||
{tab === 'products' ? (
|
||||
<ProductsPanel
|
||||
isUploading={isUploading}
|
||||
onCloseUpload={() => setUploading(false)}
|
||||
/>
|
||||
) : tab === 'catalogue' ? (
|
||||
<CataloguePanel />
|
||||
@@ -1169,56 +1153,6 @@ function StockPanel() {
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* An action on the tab bar, drawn as the tabs are drawn.
|
||||
*
|
||||
* Same height, radius, type size and ink as `TabButton`, with no border and no
|
||||
* fill — a filled pill on the right of a row of flat tabs reads as a different
|
||||
* control system sitting in the same strip. It takes a hover tint instead of a
|
||||
* resting one, which is what separates an action from a tab that is simply not
|
||||
* selected.
|
||||
*/
|
||||
function BarAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: isDisabled ? 'default' : 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* The page-local `TabButton` lived here — see `components/TabBar`. */
|
||||
|
||||
function Th({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
ArrowDownUp,
|
||||
AlertTriangle,
|
||||
BadgePercent,
|
||||
BarChart3,
|
||||
Boxes,
|
||||
@@ -24,7 +25,7 @@ import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
@@ -52,8 +53,13 @@ export function ReportsPage() {
|
||||
/* The order rows, not `getlocationsummary`. That endpoint carries no money at
|
||||
all and ignores the date picker — see `branchStats.ts`. These are the rows
|
||||
Sales totals, so the two pages now agree. */
|
||||
const orders = useOrders(tenantid ? { tenantid, ...dates.range, pagesize: 500 } : undefined);
|
||||
const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
|
||||
/* Every order in the window, not the first 500 of them. Each figure below is
|
||||
a SUM, and a sum over one page stops being the total the moment a tenant
|
||||
trades past it — silently, because the old read discarded the envelope and
|
||||
nothing downstream could tell a partial answer from a complete one. */
|
||||
const orders = useAllOrders(tenantid ? { tenantid, ...dates.range } : undefined);
|
||||
const orderRows = orders.data?.rows;
|
||||
const byBranch = useMemo(() => branchOrderStats(orderRows ?? []), [orderRows]);
|
||||
const posSales = usePosSalesByBranch(branchIds, dates.range);
|
||||
|
||||
const rows = useMemo(
|
||||
@@ -133,6 +139,8 @@ export function ReportsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{orders.data?.truncated && tab !== 'inventory' ? <PartialTotals /> : null}
|
||||
|
||||
{tab === 'revenue' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
@@ -336,61 +344,117 @@ function Bar({
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
/**
|
||||
* One bar per trading DAY, from `PosSalesSummary.byday`.
|
||||
*
|
||||
* It used to fold everything into seven fixed weekday slots — `(getDay() + 6) % 7`
|
||||
* into a `Mon…Sun` array. That is right for exactly one window width and wrong
|
||||
* for every other: pick "This month" and all four or five Mondays are summed into
|
||||
* a single bar still labelled "Mon", so a figure covering five days reads as one.
|
||||
* Nothing on screen said so, which is what made it worth fixing rather than
|
||||
* documenting.
|
||||
*
|
||||
* Keyed on the date itself now, so the bar count follows the range instead of the
|
||||
* calendar, and the axis switches from weekday names to dates once a week is
|
||||
* exceeded — "Mon" is only unambiguous while at most one Monday is in view.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parsed as a LOCAL date, not through `new Date(string)`.
|
||||
*
|
||||
* `new Date('2026-09-12')` is parsed as UTC midnight, which is the previous
|
||||
* evening anywhere west of Greenwich — the day a bar belongs to would shift under
|
||||
* the reader depending on their clock. Splitting the parts sidesteps the question
|
||||
* entirely: a trading day is a wall-clock day at the till.
|
||||
*/
|
||||
function parseTradingDay(raw: string): Date | null {
|
||||
const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
|
||||
if (parts) return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]));
|
||||
const loose = new Date(raw);
|
||||
return isNaN(loose.getTime()) ? null : loose;
|
||||
}
|
||||
|
||||
const dayKey = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
|
||||
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
const totals = new Array(7).fill(0);
|
||||
let hasData = false;
|
||||
const points = useMemo(() => {
|
||||
const byDay = new Map<string, { date: Date; amount: number }>();
|
||||
for (const row of rows) {
|
||||
for (const entry of row.byDay) {
|
||||
if (!entry.day) continue;
|
||||
const d = new Date(entry.day);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const dayIndex = (d.getDay() + 6) % 7;
|
||||
totals[dayIndex] += (entry.amount ?? 0);
|
||||
hasData = true;
|
||||
}
|
||||
const date = parseTradingDay(entry.day);
|
||||
if (!date) continue;
|
||||
const key = dayKey(date);
|
||||
const seen = byDay.get(key);
|
||||
if (seen) seen.amount += entry.amount ?? 0;
|
||||
else byDay.set(key, { date, amount: entry.amount ?? 0 });
|
||||
}
|
||||
}
|
||||
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
|
||||
|
||||
/* Chronological. Map order is insertion order, which is whatever sequence the
|
||||
branches came back in — the bars would otherwise be in no order at all. */
|
||||
const sorted = [...byDay.values()].sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
/* Weekday names only while at most one of each is on screen. Past a week they
|
||||
stop identifying a bar and the date has to carry it. */
|
||||
const asWeekdays = sorted.length <= 7;
|
||||
|
||||
return sorted.map(({ date, amount }) => ({
|
||||
name: asWeekdays
|
||||
? date.toLocaleDateString('en-IN', { weekday: 'short' })
|
||||
: date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }),
|
||||
/* The tooltip always names the full date, whatever the axis had room for. */
|
||||
full: date.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' }),
|
||||
amount,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
if (points.length === 0) return null;
|
||||
|
||||
/* Enough room per bar to stay legible, and a scroll when the range outgrows the
|
||||
card — a month of bars squeezed into 400px is a texture, not a chart. */
|
||||
const plotWidth = Math.max(400, points.length * 44);
|
||||
/* Recharts draws every tick unless told otherwise; past a dozen they collide. */
|
||||
const tickInterval = points.length > 12 ? Math.ceil(points.length / 10) - 1 : 0;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" />
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
<div style={{ minWidth: plotWidth, height: '100%' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
<RechartsBarChart data={points} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={tickInterval}
|
||||
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
|
||||
dy={10}
|
||||
/>
|
||||
<Tooltip
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
|
||||
formatter={(value: any) => [money(value as number), 'Takings']}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
formatter={(value) => [money(Number(value) || 0), 'Takings']}
|
||||
/* Recharts hands the axis label back; swap in the full date the
|
||||
tick had no room for. */
|
||||
labelFormatter={(label, payload) => payload?.[0]?.payload?.full ?? label}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-low)',
|
||||
background: 'var(--color-background-surface)',
|
||||
padding: '8px 12px'
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
itemStyle={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<RechartsBar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand)"
|
||||
<RechartsBar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand)"
|
||||
radius={[6, 6, 6, 6]}
|
||||
barSize={36}
|
||||
maxBarSize={36}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -402,24 +466,70 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory reporting, per branch.
|
||||
* Stock across every branch in scope, not whichever one sorted first.
|
||||
*
|
||||
* Fast and slow movers come from the stock statement's `debit` — what actually
|
||||
* left the shelf — rather than from a sales table, because that is the one
|
||||
* figure that counts both channels without double-counting either.
|
||||
* This read `scoped[0]` and presented the result beside figures covering the
|
||||
* whole tenant — inventory value for one shop against revenue for the chain. The
|
||||
* branch name sat in one KPI's note, so the mismatch was disclosed in the single
|
||||
* place a reader was least likely to check it against.
|
||||
*
|
||||
* The rows are summed PER PRODUCT rather than concatenated: the same SKU has a
|
||||
* statement row in each branch that carries it, and a list of those rows would
|
||||
* count one product as several and split its movement across duplicate entries in
|
||||
* the mover lists.
|
||||
*/
|
||||
function InventoryReport() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const statements = useStockStatementByBranch(tenantid || undefined, branchIds, {
|
||||
pagesize: 200,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
|
||||
/* Not memoised, deliberately. `useQueries` hands back a fresh array every
|
||||
render, so any dependency list over it either recomputes anyway or has to be
|
||||
faked with a fingerprint that can go stale while the data changes underneath
|
||||
it. A few hundred rows through a Map is cheaper than that bug. */
|
||||
const rows: {
|
||||
productid: number;
|
||||
productname?: string;
|
||||
debit: number;
|
||||
closing: number;
|
||||
retailprice: number;
|
||||
}[] = [];
|
||||
const byProduct = new Map<number, (typeof rows)[number]>();
|
||||
for (const query of statements) {
|
||||
for (const row of query.data ?? []) {
|
||||
const seen = byProduct.get(row.productid);
|
||||
if (seen) {
|
||||
seen.debit += row.debit ?? 0;
|
||||
seen.closing += row.closing ?? 0;
|
||||
/* Price belongs to the product, not to the branch's row — keep the first
|
||||
real one rather than summing prices across shops. */
|
||||
if (!seen.retailprice) seen.retailprice = row.retailprice ?? 0;
|
||||
if (!seen.productname) seen.productname = row.productname;
|
||||
} else {
|
||||
const entry = {
|
||||
productid: row.productid,
|
||||
productname: row.productname,
|
||||
debit: row.debit ?? 0,
|
||||
closing: row.closing ?? 0,
|
||||
retailprice: row.retailprice ?? 0,
|
||||
};
|
||||
byProduct.set(row.productid, entry);
|
||||
rows.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moving = [...rows].sort((a, b) => b.debit - a.debit);
|
||||
const fast = moving.slice(0, 5);
|
||||
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
|
||||
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
|
||||
const slow = moving.filter((row) => row.closing > 0).slice(-5).reverse();
|
||||
const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0);
|
||||
|
||||
/* Every tile on this tab now covers the same thing, so say what that is on all
|
||||
of them rather than on one. */
|
||||
const coverage =
|
||||
scoped.length === 1 ? (scoped[0]?.locationname ?? '—') : `across ${count(scoped.length)} branches`;
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
@@ -427,21 +537,21 @@ function InventoryReport() {
|
||||
<KpiCard
|
||||
label="Inventory value"
|
||||
value={money(value)}
|
||||
note={branch?.locationname ?? '—'}
|
||||
note={coverage}
|
||||
tone="accent"
|
||||
icon={<Warehouse size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Products"
|
||||
value={count(rows.length)}
|
||||
note="with a stock record"
|
||||
note={`with a stock record · ${coverage}`}
|
||||
tone="neutral"
|
||||
icon={<Package size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Units moved"
|
||||
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
|
||||
note="off the shelf this period"
|
||||
value={count(rows.reduce((sum, row) => sum + row.debit, 0))}
|
||||
note={`off the shelf this period · ${coverage}`}
|
||||
tone="neutral"
|
||||
icon={<ArrowDownUp size={15} />}
|
||||
/>
|
||||
@@ -550,3 +660,33 @@ function Td({
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Said out loud, because a partial total looks exactly like a complete one.
|
||||
*
|
||||
* `ordersAll` stops at its page bound, and past that bound every App figure on
|
||||
* this page is a floor rather than a total. A number that is quietly short is
|
||||
* worse than no number: it is read, trusted and acted on. The way out is the
|
||||
* date picker — a narrower window fits inside the bound and the figures go back
|
||||
* to being exact — so the notice names it rather than just apologising.
|
||||
*/
|
||||
function PartialTotals() {
|
||||
return (
|
||||
<HStack
|
||||
gap={1.5}
|
||||
align="center"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: '1px solid var(--color-warning-line, #F0DFB5)',
|
||||
background: 'var(--color-warning-wash, #FFFBF0)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} style={{ color: 'var(--color-warning, #b7860b)', flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ color: 'var(--color-slate-700)' }}>
|
||||
This window holds more orders than one read returns, so the App figures below are a
|
||||
minimum, not a total. Narrow the date range to see exact numbers.
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
@@ -6,6 +7,13 @@ import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
import { SheetUploadDrawer } from '../SheetUploadDrawer';
|
||||
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
|
||||
import {
|
||||
PROGRESS_STEPS,
|
||||
STEP_LABEL,
|
||||
type StepId,
|
||||
} from '@/features/onboarding/onboardingState';
|
||||
import { useLocationProducts } from '@/queries/hooks';
|
||||
|
||||
/**
|
||||
* This merchant's uploads.
|
||||
@@ -21,8 +29,27 @@ import { SheetUploadDrawer } from '../SheetUploadDrawer';
|
||||
*/
|
||||
export function UploadsPage() {
|
||||
const { user } = useAuth();
|
||||
const { selected } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
const { selected, tenantid } = useBranchScope();
|
||||
const [params] = useSearchParams();
|
||||
/**
|
||||
* Opened by `?upload=1`, so onboarding can send somebody straight to the sheet
|
||||
* drawer.
|
||||
*
|
||||
* That link used to point at Inventory, which carried its own "Upload sheet"
|
||||
* button. Two doors into one drawer put the receipt on a page the uploader had
|
||||
* never visited: a merchant who imported from Inventory had no reason to know
|
||||
* Uploads existed, and so no way to find out whether the import had worked.
|
||||
* The door is here now, on the page that shows the outcome — and the deep link
|
||||
* follows it rather than being dropped, because the setup step depends on it.
|
||||
*/
|
||||
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
|
||||
|
||||
/* Onboarding sends people here mid-setup. Without this they arrive on a page
|
||||
with no way back to the step they left. */
|
||||
const fromSetup = params.get('setup') as StepId | null;
|
||||
const setupProducts = useLocationProducts(tenantid || undefined, undefined, 0, {
|
||||
allBranches: true,
|
||||
});
|
||||
/*
|
||||
No date filter by default, unlike the reporting pages.
|
||||
|
||||
@@ -36,6 +63,21 @@ export function UploadsPage() {
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{/* Above the header, because it is about the errand rather than the page:
|
||||
it is the only thing on screen that knows setup is unfinished. */}
|
||||
{fromSetup ? (
|
||||
<SetupReturnBar
|
||||
position={PROGRESS_STEPS.indexOf(fromSetup) + 1}
|
||||
total={PROGRESS_STEPS.length}
|
||||
stepLabel={STEP_LABEL[fromSetup]}
|
||||
isDone={(setupProducts.data ?? []).length > 0}
|
||||
doneTitle={`${(setupProducts.data ?? []).length} product${
|
||||
(setupProducts.data ?? []).length === 1 ? '' : 's'
|
||||
} added`}
|
||||
href={`/admin/onboarding?advance=${fromSetup}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
/>
|
||||
@@ -46,9 +88,9 @@ export function UploadsPage() {
|
||||
onUpload={() => setUploading(true)}
|
||||
{...(selected === null ? {} : { locationid: selected })}
|
||||
/>
|
||||
{/* The same drawer Inventory opens. Uploading from the page that lists
|
||||
uploads is the obvious place for it, and routing somebody to another
|
||||
screen to press a second button is not a flow, it is a detour. */}
|
||||
{/* The only door to this drawer. Uploading from the page that lists
|
||||
uploads is the obvious place for it, and it is now the single place —
|
||||
Inventory's copy is gone. */}
|
||||
{isUploading ? (
|
||||
<SheetUploadDrawer
|
||||
tenantid={user?.tenantid}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* ══ Counters ══════════════════════════════════════════════════════════════
|
||||
Everything here builds on the Console's tokens and components — .panel,
|
||||
.kpi, .chip, .pill, .grid-table, .tile — so the two pages read as one
|
||||
product. Only what is particular to this page is defined. */
|
||||
.pill, .grid-table, .tile — so the two pages read as one product. Only what
|
||||
is particular to this page is defined.
|
||||
|
||||
`.kpi` and `.chip` used to be on that list and are not any more, because
|
||||
they are no longer CSS at all: the tiles are `components/KpiCard` and the
|
||||
status filter is `components/TabBar` at `size="sm"`, shared with every other
|
||||
page rather than with the Console alone. */
|
||||
|
||||
.counter-controls {
|
||||
display: flex;
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
the part that gets clipped, capped so it does not push the buttons off. */
|
||||
.assign-bar-picker {
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
flex: 1 1 220px;
|
||||
flex: 1 1 300px;
|
||||
}
|
||||
|
||||
/* Pushed to the far end so the result reads as an outcome rather than as
|
||||
|
||||
@@ -70,96 +70,21 @@
|
||||
removing everywhere else. */
|
||||
|
||||
|
||||
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */
|
||||
.strat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
min-height: 42px;
|
||||
background: var(--dispatch-card-bg);
|
||||
border: 1px solid var(--dispatch-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--dispatch-shadow-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* ── Strategy & View Mode Bar (xpressconsole #strat-row) ─────────────────────
|
||||
|
||||
.strat-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
GONE. `.strat-row`, `.strat-tabs`, `.strat-ops-cluster`, `.sbt` and its
|
||||
icon / badge / live-dot parts were a bordered, shadowed toolbar holding a
|
||||
seventh treatment of "tab": outlined when idle, a solid brand fill with a
|
||||
purple glow when selected.
|
||||
|
||||
.strat-ops-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
Both boards render `components/TabBar` now — the store board's six views
|
||||
and the platform board's two, which had been borrowing `.sbt` from this
|
||||
file across a feature boundary. The card around the strip went with it:
|
||||
its only remaining child was the tab row, and every other page in the
|
||||
console stands its switcher directly on the page.
|
||||
|
||||
.sbt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dispatch-border);
|
||||
background: var(--dispatch-bg);
|
||||
color: var(--dispatch-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-family: inherit;
|
||||
}
|
||||
The live dot on Active Fleet survives as `Tab`'s `isLive`. */
|
||||
|
||||
.sbt:hover {
|
||||
background: var(--dispatch-bg-sub);
|
||||
color: var(--dispatch-text);
|
||||
border-color: rgba(102, 37, 130, 0.3);
|
||||
}
|
||||
|
||||
.sbt.active {
|
||||
background: var(--dispatch-accent);
|
||||
border-color: var(--dispatch-accent);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 3px 10px rgba(102, 37, 130, 0.25);
|
||||
}
|
||||
|
||||
.sbt-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sbt-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--dispatch-bg-sub);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--dispatch-text);
|
||||
}
|
||||
|
||||
.sbt.active .sbt-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbt-dot-live {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Wave / Batch Slots */
|
||||
/* No KPI overrides.
|
||||
|
||||
@@ -59,12 +59,19 @@ export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
|
||||
/* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a
|
||||
rail down each card and a dot beside each heading.
|
||||
|
||||
It is gone rather than moved. The console does not colour-code severity:
|
||||
`KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour
|
||||
ramp existed on this page and nowhere else in the product. Urgency is already
|
||||
carried twice over — by the group's name, in plain words, and by the order the
|
||||
groups appear in — so the colour was a third telling, in the loudest register
|
||||
available, on the one page a supervisor opens when they are already worried. */
|
||||
It is gone rather than moved. The console does not paint a severity RAMP:
|
||||
urgency here is already carried twice over — by the group's name, in plain
|
||||
words, and by the order the groups appear in — so a red/amber/green scale was
|
||||
a third telling, in the loudest register available, on the one page a
|
||||
supervisor opens when they are already worried.
|
||||
|
||||
This note used to cite `KpiCard` mapping every tone to `--color-brand` as the
|
||||
supporting evidence. That is no longer true and the citation would mislead:
|
||||
`KpiCard`'s tone now tints its icon tile, and `success` is used on five
|
||||
tiles. The distinction that survives is between a single accent marking one
|
||||
state and a graded scale applied across a whole strip — the filter chips on
|
||||
this page carried the second and were swept into `components/TabBar`, which
|
||||
has no tone at all. */
|
||||
|
||||
export interface ProblemContext {
|
||||
/** Evaluation instant, so a whole board is judged against one clock. */
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Token } from '@astryxdesign/core/Token';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
|
||||
import { Boxes, ClipboardList, Funnel, PackageSearch } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { SelectInput } from '@/components/SelectInput';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
useStockRequests,
|
||||
useStockStatement,
|
||||
} from '@/queries/hooks';
|
||||
import { CatalogueSidebar } from '@/features/catalogue/CatalogueSidebar';
|
||||
import { matchesBrand, tenantBrands } from '@/features/catalogue/tenantBrands';
|
||||
import { StoreSetupReturn } from '@/features/setup/StoreSetupReturn';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
|
||||
@@ -63,6 +67,10 @@ export function StoreProductsPage() {
|
||||
|
||||
const [tab, setTab] = useState<Tab>('catalogue');
|
||||
const [category, setCategory] = useState('');
|
||||
const [brand, setBrand] = useState('');
|
||||
/* Closed on arrival, as on the platform catalogue — a filter that opens by
|
||||
itself has narrowed the shelf before the shopkeeper asked. */
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [open, setOpen] = useState<Product | null>(null);
|
||||
const [asking, setAsking] = useState<{ product: Product; qty: number } | null>(null);
|
||||
@@ -96,10 +104,49 @@ export function StoreProductsPage() {
|
||||
* `WHERE publishedat IS NOT NULL`. And `status` is not the flag — it is
|
||||
* rewritten to available/outofstock on every stock movement.
|
||||
*/
|
||||
/**
|
||||
* This shop's shelf: released and priced, before any filter is applied.
|
||||
*
|
||||
* The rail is counted from THIS rather than from every row the tenant owns.
|
||||
* A brand that exists only on unreleased products is not on the shelf, and
|
||||
* listing it would offer a filter that empties the page — the rail is built so
|
||||
* that everything it offers has something behind it.
|
||||
*/
|
||||
const released = useMemo(
|
||||
() => (products.data ?? []).filter((product) => isPublished(product) && effectivePrice(product) > 0),
|
||||
[products.data],
|
||||
);
|
||||
|
||||
const brands = useMemo(() => tenantBrands(released), [released]);
|
||||
|
||||
/* The aisles stocked INSIDE the chosen brand — the rail's second level. */
|
||||
const railCategories = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
const names = new Map<number, string>();
|
||||
for (const row of aisles.data ?? []) {
|
||||
if (row.subcatid > 0 && row.subcatname) names.set(row.subcatid, row.subcatname);
|
||||
}
|
||||
for (const product of released) {
|
||||
if (!matchesBrand(product, brand)) continue;
|
||||
const id = String(product.subcategoryid ?? 0);
|
||||
if (seen.has(id)) continue;
|
||||
seen.set(id, id === '0' ? 'Uncategorized' : (names.get(Number(id)) ?? 'Uncategorized'));
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [released, brand, aisles.data]);
|
||||
|
||||
/* An aisle stocked by one brand need not be stocked by the next, and a filter
|
||||
the new brand has never heard of quietly returns nothing. */
|
||||
useEffect(() => {
|
||||
setCategory('');
|
||||
}, [brand]);
|
||||
|
||||
const catalogue = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return (products.data ?? [])
|
||||
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
|
||||
return released
|
||||
.filter((product) => matchesBrand(product, brand))
|
||||
.filter((product) => !category || String(product.subcategoryid ?? 0) === category)
|
||||
.filter((product) =>
|
||||
term === ''
|
||||
@@ -109,7 +156,7 @@ export function StoreProductsPage() {
|
||||
.includes(term),
|
||||
)
|
||||
.sort((a, b) => (a.productname ?? '').localeCompare(b.productname ?? ''));
|
||||
}, [products.data, category, keyword]);
|
||||
}, [released, brand, category, keyword]);
|
||||
|
||||
const stockRows = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
@@ -154,16 +201,32 @@ export function StoreProductsPage() {
|
||||
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Products"
|
||||
title="Inventory"
|
||||
actions={
|
||||
tab === 'requests' ? null : (
|
||||
<SearchInput
|
||||
label="Search products"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
width={240}
|
||||
/>
|
||||
<HStack gap={1} align="center">
|
||||
{/* Shown whenever a brand is narrowing the shelf, so closing the
|
||||
rail cannot leave the list short for no visible reason. */}
|
||||
{tab === 'catalogue' && brand ? (
|
||||
<Token label={brand} size="sm" onRemove={() => setBrand('')} />
|
||||
) : null}
|
||||
{tab === 'catalogue' ? (
|
||||
<IconButton
|
||||
label={isFiltersOpen ? 'Hide brands' : 'Filter by brand'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
<SearchInput
|
||||
label="Search products"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
width={240}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
tabs={
|
||||
@@ -192,17 +255,35 @@ export function StoreProductsPage() {
|
||||
/>
|
||||
|
||||
{tab === 'catalogue' ? (
|
||||
<CataloguePanel
|
||||
rows={catalogue}
|
||||
isLoading={products.isLoading}
|
||||
latest={latest}
|
||||
categoryOptions={categoryOptions}
|
||||
category={category}
|
||||
onCategory={setCategory}
|
||||
onOpen={setOpen}
|
||||
onAsk={(product, qty) => setAsking({ product, qty })}
|
||||
onAskMany={setAskingMany}
|
||||
/>
|
||||
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
||||
{isFiltersOpen ? (
|
||||
<CatalogueSidebar
|
||||
brands={brands}
|
||||
isLoading={products.isLoading}
|
||||
brand={brand}
|
||||
category={category}
|
||||
categories={railCategories}
|
||||
isLoadingCategories={aisles.isLoading}
|
||||
/* Released rows with no brand are on the shelf but not in the rail,
|
||||
so the sum would be short of what the shopkeeper can see. */
|
||||
totalCount={released.length}
|
||||
onBrand={setBrand}
|
||||
onCategory={setCategory}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CataloguePanel
|
||||
rows={catalogue}
|
||||
isLoading={products.isLoading}
|
||||
latest={latest}
|
||||
categoryOptions={categoryOptions}
|
||||
category={category}
|
||||
onCategory={setCategory}
|
||||
onOpen={setOpen}
|
||||
onAsk={(product, qty) => setAsking({ product, qty })}
|
||||
onAskMany={setAskingMany}
|
||||
/>
|
||||
</div>
|
||||
) : tab === 'stock' ? (
|
||||
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
|
||||
) : (
|
||||
@@ -297,17 +378,13 @@ function CataloguePanel({
|
||||
? 'Reading your catalogue…'
|
||||
: `${count(rows.length)} product${rows.length === 1 ? '' : 's'} released to this shop`}
|
||||
</Text>
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Category"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={category}
|
||||
onChange={onCategory}
|
||||
placeholder="All categories"
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</div>
|
||||
<SelectInput
|
||||
label="Category"
|
||||
value={category}
|
||||
onChange={onCategory}
|
||||
options={categoryOptions}
|
||||
width={190}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -165,7 +165,7 @@ export function StoreSetupPage() {
|
||||
icon: <Boxes size={20} />,
|
||||
title: 'See your shelf',
|
||||
body: 'What is priced, what is in stock, and what is out.',
|
||||
cta: 'Open products',
|
||||
cta: 'Open inventory',
|
||||
onClick: () => navigate('/store/products'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
@@ -333,22 +334,17 @@ export function UploadsPanel({
|
||||
<VStack gap={2}>
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||
<div className="up-toolbar">
|
||||
<div className="up-tabs" role="tablist" aria-label="Filter uploads">
|
||||
<TabBar size="sm" aria-label="Filter uploads">
|
||||
{TABS.map((entry) => (
|
||||
<button
|
||||
<Tab
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
className="up-tab"
|
||||
aria-selected={tab === entry.id}
|
||||
data-active={tab === entry.id}
|
||||
label={entry.label}
|
||||
count={counts[entry.id]}
|
||||
isActive={tab === entry.id}
|
||||
onClick={() => setTab(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
<span>{counts[entry.id]}</span>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabBar>
|
||||
|
||||
<div className="up-toolbar-spacer" />
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,37 @@
|
||||
const MARKER = 'nearle:stale-chunk-reload-at';
|
||||
const COOLDOWN_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The chunk an error is about, so the guard counts PER MODULE.
|
||||
*
|
||||
* This is the fix for "I get this error on many pages". The marker used to be
|
||||
* a single timestamp for the whole tab, so the first failing route reloaded
|
||||
* and recovered — and then every OTHER route that failed within the next
|
||||
* thirty seconds was refused a reload and shown the error instead, even though
|
||||
* a reload is exactly what each of them needed. One deploy replaces every
|
||||
* chunk, so a person clicking through three pages hit three different missing
|
||||
* modules and the guard only helped with the first.
|
||||
*
|
||||
* A second failure of the SAME module still refuses, which is the loop the
|
||||
* guard exists to prevent: after a reload that module either loads or it is
|
||||
* genuinely gone, and reloading again would not change that.
|
||||
*
|
||||
* The filename is enough to identify it and keeps the key short. Content
|
||||
* hashes change with every deploy, so these markers expire on their own rather
|
||||
* than needing to be cleaned up.
|
||||
*/
|
||||
function moduleKeyOf(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
const url = /https?:\/\/\S+|\/[^\s:]+\.(?:js|mjs|tsx?|css)/i.exec(message)?.[0];
|
||||
if (!url) {
|
||||
/* Safari says only "Importing a module script failed." with no URL. Those
|
||||
share one key and therefore one reload per cooldown — the old behaviour,
|
||||
kept because there is nothing to tell them apart by. */
|
||||
return 'unknown';
|
||||
}
|
||||
return url.split('?')[0]?.split('/').pop() || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a thrown value is a chunk that would not load.
|
||||
*
|
||||
@@ -51,17 +82,17 @@ export function isStaleChunkError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/** Private-mode Safari throws on `sessionStorage`; a failure here must not mask the real error. */
|
||||
function readMarker(): number {
|
||||
function readMarker(key: string): number {
|
||||
try {
|
||||
return Number(window.sessionStorage.getItem(MARKER)) || 0;
|
||||
return Number(window.sessionStorage.getItem(`${MARKER}:${key}`)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMarker(at: number): void {
|
||||
function writeMarker(key: string, at: number): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(MARKER, String(at));
|
||||
window.sessionStorage.setItem(`${MARKER}:${key}`, String(at));
|
||||
} catch {
|
||||
/* Storage unavailable. The reload still happens; only the loop guard is
|
||||
lost, and a browser that cannot store this cannot loop through it
|
||||
@@ -77,7 +108,8 @@ function writeMarker(at: number): void {
|
||||
* arrived just throws the same error again, which is what the old button did.
|
||||
*/
|
||||
export function canRecoverByReloading(error: unknown): boolean {
|
||||
return isStaleChunkError(error) && Date.now() - readMarker() > COOLDOWN_MS;
|
||||
if (!isStaleChunkError(error)) return false;
|
||||
return Date.now() - readMarker(moduleKeyOf(error)) > COOLDOWN_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,8 +121,8 @@ export function canRecoverByReloading(error: unknown): boolean {
|
||||
* the navigation happens, so the last frame is the spinner rather than a flash
|
||||
* of an error the user cannot act on.
|
||||
*/
|
||||
export function reloadForStaleChunk(): Promise<never> {
|
||||
writeMarker(Date.now());
|
||||
export function reloadForStaleChunk(error?: unknown): Promise<never> {
|
||||
writeMarker(moduleKeyOf(error), Date.now());
|
||||
window.location.reload();
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
@@ -105,7 +137,7 @@ export function reloadForStaleChunk(): Promise<never> {
|
||||
export function withStaleChunkRecovery<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
return () =>
|
||||
load().catch((error: unknown) => {
|
||||
if (canRecoverByReloading(error)) return reloadForStaleChunk();
|
||||
if (canRecoverByReloading(error)) return reloadForStaleChunk(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,6 +436,34 @@ export function useStockStatement(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock statement for EVERY branch in scope, one request each.
|
||||
*
|
||||
* `getstockstatement` takes a single `locationid` and there is no tenant-wide
|
||||
* stock read, so a multi-branch view fans out — the same constraint, and the same
|
||||
* shape of answer, as `usePosSalesByBranch`.
|
||||
*
|
||||
* The Reports inventory tab read `scoped[0]` before this: whatever branch
|
||||
* happened to sort first stood in for the whole tenant, on a page where every
|
||||
* other figure covers all of them. A reader comparing inventory value against
|
||||
* this period's revenue was comparing one shop against the chain.
|
||||
*/
|
||||
export function useStockStatementByBranch(
|
||||
tenantid: number | undefined,
|
||||
locationIds: readonly number[],
|
||||
params: { keyword?: string; pagesize?: number } = {},
|
||||
) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.stock.statement(tenantid ?? 0, locationid, params),
|
||||
queryFn: () =>
|
||||
stockApi.statement({ tenantid: tenantid as number, locationid, ...params }),
|
||||
enabled: Boolean(tenantid),
|
||||
...live,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Store Admin: order and bill rows ────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -457,6 +485,30 @@ export function useOrders(query: OrderQuery | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every order in the window, paged until the rows run out.
|
||||
*
|
||||
* For the screens that TOTAL orders rather than list them. `useOrders` returns
|
||||
* one page and is right for a table with a pager under it; a sum built from one
|
||||
* page is simply wrong past that page, and wrong without saying so.
|
||||
*
|
||||
* Returns the flag as well as the rows: at the page bound the figures ARE partial
|
||||
* and the screen has to say so. A caller that ignores `truncated` has recreated
|
||||
* the bug this exists to fix.
|
||||
*/
|
||||
export function useAllOrders(query: OrderQuery | undefined, pagesize = 500) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.orderList({
|
||||
...((query ?? {}) as unknown as Record<string, unknown>),
|
||||
all: true,
|
||||
pagesize,
|
||||
}),
|
||||
queryFn: () => insightsApi.ordersAll(query as OrderQuery, { pagesize }),
|
||||
enabled: Boolean(query?.tenantid || query?.partnerid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter bills, per branch.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user