diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx
index d3efd4d..c8a73eb 100644
--- a/src/components/PageHeader.tsx
+++ b/src/components/PageHeader.tsx
@@ -2,23 +2,32 @@ import type { ReactNode } from 'react';
export interface PageHeaderProps {
title: string;
- /** A real count beside the name — "8 on record". Tabular, meta-coloured. */
- count?: string;
- /** One line of context. */
- description?: string;
/** Right-aligned actions, wrapping. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
tabs?: ReactNode;
- isLive?: boolean;
+ /**
+ * Put the tabs on the SAME line as the actions — tabs left, actions right —
+ * instead of on a row of their own beneath.
+ *
+ * Worth having now that the page titles are gone: with nothing above them the
+ * tabs and a lone button sat on two nearly empty lines, and pulling them onto
+ * one gives the page back a row without crowding anything.
+ */
+ isTabsInline?: boolean;
}
/**
* The page frame header, built to KROW's `AdminPage` spec.
*
- * Title + count + a live pill on one line, a one-line subtitle beneath, actions
- * right-aligned and wrapping. Everything the page stacks below sits on a 24px
- * rhythm.
+ * Actions right-aligned and wrapping, with an optional tabs row beneath.
+ * Everything the page stacks below sits on a 24px rhythm.
+ *
+ * The visible title, count, Live pill and description are gone from every page
+ * in all three workspaces. They restated what the chrome already says — the top
+ * bar names the section, the branch picker gives the count — and the
+ * description was a line of prose above data that nobody reads twice. The title
+ * survives as a screen-reader-only h1; see the note at the call.
*
* There was a hairline rule under all of this, with 16px of padding above it
* and another 12px below before the tabs — 28px of nothing plus a line, on
@@ -31,87 +40,53 @@ export interface PageHeaderProps {
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
*/
-export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
+export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
+ const hasRow = Boolean(actions || (isTabsInline && tabs));
+
return (
<>
-
-
-
-
- {title}
-
+ {/*
+ The title is still here, and still read aloud — it is just not drawn.
- {count ? (
-
- {count}
-
- ) : null}
+ Removing it visually was the ask; removing it outright would leave every
+ page in the console with no h1, no document outline and nothing for a
+ screen reader to announce on navigation. `sr-only` is absolutely
+ positioned, so it is out of flow and costs no layout.
+ */}
+
{title}
- {isLive ? (
-
-
- Live
-
- ) : null}
-
+ {/*
+ No row at all when there is nothing to put in it.
- {description ? (
-
- {description}
-
+ It used to render an empty regardless. At zero height that
+ looks free, but it is still a flex child, so it collected the column's
+ 12px gap and pushed everything down — a page with no actions sat 36px
+ below the nav bar while Inventory sat at 24. The gap now comes from one
+ place, the column's own padding, and every page matches.
+ */}
+ {hasRow ? (
+
+ {/* Left of the row when inline, so the tabs start at the page's left
+ edge and the actions stay on the right. */}
+ {isTabsInline && tabs ?
{tabs}
: }
+
+ {actions ? (
+
+ {actions}
+
) : null}
-
+
+ ) : null}
- {actions ? (
-
- {actions}
-
- ) : null}
-
- {tabs ?
{tabs}
: null}
+ {tabs && !isTabsInline ?
{tabs}
: null}
>
);
}
diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
index a8cddd8..e7d20e9 100644
--- a/src/components/shell/AppShell.tsx
+++ b/src/components/shell/AppShell.tsx
@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel';
+import { DateScopePicker } from './DateScope';
export interface NavEntry {
to: string;
@@ -133,14 +134,26 @@ export function AppShell({
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
on exactly the same left edge as the page title below them at every
width, including a 2560px monitor where the body is centred. */}
+ {/*
+ Opaque, with no backdrop blur.
+
+ The blur cost more than it bought the moment a popover moved into this
+ bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
+ `position: fixed` descendant, and the design system's popovers are fixed
+ and CSS-anchor-positioned — so the date picker's calendar rendered
+ inside the header at 0×0 and could not be opened at all. The same
+ control worked perfectly two pixels lower, on the page.
+
+ A solid background is the fix rather than a hack around it: the bar sits
+ on a near-white page, so at 85% opacity plus blur it was already almost
+ opaque, and nothing here reads differently for losing it.
+ */}
@@ -247,6 +260,13 @@ export function AppShell({
the catalogue are real and stay. A control that looks like it
works costs more trust than a missing one. */}
+ {/* The date filter, beside the profile and common to every page.
+
+ Rendered here rather than by each page so the two questions the
+ console asks — which shop, and when — are both answered in the
+ chrome, and so the answer survives navigation. */}
+
+
{headerActions}
{/* No notification bell. It was labelled "2 unread" with the dot
diff --git a/src/components/shell/DateScope.tsx b/src/components/shell/DateScope.tsx
new file mode 100644
index 0000000..4947787
--- /dev/null
+++ b/src/components/shell/DateScope.tsx
@@ -0,0 +1,85 @@
+import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
+import type { DateRange } from '@/api/insights';
+import {
+ DateRangePicker,
+ presetRange,
+ type RangePreset,
+} from '@/features/store-admin/DateRangePicker';
+
+/**
+ * The console's date filter, held once for the whole workspace.
+ *
+ * It sits in the top bar beside the profile, not on the page, and every page
+ * reads it from here — the same relationship `BranchScope` already has to the
+ * branch picker beside the logo. The two scopes now work the same way: the two
+ * questions every page is asked, "which shop" and "when", are answered once in
+ * the chrome rather than re-answered on each screen.
+ *
+ * ── What this changes about the pages ───────────────────────────────────────
+ *
+ * The range survives navigation. Setting March on Sales and clicking through to
+ * Reports shows March, which is what somebody looking into a month actually
+ * wants and is the whole reason for lifting it. It also means the live boards —
+ * Console, Counters, Terminals — no longer force themselves back to today; they
+ * follow the shared range like everything else. That is the trade the move
+ * makes, and it is the right one, but it IS a change: those three used to be
+ * pinned to the day whatever else you had chosen.
+ */
+
+export interface DateScopeValue {
+ preset: RangePreset;
+ range: 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. */
+ isFiltered: boolean;
+}
+
+/** Where the console starts. Month to date is what most pages want. */
+export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
+
+const DateScopeContext = createContext(null);
+
+export function DateScopeProvider({ children }: { children: ReactNode }) {
+ const [preset, setPreset] = useState(DEFAULT_RANGE_PRESET);
+ const [range, setRange] = useState(() => presetRange(DEFAULT_RANGE_PRESET));
+
+ const value = useMemo(
+ () => ({
+ preset,
+ range,
+ set: (nextPreset, nextRange) => {
+ setPreset(nextPreset);
+ setRange(nextRange);
+ },
+ clear: () => {
+ setPreset('custom');
+ setRange({});
+ },
+ isFiltered: Boolean(range.fromdate || range.todate),
+ }),
+ [preset, range],
+ );
+
+ return {children};
+}
+
+/**
+ * The shared range.
+ *
+ * Throws outside the provider rather than inventing a local range: a page that
+ * silently filtered on its own dates while the bar showed something else would
+ * be the exact confusion this exists to remove.
+ */
+export function useDateScope(): DateScopeValue {
+ const value = useContext(DateScopeContext);
+ if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
+ return value;
+}
+
+/** The control itself. Rendered once, in the top bar. */
+export function DateScopePicker() {
+ const dates = useDateScope();
+ return ;
+}
diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx
index 7a72a08..79f7ab7 100644
--- a/src/features/catalogue/CatalogueDetailDrawer.tsx
+++ b/src/features/catalogue/CatalogueDetailDrawer.tsx
@@ -204,6 +204,7 @@ export function CatalogueDetailDrawer({
lookup is needed to find the key. */}
{product.highlights?.length || product.nutrients?.length ? (
diff --git a/src/features/console/ConsolePage.tsx b/src/features/console/ConsolePage.tsx
index a76dda3..30d55a1 100644
--- a/src/features/console/ConsolePage.tsx
+++ b/src/features/console/ConsolePage.tsx
@@ -1,6 +1,5 @@
-import { useMemo, useState } from 'react';
-import type { DateRange } from '@/api/insights';
-import { DateRangePicker, presetRange, type RangePreset } from '@/features/store-admin/DateRangePicker';
+import { useMemo } from 'react';
+import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus';
import {
@@ -50,12 +49,11 @@ export function ConsolePage() {
useBranchScope();
const base = isPinned ? '/store' : '/admin';
- const [preset, setPreset] = useState('today');
- const [range, setRange] = useState(() => presetRange('today'));
+ const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
- const posNow = usePosSalesByBranch(branchIds, range);
+ const posNow = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
allBranches: true,
@@ -68,7 +66,7 @@ export function ConsolePage() {
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
[posNow],
);
- const series = useConsoleSeries(tenantid, selected, range, posNowData);
+ const series = useConsoleSeries(tenantid, selected, dates.range, posNowData);
// One instant for the whole board, so two tills read a second apart are not
// judged against two different "nows".
@@ -130,27 +128,7 @@ export function ConsolePage() {
return (
- }
- />
-
-
-
- )
- }
- >
- {/* Columns carry meaning, so the table scrolls sideways rather
- than dropping any of them. The page itself never scrolls wide. */}
-
-
- data={rows}
- columns={columns}
- idKey="tenantid"
- density="balanced"
- hasHover
- dividers="rows"
- />
-
-
-
-
-
-
- Page {page}
- {search ? ` · filtered from ${rows.length} on this page` : ''}
-
-
-
-
-
- )}
-
- );
-}
-
-/**
- * Merchants that have been created but never approved.
- *
- * Read-only, and it says why on the panel rather than offering a button that
- * cannot work: `tenants.approved` is written once, at creation, and there is no
- * update or approve endpoint anywhere in the backend. Until there is, this is
- * the only place these rows are visible at all — `getalltenants` cannot see
- * them.
- */
-function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
- if (isLoading) {
- return (
-
-
-
- Reading the approval queue…
-
-
-
- );
- }
-
- if (rows.length === 0) {
- return (
-
-
-
- Nothing waiting
-
-
- Every merchant on the platform has been approved.
-
-
-
- );
- }
-
- return (
-
-
- These merchants exist but are not approved, so they do not appear in the directory.
- Approving is a database change today — the backend has no endpoint for it.
-
-
-
+
+
+ setTab('directory')}
+ />
+ setTab('pending')}
+ />
+
+
+ {tab === 'pending' ? (
+
+ ) : (
+
+
+
+
+ }
+ />
+
+
+
+ )
+ }
+ >
+ {/* Columns carry meaning, so the table scrolls sideways rather
+ than dropping any of them. The page itself never scrolls wide. */}
+
+
+ data={rows}
+ columns={columns}
+ idKey="tenantid"
+ density="balanced"
+ hasHover
+ dividers="rows"
+ />
+
+
+
+
+
+
+ Page {page}
+ {search ? ` · filtered from ${rows.length} on this page` : ''}
+
+
+ setPage((current) => Math.max(1, current - 1))}
+ />
+ setPage((current) => current + 1)}
+ />
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * Merchants that have been created but never approved.
+ *
+ * Read-only, and it says why on the panel rather than offering a button that
+ * cannot work: `tenants.approved` is written once, at creation, and there is no
+ * update or approve endpoint anywhere in the backend. Until there is, this is
+ * the only place these rows are visible at all — `getalltenants` cannot see
+ * them.
+ */
+function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
+ if (isLoading) {
+ return (
+
+
+
+ Reading the approval queue…
+
+
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return (
+
+
+
+ Nothing waiting
+
+
+ Every merchant on the platform has been approved.
+
+
+
+ );
+ }
+
+ return (
+
+
+ These merchants exist but are not approved, so they do not appear in the directory.
+ Approving is a database change today — the backend has no endpoint for it.
+
+
+
+ );
+}
diff --git a/src/features/nearle-admin/pages/UploadsPage.tsx b/src/features/nearle-admin/pages/UploadsPage.tsx
index ab9c611..d315553 100644
--- a/src/features/nearle-admin/pages/UploadsPage.tsx
+++ b/src/features/nearle-admin/pages/UploadsPage.tsx
@@ -1,3 +1,4 @@
+import { useDateScope } from '@/components/shell/DateScope';
import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
@@ -13,17 +14,26 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
*/
export function UploadsPage() {
const navigate = useNavigate();
+ /*
+ No date filter by default, unlike the reporting pages.
+
+ This is a receipt log, and the reason somebody opens it is to find an upload
+ from a while ago. Starting at month-to-date hid every receipt older than the
+ 1st — the list read "Nothing matches" while the tab beside it said there were
+ two. The read already returns only the most recent fifty, so the range is a
+ narrowing rather than the thing that makes the page tractable.
+ */
+ const dates = useDateScope();
return (
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
different action from a merchant sending their own list — so this goes
to the page that owns it rather than opening a merchant's drawer. */}
- navigate('/nearle/catalogue?tab=sheet')} />
+ navigate('/nearle/catalogue?tab=sheet')} />
);
}
diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx
index aaa35f3..e4d82bc 100644
--- a/src/features/store-admin/HealthScorePanel.tsx
+++ b/src/features/store-admin/HealthScorePanel.tsx
@@ -3,7 +3,7 @@ import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition';
import type { Product } from '@/api/types';
import { DrawerCard, Note, Row, Section } from './drawerKit';
-import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
+import { BAND_COLOR, BAND_LABEL, facts, isEdible, present } from './healthScore';
import './pages/deliveries.css';
/**
@@ -26,10 +26,40 @@ import './pages/deliveries.css';
* confident one — see the caveat, which is shown whenever the service's own
* match confidence is below 70%.
*/
-export function HealthScorePanel({ product }: { product: Product }) {
+export function HealthScorePanel({
+ product,
+ category,
+}: {
+ product: Product;
+ /**
+ * The product's category, used to decide whether a nutrition score means
+ * anything for it at all. Passed in because the two callers hold it in
+ * different fields — a tenant product has `categoryname`, a catalogue row has
+ * `category` — and neither is on the narrow shape this panel receives.
+ */
+ category?: string | null;
+}) {
const brand = (product.productbrand ?? '').trim();
const imageId = (product.imageid ?? '').trim();
+ /*
+ Soap does not have a health score, and the service will give you one anyway.
+
+ Measured again 7 Sep 2026: `GET /nutrition/Godrej/godrej_hit_spray_1101d017`
+ returns `health_score: 80`, `data_status: "verified"` — an "excellent" score
+ for insecticide. Palmolive and Pantene score 37.5 through the same route.
+
+ The catalogue team's own guidance is to hide the field entirely for non-food
+ rather than show an empty slot, and their per-product endpoint still does not
+ publish the edibility label that gates their LIST endpoint — `data_status` is
+ "unavailable" for both a soap and an unscored food, and `category` is
+ populated for both, so nothing in the response separates them. Our own
+ category does, so the decision is made here until they publish theirs.
+
+ Before the network call, deliberately: there is nothing to ask about.
+ */
+ if (!isEdible(category)) return null;
+
const query = useQuery({
queryKey: ['nutrition', brand, imageId],
queryFn: () => nutritionApi.forProduct(brand, imageId),
@@ -89,8 +119,9 @@ export function HealthScorePanel({ product }: { product: Product }) {
return (
- This product is in the catalogue but has not been scored yet. It will appear here, and in
- the app, once it has.
+ Nutrition information not available for this product. The catalogue service has already
+ searched its nutrition databases and found nothing usable, so this gap closes only by
+ entering the per-100g figures by hand — not by waiting.
);
diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx
index 06e3e63..4dde6a0 100644
--- a/src/features/store-admin/ProductDrawer.tsx
+++ b/src/features/store-admin/ProductDrawer.tsx
@@ -172,7 +172,10 @@ export function ProductDrawer({
{/* ── Health score ──────────────────────────────────────────────────
Under Sizes and above the shop detail: it is what a SHOPPER reads,
and it belongs with the other shopper-facing facts. */}
-
+
{product.productdesc ? (
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index 8b8e530..dd94b0e 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -5,6 +5,7 @@ import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
+import { DateScopeProvider } from '@/components/shell/DateScope';
/**
* Store Admin — the merchant workspace.
@@ -80,6 +81,7 @@ const MANAGE: readonly MenuEntry[] = [
export function StoreAdminShell() {
return (
+
@@ -93,6 +95,7 @@ export function StoreAdminShell() {
/>
+
);
}
diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts
index 6af3590..e4efa3c 100644
--- a/src/features/store-admin/dispatchModel.test.ts
+++ b/src/features/store-admin/dispatchModel.test.ts
@@ -9,7 +9,6 @@ import {
groupByStore,
isToday,
lastSeen,
- shiftDay,
stopKey,
stopValue,
toStops,
@@ -377,14 +376,6 @@ test('the day is worth the work not yet sent out, too', () => {
/* ── The day ─────────────────────────────────────────────────────────────── */
-test('stepping a day stays in local time', () => {
- // Parsing the string would shift the whole board by a day in some zones.
- assert.equal(shiftDay('2026-09-05', -1), '2026-09-04');
- assert.equal(shiftDay('2026-09-05', 1), '2026-09-06');
- assert.equal(shiftDay('2026-01-01', -1), '2025-12-31', 'across a year boundary');
- assert.equal(shiftDay('2026-03-01', -1), '2026-02-28', 'across a month boundary');
-});
-
test('today is today', () => {
assert.equal(isToday(ymd(new Date())), true);
assert.equal(isToday('2020-01-01'), false);
diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts
index cee69ff..204e17e 100644
--- a/src/features/store-admin/dispatchModel.ts
+++ b/src/features/store-admin/dispatchModel.ts
@@ -421,21 +421,12 @@ export function dayTotals(stops: readonly Stop[]): DayTotals {
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
}
-/** One day as Fiesta wants it, and the stepper's arithmetic. */
+/** One day as Fiesta wants it. */
export function ymd(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
-export function shiftDay(day: string, by: number): string {
- const [y, m, d] = day.split('-').map(Number);
- // Local midnight, then step. Constructing from the parts rather than parsing
- // the string avoids the UTC shift that would move the whole board a day.
- const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
- date.setDate(date.getDate() + by);
- return ymd(date);
-}
-
export function isToday(day: string): boolean {
return day === ymd(new Date());
}
diff --git a/src/features/store-admin/healthScore.test.ts b/src/features/store-admin/healthScore.test.ts
index 5a0378d..e60bf16 100644
--- a/src/features/store-admin/healthScore.test.ts
+++ b/src/features/store-admin/healthScore.test.ts
@@ -254,3 +254,52 @@ test('a declared allergen is shown regardless of confidence', () => {
assert.deepEqual(shown.allergens, ['Gluten']);
assert.equal(shown.allergensUnconfirmed, false);
});
+
+/* ── Edibility, wired in 7 Sep 2026 ──────────────────────────────────────── */
+
+test('a health score is never shown for something you do not eat', () => {
+ /*
+ The service will happily score insecticide. Measured 7 Sep 2026:
+ GET /nutrition/Godrej/godrej_hit_spray_1101d017 returns health_score 80,
+ data_status "verified" — an "excellent" score for HIT spray. Palmolive soap
+ and Pantene shampoo both score 37.5 by the same route.
+
+ Their per-product endpoint does not publish the edibility label that gates
+ their list endpoint, so the decision has to be made from our own category.
+ */
+ assert.equal(isEdible('Home Care'), false);
+ assert.equal(isEdible('Personal Care'), false);
+ assert.equal(isEdible('Detergents'), false);
+ assert.equal(isEdible('Oral Care'), false);
+});
+
+test('"General" is not good enough to call something food', () => {
+ // It carries Palmolive soap and P&G alongside anything the scraper could not
+ // place. Ambiguous is not a basis for showing a nutrition score.
+ assert.equal(isEdible('General'), false);
+ assert.equal(isEdible('general'), false);
+});
+
+test('the real catalogue food categories are recognised', () => {
+ // Taken from live rows, not invented — these are what the drawer receives.
+ for (const category of [
+ 'Spices & Masalas',
+ 'Pulses, Grains & Spices',
+ 'Health Drinks',
+ 'Dairy',
+ 'Snacks & Namkeen',
+ 'Edible Oils',
+ 'Biscuits & Cookies',
+ 'Beverages',
+ ]) {
+ assert.equal(isEdible(category), true, category);
+ }
+});
+
+test('a missing category is treated as not food', () => {
+ // Hiding a panel on a food product costs one line of "not available".
+ // Showing one on a bottle of insecticide costs rather more.
+ assert.equal(isEdible(undefined), false);
+ assert.equal(isEdible(null), false);
+ assert.equal(isEdible(' '), false);
+});
diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx
index 163ae9f..9b2cc9a 100644
--- a/src/features/store-admin/pages/ConsolePage.tsx
+++ b/src/features/store-admin/pages/ConsolePage.tsx
@@ -1,4 +1,5 @@
-import { useMemo, useState } from 'react';
+import { useMemo } from 'react';
+import { useDateScope } from '@/components/shell/DateScope';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
@@ -16,7 +17,6 @@ import {
ShoppingCart,
TrendingDown,
} from 'lucide-react';
-import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
@@ -27,7 +27,6 @@ import {
useStockRequests,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
-import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -52,7 +51,7 @@ import {
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/
export function ConsolePage() {
- const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
+ const { scoped, selected, tenantid, isLoading, isPinned } = useBranchScope();
/**
* Where this page's links point.
*
@@ -62,13 +61,12 @@ export function ConsolePage() {
* does nothing.
*/
const base = isPinned ? '/store' : '/admin';
- const [preset, setPreset] = useState('today');
- const [range, setRange] = useState(() => presetRange('today'));
+ const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
- const posSales = usePosSalesByBranch(branchIds, range);
+ const posSales = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
const requests = useStockRequests(
@@ -134,23 +132,6 @@ export function ConsolePage() {
{
- setPreset(nextPreset);
- setRange(nextRange);
- }}
- />
- }
/>
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
diff --git a/src/features/store-admin/pages/CountersPage.tsx b/src/features/store-admin/pages/CountersPage.tsx
index 9840b45..c74d285 100644
--- a/src/features/store-admin/pages/CountersPage.tsx
+++ b/src/features/store-admin/pages/CountersPage.tsx
@@ -1,8 +1,7 @@
-import { useMemo, useState } from 'react';
+import { useState, useMemo } from 'react';
+import { useDateScope } from '@/components/shell/DateScope';
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
-import type { DateRange } from '@/api/insights';
import { useBranchScope } from '../BranchScope';
-import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { useCounterLabels } from '../counterLabels';
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
import { branchLabel, count, money } from '../format';
@@ -49,9 +48,8 @@ const STATE_TONE: Record = {
};
export function CountersPage() {
- const { branches, scoped, selected, current, isPinned } = useBranchScope();
- const [preset, setPreset] = useState('today');
- const [range, setRange] = useState(() => presetRange('today'));
+ const { branches, scoped, isPinned } = useBranchScope();
+ const dates = useDateScope();
const [chip, setChip] = useState('all');
const [branchId, setBranchId] = useState(null);
const [term, setTerm] = useState('');
@@ -59,7 +57,7 @@ export function CountersPage() {
const [open, setOpen] = useState(null);
const labels = useCounterLabels();
- const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
+ const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
const rows = useMemo(() => rowsOf(board), [board]);
const counts = useMemo(() => countsOf(rows), [rows]);
@@ -81,31 +79,7 @@ export function CountersPage() {
return (
}
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
index beae3b6..f9be4fa 100644
--- a/src/features/store-admin/pages/DispatchPage.tsx
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -1,13 +1,10 @@
import { useMemo, useState, type ReactNode } from 'react';
-import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
- ChevronLeft,
- ChevronRight,
IndianRupee,
MapPin,
Package,
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
+import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -37,7 +35,6 @@ import {
groupByStore,
isToday,
lastSeen,
- shiftDay,
stopAddress,
stopCustomer,
stopKey,
@@ -48,7 +45,6 @@ import {
toStops,
UNASSIGNED,
WAITING,
- ymd,
type Group,
type Stop,
type ViewMode,
@@ -56,10 +52,12 @@ import {
import './dispatch.css';
/**
- * Dispatch — one day's deliveries, read three ways.
+ * Dispatch — the day's deliveries, read three ways.
*
- * A day at a time, because that is how a round is worked: everything here is
- * scoped to a single date, not a range. No batch windows — order placement in
+ * Scoped by the console's shared date filter in the top bar, like every other
+ * page. A single day is the shape this was built for — that is how a round is
+ * worked — and the labels adapt when a wider range is chosen rather than
+ * calling a week of a rider's stops a "round". No batch windows — order placement in
* this tenant clusters so hard into one hour that a morning/afternoon/evening
* split would put ninety per cent in one bucket and call it navigation.
*
@@ -92,14 +90,14 @@ import './dispatch.css';
const DISPATCH_STATUS: Record = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
export function DispatchPage() {
- const { branches, selected, current, tenantid } = useBranchScope();
- const [day, setDay] = useDayParam();
+ const { branches, selected, tenantid } = useBranchScope();
+ const dates = useDateScope();
const [mode, setMode] = useState('riders');
const [focused, setFocused] = useState(null);
const [detail, setDetail] = useState(null);
const query = tenantid
- ? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
+ ? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
@@ -121,6 +119,16 @@ export function DispatchPage() {
: undefined,
);
+ /*
+ A one-day range is still the shape this board was built for, so it keeps the
+ wording it had: "Rounds", "on this day". A wider range is a legitimate thing
+ to ask for and the groupings all still hold — but a rider's group is then
+ several rounds run together, and the labels say so rather than pretending.
+ */
+ const isOneDay = Boolean(
+ dates.range.fromdate && dates.range.fromdate === dates.range.todate,
+ );
+
const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data],
@@ -194,12 +202,6 @@ export function DispatchPage() {
- }
tabs={
}
/>
@@ -260,7 +262,13 @@ export function DispatchPage() {
- {mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
+ {mode === 'riders'
+ ? isOneDay
+ ? 'Rounds'
+ : 'Riders'
+ : mode === 'stores'
+ ? 'Shops'
+ : 'Customers'}
- {groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
+ {groups.length > 0
+ ? 'Pick one to see its stops'
+ : isOneDay
+ ? 'Nothing out on this day'
+ : 'Nothing out in this range'}
void] {
- const [params, setParams] = useSearchParams();
- const today = ymd(new Date());
- const raw = params.get('day') ?? '';
- const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
-
- const set = (next: string) => {
- const clamped = next > today ? today : next;
- const merged = new URLSearchParams(params);
- // Today is the default, so it is left OUT of the URL: the common case gets
- // a clean address, and only a deliberate look back carries a param.
- if (clamped === today) merged.delete('day');
- else merged.set('day', clamped);
- setParams(merged, { replace: true });
- };
-
- return [day, set];
-}
-
-/**
- * A day at a time.
- *
- * Not a range picker: a round belongs to a date, and the arrows are how an
- * operator checks yesterday without thinking about a calendar. Forward is
- * disabled on today — dispatch has nothing to say about tomorrow.
- */
-function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
- const today = isToday(day);
- const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
- weekday: 'short',
- day: 'numeric',
- month: 'short',
- });
-
- return (
-
- onChange(shiftDay(day, -1))}>
-
-
- {/*
- The label doubles as the picker. Arrows answer "yesterday", which is
- most of the traffic; a date a month back would otherwise be thirty
- clicks. The input sits transparent over the label rather than replacing
- it, so the day still reads as "Sat, Sep 5" instead of as a form field,
- and clicking it opens the browser's own calendar — a real control,
- keyboard-reachable, with no picker to write or to get wrong.
- */}
-
- {label}
- {today ? today : null}
- {
- // Clearing the field yields '' — keep the day rather than blanking
- // the board.
- if (event.target.value) onChange(event.target.value);
- }}
- />
-
- onChange(shiftDay(day, 1))}
- >
-
-
- {!today ? (
- onChange(ymd(new Date()))}>
- Today
-
- ) : null}
-
- );
-}
-
/* ── The rail ────────────────────────────────────────────────────────────── */
function GroupList({
diff --git a/src/features/store-admin/pages/InventoryPage.tsx b/src/features/store-admin/pages/InventoryPage.tsx
index 66e227a..8329abf 100644
--- a/src/features/store-admin/pages/InventoryPage.tsx
+++ b/src/features/store-admin/pages/InventoryPage.tsx
@@ -59,7 +59,7 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
* backend catch up. Nothing on screen claims the selection is in force.
*/
export function InventoryPage() {
- const { branches, selected, current, tenantid } = useBranchScope();
+ const { selected, tenantid } = useBranchScope();
/**
* The tab comes from the URL, so a link can name one.
@@ -112,9 +112,8 @@ export function InventoryPage() {
) : null}
+ }
@@ -181,7 +180,10 @@ export function InventoryPage() {
) : tab === 'catalogue' ? (
) : tab === 'requests' ? (
-
+
) : (
)}
diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx
index 21e4c76..2376b5b 100644
--- a/src/features/store-admin/pages/OnboardBranchPage.tsx
+++ b/src/features/store-admin/pages/OnboardBranchPage.tsx
@@ -185,7 +185,7 @@ export function OnboardBranchPage() {
if (mutation.isSuccess) {
return (
-
+
@@ -220,7 +220,6 @@ export function OnboardBranchPage() {
@@ -190,7 +188,6 @@ export function ShopProfilePage() {
label="FSSAI / trade licence"
value={form['licenseno'] ?? ''}
onChange={set('licenseno')}
- description="Shown to shoppers. Required to display for a food business."
/>