From c31696ce4161c1dc1bd512c8f43a4addce23791e Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 7 Sep 2026 16:46:47 +0530 Subject: [PATCH] enhancement --- src/components/PageHeader.tsx | 139 +-- src/components/shell/AppShell.tsx | 26 +- src/components/shell/DateScope.tsx | 85 ++ .../catalogue/CatalogueDetailDrawer.tsx | 1 + src/features/console/ConsolePage.tsx | 34 +- .../nearle-admin/NearleAdminShell.tsx | 7 +- .../pages/GlobalCataloguePage.tsx | 1 - .../nearle-admin/pages/OnboardTenantPage.tsx | 3 - .../nearle-admin/pages/StoreDetailPage.tsx | 5 - .../nearle-admin/pages/StoresPage.tsx | 980 +++++++++--------- .../nearle-admin/pages/UploadsPage.tsx | 14 +- src/features/store-admin/HealthScorePanel.tsx | 39 +- src/features/store-admin/ProductDrawer.tsx | 5 +- src/features/store-admin/StoreAdminShell.tsx | 3 + .../store-admin/dispatchModel.test.ts | 9 - src/features/store-admin/dispatchModel.ts | 11 +- src/features/store-admin/healthScore.test.ts | 49 + .../store-admin/pages/ConsolePage.tsx | 29 +- .../store-admin/pages/CountersPage.tsx | 38 +- .../store-admin/pages/DispatchPage.tsx | 144 +-- .../store-admin/pages/InventoryPage.tsx | 12 +- .../store-admin/pages/OnboardBranchPage.tsx | 3 +- .../store-admin/pages/ReportsPage.tsx | 24 +- src/features/store-admin/pages/SalesPage.tsx | 100 +- .../store-admin/pages/ShopProfilePage.tsx | 3 - .../store-admin/pages/TerminalsPage.tsx | 24 +- .../store-admin/pages/UploadsPage.tsx | 14 +- src/features/store-admin/pages/UsersPage.tsx | 3 +- src/features/store-user/StoreUserShell.tsx | 3 + .../store-user/pages/StoreAccountPage.tsx | 2 - .../store-user/pages/StoreCustomersPage.tsx | 2 - .../store-user/pages/StoreProductsPage.tsx | 2 - .../store-user/pages/StoreStaffPage.tsx | 2 - .../store-user/pages/StoreUploadsPage.tsx | 14 +- src/features/uploads/UploadsPanel.tsx | 82 +- 35 files changed, 926 insertions(+), 986 deletions(-) create mode 100644 src/components/shell/DateScope.tsx 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 (
-
-
-
-

Console

- {scope.countLabel} - Live -
-

{scope.blurb}

-
- -
- { - setPreset(nextPreset); - setRange(nextRange); - }} - /> -
-
+

Console

diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx index ad17a3f..7440422 100644 --- a/src/features/nearle-admin/NearleAdminShell.tsx +++ b/src/features/nearle-admin/NearleAdminShell.tsx @@ -1,4 +1,5 @@ import { AppShell, type NavEntry } from '@/components/shell/AppShell'; +import { DateScopeProvider } from '@/components/shell/DateScope'; /** * Nearle Admin — the platform workspace. @@ -15,5 +16,9 @@ const NAV: readonly NavEntry[] = [ ]; export function NearleAdminShell() { - return ; + return ( + + + + ); } diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx index d6e085d..3c545d0 100644 --- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx +++ b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx @@ -51,7 +51,6 @@ export function GlobalCataloguePage() { @@ -171,7 +170,6 @@ export function OnboardTenantPage() {
@@ -207,7 +205,6 @@ export function OnboardTenantPage() { value={form.primaryemail} onChange={set('primaryemail')} placeholder="admin@kaveri.com" - description="The Administrator account is created against this address." /> First outlet name * as any} diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx index d36cdd4..d4cafb1 100644 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx @@ -178,11 +178,6 @@ export function StoreDetailPage() {
- } - /> - - - - ) - } - > - {/* 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` : ''} - - - - ); -} - -function Th({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); -} - -function Td({ - children, - isMuted, - isStrong, -}: { - children: React.ReactNode; - isMuted?: boolean; - isStrong?: boolean; -}) { - return ( - - {children} - - ); -} +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; +import { Card } from '@astryxdesign/core/Card'; +import { Table, type TableColumn } from '@astryxdesign/core/Table'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Building2, Plus, Store, Users } from 'lucide-react'; +import { DataState } from '@/components/DataState'; +import { KpiCard } from '@/components/KpiCard'; +import { PageHeader } from '@/components/PageHeader'; +import { SectionHeader } from '@/components/SectionHeader'; +import { useTenants, useTenantsByApproval } from '@/queries/hooks'; +import type { TenantInfo } from '@/api/types'; + +/** A tenant, with its branches folded in. */ +interface TenantRow extends Record { + tenantid: number; + tenantname: string; + companyname: string; + city: string; + branches: number; + status: string; + primaryemail: string; +} + +/** + * The Nearle Admin's home: every tenant on the platform, and how many branches + * sit under each. + * + * `getalltenants` returns one row per tenant-location pair, so the rows are + * grouped by tenantid here rather than shown raw — otherwise a tenant with six + * branches reads as six tenants. + */ +type Tab = 'directory' | 'pending'; + +/** Rows per page. One more than this is fetched, to know whether there is a next. */ +const PAGE_SIZE = 50; + +export function StoresPage() { + const [tab, setTab] = useState('directory'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + + /** + * A page at a time, newest first — `getalltenants` orders by `tenantid DESC` + * and has no total, so paging is "ask for one more than we show and see if it + * comes back". A platform list read whole is fine at twenty tenants and not + * at two thousand. + */ + const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 }); + + /** + * The queue of merchants awaiting approval. + * + * A separate endpoint, not a filter: `approved = 0` rows do not appear in + * `getalltenants` at all, so without this they are invisible. Nothing here + * can approve one — `approved` is writable only at creation — so this lists + * and says so. + */ + const pending = useTenantsByApproval('pending'); + + const rows = useMemo(() => { + if (!data) return []; + const grouped = new Map(); + + for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) { + const existing = grouped.get(tenant.tenantid); + if (existing) { + existing.branches += 1; + continue; + } + grouped.set(tenant.tenantid, { + tenantid: tenant.tenantid, + tenantname: tenant.tenantname, + companyname: tenant.companyname ?? '', + city: tenant.city ?? '', + branches: 1, + status: tenant.status ?? 'Unknown', + primaryemail: tenant.primaryemail ?? '', + }); + } + + const all = [...grouped.values()]; + const term = search.trim().toLowerCase(); + if (!term) return all; + return all.filter( + (row) => + row.tenantname.toLowerCase().includes(term) || + row.companyname.toLowerCase().includes(term) || + row.city.toLowerCase().includes(term), + ); + }, [data, search]); + + const totals = useMemo(() => { + const tenants = rows.length; + const branches = rows.reduce((sum, row) => sum + row.branches, 0); + const active = rows.filter((row) => row.status.toLowerCase() === 'active').length; + return { tenants, branches, active }; + }, [rows]); + + const columns: TableColumn[] = [ + { + key: 'tenantname', + header: 'Tenant', + width: { type: 'proportional', value: 3 }, + renderCell: (row) => ( + + + {row.tenantname} + + + {row.companyname || '—'} + + + ), + }, + { + key: 'city', + header: 'City', + width: { type: 'proportional', value: 1.5 }, + renderCell: (row) => {row.city || '—'}, + }, + { + key: 'branches', + header: 'Branches', + align: 'end', + width: { type: 'pixel', value: 110 }, + renderCell: (row) => ( + + {row.branches} + + ), + }, + { + key: 'primaryemail', + header: 'Primary admin', + width: { type: 'proportional', value: 2 }, + renderCell: (row) => ( + + {row.primaryemail || '—'} + + ), + }, + { + key: 'status', + header: 'Status', + align: 'end', + width: { type: 'pixel', value: 120 }, + renderCell: (row) => ( + + ), + }, + { + key: 'actions', + header: '', + align: 'end', + width: { type: 'pixel', value: 110 }, + renderCell: (row) => ( + + Open → + + ), + }, + ]; + + return ( + + {/* No `isLive` here. Both of this page's reads — `useTenants` and + `useTenantsByApproval` — use the `stable` query options: a 5-minute + staleTime and no refetchInterval. The pill claimed a freshness the page + does not have. The pages that keep it (Console, Sales, Counters, store + detail) poll on a real interval. */} + } + href="/nearle/onboard/tenant" + as={Link} + /> + } + /> + +
+ } + fill={totals.tenants ? totals.active / totals.tenants : 0} + /> + } + /> + } + /> +
+ + + 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` : ''} + + + + ); +} + +function Th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ + children, + isMuted, + isStrong, +}: { + children: React.ReactNode; + isMuted?: boolean; + isStrong?: boolean; +}) { + return ( + + {children} + + ); +} 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 (
-
-
-
-

Counters

- - {selected === null - ? `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}` - : branchLabel(current?.locationname) || 'This branch'} - - Live -
-

Monitor your counters, activity and connection status.

-
-
- { - setPreset(nextPreset); - setRange(nextRange); - }} - /> -
-
- +

Counters

} 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 ( -
- - {/* - 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); - }} - /> - - - {!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() { diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx index 942b458..6a206ef 100644 --- a/src/features/store-admin/pages/ReportsPage.tsx +++ b/src/features/store-admin/pages/ReportsPage.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react'; +import { useState, useMemo } from 'react'; +import { useDateScope } from '@/components/shell/DateScope'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; @@ -19,13 +20,11 @@ import { TrendingDown, Warehouse, } from 'lucide-react'; -import type { DateRange } from '@/api/insights'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { SectionHeader } from '@/components/SectionHeader'; import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks'; import { useBranchScope } from '../BranchScope'; -import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker'; import { branchLabel, count, money, percent, share } from '../format'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { TablePager } from '@/components/TablePager'; @@ -42,14 +41,13 @@ type Tab = 'revenue' | 'sales' | 'inventory'; * `posorders` would not be. */ export function ReportsPage() { - const { branches, scoped, selected, current, tenantid } = useBranchScope(); + const { scoped, tenantid } = useBranchScope(); const [tab, setTab] = useState('revenue'); - const [preset, setPreset] = useState('month'); - const [range, setRange] = useState(() => presetRange('month')); + 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 rows = useMemo( () => @@ -101,18 +99,6 @@ export function ReportsPage() { { - setPreset(nextPreset); - setRange(nextRange); - }} - /> - } tabs={ ('orders'); const [status, setStatus] = useState('all'); - const [preset, setPreset] = useState('month'); - const [range, setRange] = useState(() => presetRange('month')); + const dates = useDateScope(); const [keyword, setKeyword] = useState(''); /* What the tables are currently showing. Any change to it sends every pager back to page 1 — landing on page 4 of a search you just typed reads as an empty result. */ - const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`; + const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`; const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>( null, @@ -86,15 +82,15 @@ export function SalesPage() { tenantid, locationid: selected ?? undefined, keyword: keyword.trim() || undefined, - ...range, + ...dates.range, pagesize: 200, } : undefined; const orders = useOrders(query); const deliveries = useDeliveries(query); - const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 }); - const posSummary = usePosSalesByBranch(branchIds, range); + const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 }); + const posSummary = usePosSalesByBranch(branchIds, dates.range); const allOrders = orders.data ?? []; const allDeliveries = deliveries.data ?? []; @@ -218,30 +214,6 @@ export function SalesPage() { - {/* Counter sales that never touched a till. Sits beside the date - range rather than under the Counter tab because it is how the - figures on every tab get there for a shop with no POS. */} - } - isDisabled={!tenantid} - onClick={() => setUploading(true)} - /> - { - setPreset(nextPreset); - setRange(nextRange); - }} - /> - - } tabs={ ) : null} - {isUploading && tenantid ? ( - branch.locationid)} - onClose={() => setUploading(false)} - /> - ) : null} - {/* The row is re-read from the live list on every render rather than used as captured, because the drawer can now CHANGE the row it is @@ -1427,48 +1390,3 @@ function Td({ ); } -/** - * A header action drawn like a tab, so the two rows read as one bar. Same shape - * as Inventory's — copied rather than shared because the two pages own their - * chrome, and a shared one would have to take a theme prop to stay identical. - */ -function BarAction({ - label, - icon, - onClick, - isDisabled, -}: { - label: string; - icon: React.ReactNode; - onClick: () => void; - isDisabled?: boolean; -}) { - const [isHovered, setHovered] = useState(false); - return ( - - ); -} diff --git a/src/features/store-admin/pages/ShopProfilePage.tsx b/src/features/store-admin/pages/ShopProfilePage.tsx index 4536729..cf56aa3 100644 --- a/src/features/store-admin/pages/ShopProfilePage.tsx +++ b/src/features/store-admin/pages/ShopProfilePage.tsx @@ -118,7 +118,6 @@ export function ShopProfilePage() { 0 ? { count: `${gaps.length} still to fill in` } : {})} @@ -175,7 +174,6 @@ export function ShopProfilePage() { value={form['tenantimage'] ?? ''} onChange={set('tenantimage')} placeholder="https://…" - description="The picture shoppers see beside your shop in the app." />
@@ -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." />