enhancement

This commit is contained in:
2026-09-07 16:46:47 +05:30
parent 05ac76f5f0
commit c31696ce41
35 changed files with 926 additions and 986 deletions

View File

@@ -2,23 +2,32 @@ import type { ReactNode } from 'react';
export interface PageHeaderProps { export interface PageHeaderProps {
title: string; 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. */ /** Right-aligned actions, wrapping. */
actions?: ReactNode; actions?: ReactNode;
/** An optional tabs row directly under the header rule. */ /** An optional tabs row directly under the header rule. */
tabs?: ReactNode; 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. * The page frame header, built to KROW's `AdminPage` spec.
* *
* Title + count + a live pill on one line, a one-line subtitle beneath, actions * Actions right-aligned and wrapping, with an optional tabs row beneath.
* right-aligned and wrapping. Everything the page stacks below sits on a 24px * Everything the page stacks below sits on a 24px rhythm.
* 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 * 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 * 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 * 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. * 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 ( return (
<> <>
<header {/*
style={{ The title is still here, and still read aloud — it is just not drawn.
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h1
className="page-title"
style={{
margin: 0,
fontFamily: 'var(--font-display)',
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
{title}
</h1>
{count ? ( Removing it visually was the ask; removing it outright would leave every
<span page in the console with no h1, no document outline and nothing for a
style={{ screen reader to announce on navigation. `sr-only` is absolutely
fontSize: 13, positioned, so it is out of flow and costs no layout.
color: 'var(--color-ink-4)', */}
fontVariantNumeric: 'tabular-nums', <h1 className="sr-only">{title}</h1>
}}
>
{count}
</span>
) : null}
{isLive ? ( {/*
<span No row at all when there is nothing to put in it.
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
padding: '2px 10px',
fontSize: 11,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: 'var(--color-brand)',
}}
/>
Live
</span>
) : null}
</div>
{description ? ( It used to render an empty <header> regardless. At zero height that
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}> looks free, but it is still a flex child, so it collected the column's
{description} 12px gap and pushed everything down — a page with no actions sat 36px
</p> 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 ? (
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
{/* 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 ? <div>{tabs}</div> : <span />}
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null} ) : null}
</div> </header>
) : null}
{actions ? ( {tabs && !isTabsInline ? <div>{tabs}</div> : null}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
</> </>
); );
} }

View File

@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles'; import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel'; import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
export interface NavEntry { export interface NavEntry {
to: string; to: string;
@@ -133,14 +134,26 @@ export function AppShell({
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit 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 on exactly the same left edge as the page title below them at every
width, including a 2560px monitor where the body is centred. */} 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.
*/}
<header <header
style={{ style={{
position: 'sticky', position: 'sticky',
top: 0, top: 0,
zIndex: 40, zIndex: 40,
background: 'rgba(255,255,255,.85)', background: 'var(--color-surface)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
borderBottom: '1px solid var(--color-line)', borderBottom: '1px solid var(--color-line)',
}} }}
> >
@@ -247,6 +260,13 @@ export function AppShell({
the catalogue are real and stay. A control that looks like it the catalogue are real and stay. A control that looks like it
works costs more trust than a missing one. */} 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. */}
<DateScopePicker />
{headerActions} {headerActions}
{/* No notification bell. It was labelled "2 unread" with the dot {/* No notification bell. It was labelled "2 unread" with the dot

View File

@@ -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<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));
const value = useMemo<DateScopeValue>(
() => ({
preset,
range,
set: (nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
},
clear: () => {
setPreset('custom');
setRange({});
},
isFiltered: Boolean(range.fromdate || range.todate),
}),
[preset, range],
);
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
}
/**
* 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 <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
}

View File

@@ -204,6 +204,7 @@ export function CatalogueDetailDrawer({
lookup is needed to find the key. */} lookup is needed to find the key. */}
<HealthScorePanel <HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }} product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
category={product.category}
/> />
{product.highlights?.length || product.nutrients?.length ? ( {product.highlights?.length || product.nutrients?.length ? (

View File

@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import type { DateRange } from '@/api/insights'; import { useDateScope } from '@/components/shell/DateScope';
import { DateRangePicker, presetRange, type RangePreset } from '@/features/store-admin/DateRangePicker';
import { useBranchScope } from '@/features/store-admin/BranchScope'; import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus'; import { summariseBranch } from '@/features/store-admin/posStatus';
import { import {
@@ -50,12 +49,11 @@ export function ConsolePage() {
useBranchScope(); useBranchScope();
const base = isPinned ? '/store' : '/admin'; const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined); const orders = useLocationSummary(tenantid || undefined);
const posNow = usePosSalesByBranch(branchIds, range); const posNow = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds); const posHealth = usePosHealthByBranch(branchIds);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, { const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
allBranches: true, allBranches: true,
@@ -68,7 +66,7 @@ export function ConsolePage() {
() => posNow.flatMap((query) => (query.data ? [query.data] : [])), () => posNow.flatMap((query) => (query.data ? [query.data] : [])),
[posNow], [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 // One instant for the whole board, so two tills read a second apart are not
// judged against two different "nows". // judged against two different "nows".
@@ -130,27 +128,7 @@ export function ConsolePage() {
return ( return (
<div className="console"> <div className="console">
<header className="console-head"> <h1 className="sr-only">Console</h1>
<div className="console-title">
<div className="console-title-row">
<h1>Console</h1>
<span className="console-scope">{scope.countLabel}</span>
<span className="console-live"><i />Live</span>
</div>
<p className="console-blurb">{scope.blurb}</p>
</div>
<div className="console-actions">
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</div>
</header>
<KpiStrip totals={totals} isLoading={isBusy} /> <KpiStrip totals={totals} isLoading={isBusy} />

View File

@@ -1,4 +1,5 @@
import { AppShell, type NavEntry } from '@/components/shell/AppShell'; import { AppShell, type NavEntry } from '@/components/shell/AppShell';
import { DateScopeProvider } from '@/components/shell/DateScope';
/** /**
* Nearle Admin — the platform workspace. * Nearle Admin — the platform workspace.
@@ -15,5 +16,9 @@ const NAV: readonly NavEntry[] = [
]; ];
export function NearleAdminShell() { export function NearleAdminShell() {
return <AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />; return (
<DateScopeProvider>
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
</DateScopeProvider>
);
} }

View File

@@ -51,7 +51,6 @@ export function GlobalCataloguePage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Global catalogue" title="Global catalogue"
description="Read the FMCG catalogue, or upload a merchant's own product list."
actions={ actions={
<SegmentedControl <SegmentedControl
label="What to do" label="What to do"

View File

@@ -130,7 +130,6 @@ export function OnboardTenantPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Tenant provisioned" title="Tenant provisioned"
description="The enterprise is registered and its primary Administrator account has been created."
/> />
<Card padding={4} elevation="low"> <Card padding={4} elevation="low">
<VStack gap={3}> <VStack gap={3}>
@@ -171,7 +170,6 @@ export function OnboardTenantPage() {
<PageBody measure="reading"> <PageBody measure="reading">
<PageHeader <PageHeader
title="Onboard tenant" title="Onboard tenant"
description="Register a merchant group, commission its first outlet, and create its Administrator account."
/> />
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -207,7 +205,6 @@ export function OnboardTenantPage() {
value={form.primaryemail} value={form.primaryemail}
onChange={set('primaryemail')} onChange={set('primaryemail')}
placeholder="admin@kaveri.com" placeholder="admin@kaveri.com"
description="The Administrator account is created against this address."
/> />
<TextInput <TextInput
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}

View File

@@ -178,11 +178,6 @@ export function StoreDetailPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title={tenant?.tenantname ?? 'Tenant'} title={tenant?.tenantname ?? 'Tenant'}
count={tenant?.companyname}
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
tenant?.city ?? '—'
}`}
isLive
actions={ actions={
<HStack gap={1}> <HStack gap={1}>
<Button <Button

View File

@@ -1,491 +1,489 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react'; import { Building2, Plus, Store, Users } from 'lucide-react';
import { DataState } from '@/components/DataState'; import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useTenants, useTenantsByApproval } from '@/queries/hooks'; import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types'; import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */ /** A tenant, with its branches folded in. */
interface TenantRow extends Record<string, unknown> { interface TenantRow extends Record<string, unknown> {
tenantid: number; tenantid: number;
tenantname: string; tenantname: string;
companyname: string; companyname: string;
city: string; city: string;
branches: number; branches: number;
status: string; status: string;
primaryemail: string; primaryemail: string;
} }
/** /**
* The Nearle Admin's home: every tenant on the platform, and how many branches * The Nearle Admin's home: every tenant on the platform, and how many branches
* sit under each. * sit under each.
* *
* `getalltenants` returns one row per tenant-location pair, so the rows are * `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 * grouped by tenantid here rather than shown raw — otherwise a tenant with six
* branches reads as six tenants. * branches reads as six tenants.
*/ */
type Tab = 'directory' | 'pending'; type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */ /** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
export function StoresPage() { export function StoresPage() {
const [tab, setTab] = useState<Tab>('directory'); const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
/** /**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC` * 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 * 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 * comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand. * at two thousand.
*/ */
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 }); const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/** /**
* The queue of merchants awaiting approval. * The queue of merchants awaiting approval.
* *
* A separate endpoint, not a filter: `approved = 0` rows do not appear in * A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here * `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists * can approve one — `approved` is writable only at creation — so this lists
* and says so. * and says so.
*/ */
const pending = useTenantsByApproval('pending'); const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => { const rows = useMemo<TenantRow[]>(() => {
if (!data) return []; if (!data) return [];
const grouped = new Map<number, TenantRow>(); const grouped = new Map<number, TenantRow>();
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) { for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid); const existing = grouped.get(tenant.tenantid);
if (existing) { if (existing) {
existing.branches += 1; existing.branches += 1;
continue; continue;
} }
grouped.set(tenant.tenantid, { grouped.set(tenant.tenantid, {
tenantid: tenant.tenantid, tenantid: tenant.tenantid,
tenantname: tenant.tenantname, tenantname: tenant.tenantname,
companyname: tenant.companyname ?? '', companyname: tenant.companyname ?? '',
city: tenant.city ?? '', city: tenant.city ?? '',
branches: 1, branches: 1,
status: tenant.status ?? 'Unknown', status: tenant.status ?? 'Unknown',
primaryemail: tenant.primaryemail ?? '', primaryemail: tenant.primaryemail ?? '',
}); });
} }
const all = [...grouped.values()]; const all = [...grouped.values()];
const term = search.trim().toLowerCase(); const term = search.trim().toLowerCase();
if (!term) return all; if (!term) return all;
return all.filter( return all.filter(
(row) => (row) =>
row.tenantname.toLowerCase().includes(term) || row.tenantname.toLowerCase().includes(term) ||
row.companyname.toLowerCase().includes(term) || row.companyname.toLowerCase().includes(term) ||
row.city.toLowerCase().includes(term), row.city.toLowerCase().includes(term),
); );
}, [data, search]); }, [data, search]);
const totals = useMemo(() => { const totals = useMemo(() => {
const tenants = rows.length; const tenants = rows.length;
const branches = rows.reduce((sum, row) => sum + row.branches, 0); const branches = rows.reduce((sum, row) => sum + row.branches, 0);
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length; const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
return { tenants, branches, active }; return { tenants, branches, active };
}, [rows]); }, [rows]);
const columns: TableColumn<TenantRow>[] = [ const columns: TableColumn<TenantRow>[] = [
{ {
key: 'tenantname', key: 'tenantname',
header: 'Tenant', header: 'Tenant',
width: { type: 'proportional', value: 3 }, width: { type: 'proportional', value: 3 },
renderCell: (row) => ( renderCell: (row) => (
<VStack gap={0}> <VStack gap={0}>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
{row.tenantname} {row.tenantname}
</Text> </Text>
<Text type="body" size="xsm" color="secondary"> <Text type="body" size="xsm" color="secondary">
{row.companyname || '—'} {row.companyname || '—'}
</Text> </Text>
</VStack> </VStack>
), ),
}, },
{ {
key: 'city', key: 'city',
header: 'City', header: 'City',
width: { type: 'proportional', value: 1.5 }, width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>, renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
}, },
{ {
key: 'branches', key: 'branches',
header: 'Branches', header: 'Branches',
align: 'end', align: 'end',
width: { type: 'pixel', value: 110 }, width: { type: 'pixel', value: 110 },
renderCell: (row) => ( renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers> <Text type="label" size="sm" hasTabularNumbers>
{row.branches} {row.branches}
</Text> </Text>
), ),
}, },
{ {
key: 'primaryemail', key: 'primaryemail',
header: 'Primary admin', header: 'Primary admin',
width: { type: 'proportional', value: 2 }, width: { type: 'proportional', value: 2 },
renderCell: (row) => ( renderCell: (row) => (
<Text type="body" size="xsm" color="secondary"> <Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'} {row.primaryemail || '—'}
</Text> </Text>
), ),
}, },
{ {
key: 'status', key: 'status',
header: 'Status', header: 'Status',
align: 'end', align: 'end',
width: { type: 'pixel', value: 120 }, width: { type: 'pixel', value: 120 },
renderCell: (row) => ( renderCell: (row) => (
<Badge <Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'} variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status} label={row.status}
/> />
), ),
}, },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
align: 'end', align: 'end',
width: { type: 'pixel', value: 110 }, width: { type: 'pixel', value: 110 },
renderCell: (row) => ( renderCell: (row) => (
<Link <Link
to={`/nearle/stores/${row.tenantid}`} to={`/nearle/stores/${row.tenantid}`}
style={{ style={{
color: 'var(--color-brand)', color: 'var(--color-brand)',
fontWeight: 600, fontWeight: 600,
fontSize: 13, fontSize: 13,
textDecoration: 'none', textDecoration: 'none',
}} }}
> >
Open → Open →
</Link> </Link>
), ),
}, },
]; ];
return ( return (
<VStack gap={3}> <VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and {/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute `useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */} detail) poll on a real interval. */}
<PageHeader <PageHeader
title="Stores" title="Stores"
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
description="Every merchant on the platform, the branches under them, and how each one is performing." actions={
actions={ <Button
<Button label="Onboard tenant"
label="Onboard tenant" variant="primary"
variant="primary" icon={<Plus size={15} />}
icon={<Plus size={15} />} href="/nearle/onboard/tenant"
href="/nearle/onboard/tenant" as={Link}
as={Link} />
/> }
} />
/>
<div className="kpi-grid">
<div className="kpi-grid"> <KpiCard
<KpiCard label="Tenants"
label="Tenants" value={String(totals.tenants)}
value={String(totals.tenants)} note={`${totals.active} active`}
note={`${totals.active} active`} tone="accent"
tone="accent" icon={<Building2 size={15} />}
icon={<Building2 size={15} />} fill={totals.tenants ? totals.active / totals.tenants : 0}
fill={totals.tenants ? totals.active / totals.tenants : 0} />
/> <KpiCard
<KpiCard label="Branches"
label="Branches" value={String(totals.branches)}
value={String(totals.branches)} note="across the network"
note="across the network" tone="neutral"
tone="neutral" icon={<Store size={15} />}
icon={<Store size={15} />} />
/> <KpiCard
<KpiCard label="Avg branches"
label="Avg branches" value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'} note="per tenant"
note="per tenant" tone="neutral"
tone="neutral" icon={<Users size={15} />}
icon={<Users size={15} />} />
/> </div>
</div>
<HStack gap={0.5} wrap="wrap">
<HStack gap={0.5} wrap="wrap"> <TabButton
<TabButton label="Directory"
label="Directory" isActive={tab === 'directory'}
isActive={tab === 'directory'} onClick={() => setTab('directory')}
onClick={() => setTab('directory')} />
/> <TabButton
<TabButton label="Awaiting approval"
label="Awaiting approval" isActive={tab === 'pending'}
isActive={tab === 'pending'} badge={pending.data?.length || undefined}
badge={pending.data?.length || undefined} onClick={() => setTab('pending')}
onClick={() => setTab('pending')} />
/> </HStack>
</HStack>
{tab === 'pending' ? (
{tab === 'pending' ? ( <PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} /> ) : (
) : ( <VStack gap={1.5}>
<VStack gap={1.5}> <SectionHeader
<SectionHeader title="Tenant directory"
title="Tenant directory" note={`${rows.length} shown`}
note={`${rows.length} shown`} action={
action={ <div className="toolbar-stack" style={{ maxWidth: 260 }}>
<div className="toolbar-stack" style={{ maxWidth: 260 }}> <TextInput
<TextInput label="Search tenants"
label="Search tenants" isLabelHidden
isLabelHidden value={search}
value={search} onChange={setSearch}
onChange={setSearch} placeholder="Search by name, company or city…"
placeholder="Search by name, company or city…" hasClear
hasClear size="sm"
size="sm" />
/> </div>
</div> }
} />
/>
<Card padding={0} elevation="low">
<Card padding={0} elevation="low"> <DataState
<DataState isLoading={isLoading}
isLoading={isLoading} error={error}
error={error} isEmpty={rows.length === 0}
isEmpty={rows.length === 0} emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'} emptyDescription={
emptyDescription={ search
search ? 'Try a different name, company or city.'
? 'Try a different name, company or city.' : 'Onboard the first merchant group to get started.'
: 'Onboard the first merchant group to get started.' }
} emptyAction={
emptyAction={ search ? undefined : (
search ? undefined : ( <Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} /> )
) }
} >
> {/* Columns carry meaning, so the table scrolls sideways rather
{/* Columns carry meaning, so the table scrolls sideways rather than dropping any of them. The page itself never scrolls wide. */}
than dropping any of them. The page itself never scrolls wide. */} <div className="table-scroll">
<div className="table-scroll"> <Table<TenantRow>
<Table<TenantRow> data={rows}
data={rows} columns={columns}
columns={columns} idKey="tenantid"
idKey="tenantid" density="balanced"
density="balanced" hasHover
hasHover dividers="rows"
dividers="rows" />
/> </div>
</div> </DataState>
</DataState> </Card>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<HStack justify="between" align="center" gap={1} wrap="wrap"> <Text type="body" size="sm" color="secondary">
<Text type="body" size="sm" color="secondary"> Page {page}
Page {page} {search ? ` · filtered from ${rows.length} on this page` : ''}
{search ? ` · filtered from ${rows.length} on this page` : ''} </Text>
</Text> <HStack gap={1}>
<HStack gap={1}> <Button
<Button label="Previous"
label="Previous" variant="secondary"
variant="secondary" size="sm"
size="sm" isDisabled={page === 1}
isDisabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}
onClick={() => setPage((current) => Math.max(1, current - 1))} />
/> <Button
<Button label="Next"
label="Next" variant="secondary"
variant="secondary" size="sm"
size="sm" isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
isDisabled={(data?.length ?? 0) <= PAGE_SIZE} onClick={() => setPage((current) => current + 1)}
onClick={() => setPage((current) => current + 1)} />
/> </HStack>
</HStack> </HStack>
</HStack> </VStack>
</VStack> )}
)} </VStack>
</VStack> );
); }
}
/**
/** * Merchants that have been created but never approved.
* Merchants that have been created but never approved. *
* * Read-only, and it says why on the panel rather than offering a button that
* 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
* 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
* 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
* the only place these rows are visible at all — `getalltenants` cannot see * them.
* them. */
*/ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) { if (isLoading) {
if (isLoading) { return (
return ( <Card padding={0} elevation="low">
<Card padding={0} elevation="low"> <VStack padding={3}>
<VStack padding={3}> <Text type="body" size="sm" color="secondary">
<Text type="body" size="sm" color="secondary"> Reading the approval queue…
Reading the approval queue… </Text>
</Text> </VStack>
</VStack> </Card>
</Card> );
); }
}
if (rows.length === 0) {
if (rows.length === 0) { return (
return ( <Card padding={0} elevation="low">
<Card padding={0} elevation="low"> <VStack gap={0.5} padding={3}>
<VStack gap={0.5} padding={3}> <Text type="label" size="sm" weight="semibold">
<Text type="label" size="sm" weight="semibold"> Nothing waiting
Nothing waiting </Text>
</Text> <Text type="body" size="sm" color="secondary">
<Text type="body" size="sm" color="secondary"> Every merchant on the platform has been approved.
Every merchant on the platform has been approved. </Text>
</Text> </VStack>
</VStack> </Card>
</Card> );
); }
}
return (
return ( <VStack gap={1.5}>
<VStack gap={1.5}> <Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}> These merchants exist but are not approved, so they do not appear in the directory.
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.
Approving is a database change today — the backend has no endpoint for it. </Text>
</Text> <Card padding={0} elevation="low">
<Card padding={0} elevation="low"> <div className="table-scroll">
<div className="table-scroll"> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <thead>
<thead> <tr>
<tr> <Th>Merchant</Th>
<Th>Merchant</Th> <Th>Company</Th>
<Th>Company</Th> <Th>Contact</Th>
<Th>Contact</Th> <Th>City</Th>
<Th>City</Th> <Th>Tenant id</Th>
<Th>Tenant id</Th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {rows.map((row) => (
{rows.map((row) => ( <tr key={`${row.tenantid}-${row.locationid}`}>
<tr key={`${row.tenantid}-${row.locationid}`}> <Td isStrong>{row.tenantname}</Td>
<Td isStrong>{row.tenantname}</Td> <Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.companyname || '—'}</Td> <Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td> <Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td> <Td isMuted>{row.tenantid}</Td>
<Td isMuted>{row.tenantid}</Td> </tr>
</tr> ))}
))} </tbody>
</tbody> </table>
</table> </div>
</div> </Card>
</Card> </VStack>
</VStack> );
); }
}
function TabButton({
function TabButton({ label,
label, isActive,
isActive, onClick,
onClick, badge,
badge, }: {
}: { label: string;
label: string; isActive: boolean;
isActive: boolean; onClick: () => void;
onClick: () => void; badge?: number;
badge?: number; }) {
}) { return (
return ( <button
<button type="button"
type="button" onClick={onClick}
onClick={onClick} aria-pressed={isActive}
aria-pressed={isActive} style={{
style={{ display: 'inline-flex',
display: 'inline-flex', alignItems: 'center',
alignItems: 'center', gap: 6,
gap: 6, height: 32,
height: 32, padding: '0 12px',
padding: '0 12px', borderRadius: 12,
borderRadius: 12, border: 0,
border: 0, background: isActive ? 'var(--color-brand-tint)' : 'transparent',
background: isActive ? 'var(--color-brand-tint)' : 'transparent', color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)', fontSize: 13,
fontSize: 13, fontWeight: isActive ? 600 : 500,
fontWeight: isActive ? 600 : 500, cursor: 'pointer',
cursor: 'pointer', }}
}} >
> {label}
{label} {badge ? (
{badge ? ( <span
<span style={{
style={{ minWidth: 18,
minWidth: 18, height: 18,
height: 18, padding: '0 5px',
padding: '0 5px', borderRadius: 999,
borderRadius: 999, background: 'var(--color-warning, #b7860b)',
background: 'var(--color-warning, #b7860b)', color: '#fff',
color: '#fff', fontSize: 11,
fontSize: 11, fontWeight: 700,
fontWeight: 700, display: 'grid',
display: 'grid', placeItems: 'center',
placeItems: 'center', }}
}} >
> {badge}
{badge} </span>
</span> ) : null}
) : null} </button>
</button> );
); }
}
function Th({ children }: { children?: React.ReactNode }) {
function Th({ children }: { children?: React.ReactNode }) { return (
return ( <th
<th style={{
style={{ textAlign: 'center',
textAlign: 'center', padding: '10px',
padding: '10px', borderBottom: '1px solid var(--color-line)',
borderBottom: '1px solid var(--color-line)', fontSize: 12,
fontSize: 12, fontWeight: 600,
fontWeight: 600, color: 'var(--color-ink-3)',
color: 'var(--color-ink-3)', whiteSpace: 'nowrap',
whiteSpace: 'nowrap', }}
}} >
> {children}
{children} </th>
</th> );
); }
}
function Td({
function Td({ children,
children, isMuted,
isMuted, isStrong,
isStrong, }: {
}: { children: React.ReactNode;
children: React.ReactNode; isMuted?: boolean;
isMuted?: boolean; isStrong?: boolean;
isStrong?: boolean; }) {
}) { return (
return ( <td
<td style={{
style={{ textAlign: 'center',
textAlign: 'center', padding: '10px',
padding: '10px', borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)', fontWeight: isStrong ? 600 : 400,
fontWeight: isStrong ? 600 : 400, }}
}} >
> {children}
{children} </td>
</td> );
);
}

View File

@@ -1,3 +1,4 @@
import { useDateScope } from '@/components/shell/DateScope';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
@@ -13,17 +14,26 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
*/ */
export function UploadsPage() { export function UploadsPage() {
const navigate = useNavigate(); 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 ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Uploads" title="Uploads"
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
/> />
{/* A platform operator's upload writes the GLOBAL catalogue, which is a {/* A platform operator's upload writes the GLOBAL catalogue, which is a
different action from a merchant sending their own list — so this goes 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. */} to the page that owns it rather than opening a merchant's drawer. */}
<UploadsPanel onUpload={() => navigate('/nearle/catalogue?tab=sheet')} /> <UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
</VStack> </VStack>
); );
} }

View File

@@ -3,7 +3,7 @@ import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition'; import { nutritionApi } from '@/api/nutrition';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { DrawerCard, Note, Row, Section } from './drawerKit'; 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'; 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 * confident one — see the caveat, which is shown whenever the service's own
* match confidence is below 70%. * 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 brand = (product.productbrand ?? '').trim();
const imageId = (product.imageid ?? '').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({ const query = useQuery({
queryKey: ['nutrition', brand, imageId], queryKey: ['nutrition', brand, imageId],
queryFn: () => nutritionApi.forProduct(brand, imageId), queryFn: () => nutritionApi.forProduct(brand, imageId),
@@ -89,8 +119,9 @@ export function HealthScorePanel({ product }: { product: Product }) {
return ( return (
<Section title="Health score"> <Section title="Health score">
<Note> <Note>
This product is in the catalogue but has not been scored yet. It will appear here, and in Nutrition information not available for this product. The catalogue service has already
the app, once it has. searched its nutrition databases and found nothing usable, so this gap closes only by
entering the per-100g figures by hand — not by waiting.
</Note> </Note>
</Section> </Section>
); );

View File

@@ -172,7 +172,10 @@ export function ProductDrawer({
{/* ── Health score ────────────────────────────────────────────────── {/* ── Health score ──────────────────────────────────────────────────
Under Sizes and above the shop detail: it is what a SHOPPER reads, Under Sizes and above the shop detail: it is what a SHOPPER reads,
and it belongs with the other shopper-facing facts. */} and it belongs with the other shopper-facing facts. */}
<HealthScorePanel product={product} /> <HealthScorePanel
product={product}
category={product.categoryname || product.Subcategoryname}
/>
{product.productdesc ? ( {product.productdesc ? (
<Section title="Description"> <Section title="Description">

View File

@@ -5,6 +5,7 @@ import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { AssistantScope } from '@/features/console/AssistantScope'; import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from './BranchScope'; import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents'; import { useLiveEvents } from '@/queries/useLiveEvents';
import { DateScopeProvider } from '@/components/shell/DateScope';
/** /**
* Store Admin — the merchant workspace. * Store Admin — the merchant workspace.
@@ -80,6 +81,7 @@ const MANAGE: readonly MenuEntry[] = [
export function StoreAdminShell() { export function StoreAdminShell() {
return ( return (
<DateScopeProvider>
<BranchScopeProvider> <BranchScopeProvider>
<LiveWatch /> <LiveWatch />
<OnboardingGate /> <OnboardingGate />
@@ -93,6 +95,7 @@ export function StoreAdminShell() {
/> />
</AssistantScope> </AssistantScope>
</BranchScopeProvider> </BranchScopeProvider>
</DateScopeProvider>
); );
} }

View File

@@ -9,7 +9,6 @@ import {
groupByStore, groupByStore,
isToday, isToday,
lastSeen, lastSeen,
shiftDay,
stopKey, stopKey,
stopValue, stopValue,
toStops, toStops,
@@ -377,14 +376,6 @@ test('the day is worth the work not yet sent out, too', () => {
/* ── The day ─────────────────────────────────────────────────────────────── */ /* ── 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', () => { test('today is today', () => {
assert.equal(isToday(ymd(new Date())), true); assert.equal(isToday(ymd(new Date())), true);
assert.equal(isToday('2020-01-01'), false); assert.equal(isToday('2020-01-01'), false);

View File

@@ -421,21 +421,12 @@ export function dayTotals(stops: readonly Stop[]): DayTotals {
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value }; 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 { export function ymd(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; 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 { export function isToday(day: string): boolean {
return day === ymd(new Date()); return day === ymd(new Date());
} }

View File

@@ -254,3 +254,52 @@ test('a declared allergen is shown regardless of confidence', () => {
assert.deepEqual(shown.allergens, ['Gluten']); assert.deepEqual(shown.allergens, ['Gluten']);
assert.equal(shown.allergensUnconfirmed, false); 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);
});

View File

@@ -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 { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
@@ -16,7 +17,6 @@ import {
ShoppingCart, ShoppingCart,
TrendingDown, TrendingDown,
} from 'lucide-react'; } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
@@ -27,7 +27,6 @@ import {
useStockRequests, useStockRequests,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
@@ -52,7 +51,7 @@ import {
* rather than an omission. See `store-admin-backend-gap.md` §3.1. * rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/ */
export function ConsolePage() { 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. * Where this page's links point.
* *
@@ -62,13 +61,12 @@ export function ConsolePage() {
* does nothing. * does nothing.
*/ */
const base = isPinned ? '/store' : '/admin'; const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined); const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range); const posSales = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds); const posHealth = usePosHealthByBranch(branchIds);
const requests = useStockRequests( const requests = useStockRequests(
@@ -134,23 +132,6 @@ export function ConsolePage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Console" title="Console"
count={
selected === null
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
: (current?.locationname ?? undefined)
}
description="Everything trading right now — by channel, by branch, and what is waiting on you."
isLive
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
}
/> />
{/* ── Revenue, by channel, never summed ──────────────────────────── */} {/* ── Revenue, by channel, never summed ──────────────────────────── */}

View File

@@ -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 { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { useCounterLabels } from '../counterLabels'; import { useCounterLabels } from '../counterLabels';
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows'; import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
import { branchLabel, count, money } from '../format'; import { branchLabel, count, money } from '../format';
@@ -49,9 +48,8 @@ const STATE_TONE: Record<CounterState, string> = {
}; };
export function CountersPage() { export function CountersPage() {
const { branches, scoped, selected, current, isPinned } = useBranchScope(); const { branches, scoped, isPinned } = useBranchScope();
const [preset, setPreset] = useState<RangePreset>('today'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const [chip, setChip] = useState<CounterState | 'all'>('all'); const [chip, setChip] = useState<CounterState | 'all'>('all');
const [branchId, setBranchId] = useState<number | null>(null); const [branchId, setBranchId] = useState<number | null>(null);
const [term, setTerm] = useState(''); const [term, setTerm] = useState('');
@@ -59,7 +57,7 @@ export function CountersPage() {
const [open, setOpen] = useState<CounterRow | null>(null); const [open, setOpen] = useState<CounterRow | null>(null);
const labels = useCounterLabels(); 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 rows = useMemo(() => rowsOf(board), [board]);
const counts = useMemo(() => countsOf(rows), [rows]); const counts = useMemo(() => countsOf(rows), [rows]);
@@ -81,31 +79,7 @@ export function CountersPage() {
return ( return (
<div className="console"> <div className="console">
<header className="console-head"> <h1 className="sr-only">Counters</h1>
<div className="console-title">
<div className="console-title-row">
<h1>Counters</h1>
<span className="console-scope">
{selected === null
? `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`
: branchLabel(current?.locationname) || 'This branch'}
</span>
<span className="console-live"><i />Live</span>
</div>
<p className="console-blurb">Monitor your counters, activity and connection status.</p>
</div>
<div className="console-actions">
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</div>
</header>
<div className="kpi-strip"> <div className="kpi-strip">
<Kpi <Kpi
icon={<Monitor size={17} />} icon={<Monitor size={17} />}

View File

@@ -1,13 +1,10 @@
import { useMemo, useState, type ReactNode } from 'react'; import { useMemo, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { import {
Bike, Bike,
ChevronLeft,
ChevronRight,
IndianRupee, IndianRupee,
MapPin, MapPin,
Package, Package,
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar'; import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery'; import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks'; import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format'; import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -37,7 +35,6 @@ import {
groupByStore, groupByStore,
isToday, isToday,
lastSeen, lastSeen,
shiftDay,
stopAddress, stopAddress,
stopCustomer, stopCustomer,
stopKey, stopKey,
@@ -48,7 +45,6 @@ import {
toStops, toStops,
UNASSIGNED, UNASSIGNED,
WAITING, WAITING,
ymd,
type Group, type Group,
type Stop, type Stop,
type ViewMode, type ViewMode,
@@ -56,10 +52,12 @@ import {
import './dispatch.css'; 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 by the console's shared date filter in the top bar, like every other
* scoped to a single date, not a range. No batch windows — order placement in * 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 * 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. * split would put ninety per cent in one bucket and call it navigation.
* *
@@ -92,14 +90,14 @@ import './dispatch.css';
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' }; const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
export function DispatchPage() { export function DispatchPage() {
const { branches, selected, current, tenantid } = useBranchScope(); const { branches, selected, tenantid } = useBranchScope();
const [day, setDay] = useDayParam(); const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders'); const [mode, setMode] = useState<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null); const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null); const [detail, setDetail] = useState<Stop | null>(null);
const query = tenantid const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 } ? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
: undefined; : undefined;
const deliveries = useDeliveries(query); const deliveries = useDeliveries(query);
@@ -121,6 +119,16 @@ export function DispatchPage() {
: undefined, : 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( const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []), () => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data], [deliveries.data, orders.data],
@@ -194,12 +202,6 @@ export function DispatchPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Dispatch" title="Dispatch"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="One day of deliveries — by rider, by shop, or by customer."
isLive
actions={
<DayStepper day={day} onChange={setDay} />
}
tabs={ tabs={
<HStack gap={0.5} wrap="wrap"> <HStack gap={0.5} wrap="wrap">
<ModeTab <ModeTab
@@ -230,7 +232,7 @@ export function DispatchPage() {
<KpiCard <KpiCard
label="Stops" label="Stops"
value={count(totals.stops)} value={count(totals.stops)}
note={isToday(day) ? 'today' : 'on this day'} note={isOneDay ? (isToday(dates.range.fromdate ?? '') ? 'today' : 'on this day') : 'in this range'}
tone="accent" tone="accent"
icon={<Package size={15} />} icon={<Package size={15} />}
/> />
@@ -260,7 +262,13 @@ export function DispatchPage() {
<div className="dispatch-body"> <div className="dispatch-body">
<div className="dispatch-rail"> <div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary"> <Text type="label" size="xsm" color="secondary">
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'} {mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text> </Text>
<GroupList <GroupList
groups={groups} groups={groups}
@@ -301,7 +309,11 @@ export function DispatchPage() {
<MapPin size={22} /> <MapPin size={22} />
</span> </span>
<Text type="label" size="sm" weight="semibold"> <Text type="label" size="sm" weight="semibold">
{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'}
</Text> </Text>
<Text <Text
type="body" type="body"
@@ -326,98 +338,6 @@ export function DispatchPage() {
); );
} }
/* ── The day ─────────────────────────────────────────────────────────────── */
/**
* Which day is on screen, held in the URL.
*
* The same reasoning `BranchScope` gives for the branch: "Dispatch, last
* Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
* not silently throw the operator back to today while they are reading a round
* that belongs to another date.
*
* A malformed or future value falls back to today rather than 400ing the
* delivery read — the param is user-editable, so it is untrusted input.
*/
function useDayParam(): [string, (next: string) => 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 (
<div className="day-stepper">
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
<ChevronLeft size={15} />
</button>
{/*
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.
*/}
<span className="day-label">
<span aria-hidden>{label}</span>
{today ? <em aria-hidden>today</em> : null}
<input
type="date"
aria-label="Pick a day"
value={day}
max={ymd(new Date())}
onChange={(event) => {
// Clearing the field yields '' — keep the day rather than blanking
// the board.
if (event.target.value) onChange(event.target.value);
}}
/>
</span>
<button
type="button"
aria-label="Next day"
disabled={today}
onClick={() => onChange(shiftDay(day, 1))}
>
<ChevronRight size={15} />
</button>
{!today ? (
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
Today
</button>
) : null}
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */ /* ── The rail ────────────────────────────────────────────────────────────── */
function GroupList({ function GroupList({

View File

@@ -59,7 +59,7 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
* backend catch up. Nothing on screen claims the selection is in force. * backend catch up. Nothing on screen claims the selection is in force.
*/ */
export function InventoryPage() { 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. * The tab comes from the URL, so a link can name one.
@@ -112,9 +112,8 @@ export function InventoryPage() {
) : null} ) : null}
<PageHeader <PageHeader
isTabsInline
title="Inventory" title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="What you sell, where it is stocked, and what the shops are asking you for."
/* Upload sheet lives here, not in the tab row, so its position is /* 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 fixed — it no longer appears and disappears as the tab changes, and
nothing below shifts sideways when it does. nothing below shifts sideways when it does.
@@ -129,7 +128,7 @@ export function InventoryPage() {
stated twice on one screen invites the reader to check whether the stated twice on one screen invites the reader to check whether the
two agree. */ two agree. */
actions={ actions={
<HStack gap={0.5} align="center" wrap="wrap"> <HStack gap={1} align="center" wrap="wrap">
<BarAction <BarAction
label="Upload sheet" label="Upload sheet"
icon={<UploadCloud size={14} />} icon={<UploadCloud size={14} />}
@@ -181,7 +180,10 @@ export function InventoryPage() {
) : tab === 'catalogue' ? ( ) : tab === 'catalogue' ? (
<CataloguePanel /> <CataloguePanel />
) : tab === 'requests' ? ( ) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} /> <RequestsPanel
requests={requests.data ?? []}
isLoading={requests.isLoading}
/>
) : ( ) : (
<StockPanel /> <StockPanel />
)} )}

View File

@@ -185,7 +185,7 @@ export function OnboardBranchPage() {
if (mutation.isSuccess) { if (mutation.isSuccess) {
return ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." /> <PageHeader title="Branch commissioned" />
<Card padding={4} elevation="low"> <Card padding={4} elevation="low">
<VStack gap={3}> <VStack gap={3}>
<HStack align="center" gap={1.5}> <HStack align="center" gap={1.5}>
@@ -220,7 +220,6 @@ export function OnboardBranchPage() {
<PageBody measure="reading"> <PageBody measure="reading">
<PageHeader <PageHeader
title="Onboard branch" title="Onboard branch"
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
/> />
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>

View File

@@ -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 { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
@@ -19,13 +20,11 @@ import {
TrendingDown, TrendingDown,
Warehouse, Warehouse,
} from 'lucide-react'; } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks'; import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -42,14 +41,13 @@ type Tab = 'revenue' | 'sales' | 'inventory';
* `posorders` would not be. * `posorders` would not be.
*/ */
export function ReportsPage() { export function ReportsPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope(); const { scoped, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('revenue'); const [tab, setTab] = useState<Tab>('revenue');
const [preset, setPreset] = useState<RangePreset>('month'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined); const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range); const posSales = usePosSalesByBranch(branchIds, dates.range);
const rows = useMemo( const rows = useMemo(
() => () =>
@@ -101,18 +99,6 @@ export function ReportsPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Reports" title="Reports"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Revenue and stock over a period you choose, compared where a comparison is sound."
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
}
tabs={ tabs={
<HStack gap={0.5} wrap="wrap"> <HStack gap={0.5} wrap="wrap">
<TabButton <TabButton

View File

@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react'; import { useState, useMemo } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react'; import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types'; import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
@@ -17,7 +17,6 @@ import {
usePosSalesByBranch, usePosSalesByBranch,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, moneyExact } from '../format'; import { branchLabel, count, money, moneyExact } from '../format';
import { import {
DELIVERY_STATUS, DELIVERY_STATUS,
@@ -31,7 +30,6 @@ import {
syncLagMs, syncLagMs,
type StatusKey, type StatusKey,
} from '../orderStatus'; } from '../orderStatus';
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus'; import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar'; import { AssignBar } from '../AssignBar';
@@ -60,17 +58,15 @@ type Tab = 'orders' | 'deliveries' | 'counter';
* every row would be a column of dashes. * every row would be a column of dashes.
*/ */
export function SalesPage() { export function SalesPage() {
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope(); const { scoped, selected, branches, tenantid } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const [tab, setTab] = useState<Tab>('orders'); const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all'); const [status, setStatus] = useState<StatusKey>('all');
const [preset, setPreset] = useState<RangePreset>('month'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
/* What the tables are currently showing. Any change to it sends every pager /* 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 back to page 1 — landing on page 4 of a search you just typed reads as an
empty result. */ 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>( const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
null, null,
@@ -86,15 +82,15 @@ export function SalesPage() {
tenantid, tenantid,
locationid: selected ?? undefined, locationid: selected ?? undefined,
keyword: keyword.trim() || undefined, keyword: keyword.trim() || undefined,
...range, ...dates.range,
pagesize: 200, pagesize: 200,
} }
: undefined; : undefined;
const orders = useOrders(query); const orders = useOrders(query);
const deliveries = useDeliveries(query); const deliveries = useDeliveries(query);
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 }); const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, range); const posSummary = usePosSalesByBranch(branchIds, dates.range);
const allOrders = orders.data ?? []; const allOrders = orders.data ?? [];
const allDeliveries = deliveries.data ?? []; const allDeliveries = deliveries.data ?? [];
@@ -218,30 +214,6 @@ export function SalesPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Sales" title="Sales"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
isLive
actions={
<HStack gap={0.5} align="center" wrap="wrap">
{/* 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. */}
<BarAction
label="Upload counter sales"
icon={<Upload size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</HStack>
}
tabs={ tabs={
<HStack gap={0.5} wrap="wrap"> <HStack gap={0.5} wrap="wrap">
<TabButton <TabButton
@@ -482,15 +454,6 @@ export function SalesPage() {
</> </>
) : null} ) : null}
{isUploading && tenantid ? (
<CounterSalesDrawer
tenantid={tenantid}
{...(isPinned && current ? { locationid: current.locationid } : {})}
branchIds={branches.map((branch) => branch.locationid)}
onClose={() => setUploading(false)}
/>
) : null}
{/* The row is re-read from the live list on every render rather than {/* 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 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 (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(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 ? 'not-allowed' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}

View File

@@ -118,7 +118,6 @@ export function ShopProfilePage() {
<PageBody measure="reading"> <PageBody measure="reading">
<PageHeader <PageHeader
title="Shop profile" title="Shop profile"
description="What shoppers see about your business, and how to reach you."
{...(gaps.length > 0 {...(gaps.length > 0
? { count: `${gaps.length} still to fill in` } ? { count: `${gaps.length} still to fill in` }
: {})} : {})}
@@ -175,7 +174,6 @@ export function ShopProfilePage() {
value={form['tenantimage'] ?? ''} value={form['tenantimage'] ?? ''}
onChange={set('tenantimage')} onChange={set('tenantimage')}
placeholder="https://…" placeholder="https://…"
description="The picture shoppers see beside your shop in the app."
/> />
</div> </div>
</HStack> </HStack>
@@ -190,7 +188,6 @@ export function ShopProfilePage() {
label="FSSAI / trade licence" label="FSSAI / trade licence"
value={form['licenseno'] ?? ''} value={form['licenseno'] ?? ''}
onChange={set('licenseno')} onChange={set('licenseno')}
description="Shown to shoppers. Required to display for a food business."
/> />
<TextArea <TextArea

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useState, useMemo } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
@@ -16,12 +17,10 @@ import {
Pencil, Pencil,
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel } from '../format'; import { branchLabel } from '../format';
import { useCounterLabels, type CounterLabels } from '../counterLabels'; import { useCounterLabels, type CounterLabels } from '../counterLabels';
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus'; import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
@@ -48,14 +47,13 @@ import { usePaged } from '@/components/usePaged';
* correct output, and it is the one the old design could not produce. * correct output, and it is the one the old design could not produce.
*/ */
export function TerminalsPage() { export function TerminalsPage() {
const { branches, scoped, selected, current } = useBranchScope(); const { scoped } = useBranchScope();
const [preset, setPreset] = useState<RangePreset>('today'); const dates = useDateScope();
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const [showFine, setShowFine] = useState(false); const [showFine, setShowFine] = useState(false);
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const labels = useCounterLabels(); const labels = useCounterLabels();
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden }); const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
const needs = board.now.length + board.look.length; const needs = board.now.length + board.look.length;
/** /**
@@ -79,19 +77,7 @@ export function TerminalsPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Counters" title="Counters"
count={selected === null ? `${branches.length} branches` : current?.locationname}
{...(summary ? { description: summary } : {})} {...(summary ? { description: summary } : {})}
isLive
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
}
/> />
{/* The console's opening move on every operations page — Console, Sales, {/* The console's opening move on every operations page — Console, Sales,

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
@@ -22,15 +23,26 @@ export function UploadsPage() {
const { user } = useAuth(); const { user } = useAuth();
const { selected } = useBranchScope(); const { selected } = useBranchScope();
const [isUploading, setUploading] = useState(false); const [isUploading, setUploading] = useState(false);
/*
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 ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Uploads" title="Uploads"
description="Spreadsheets sent to the catalogue service — what each one added, and whether it reached a shelf."
/> />
<UploadsPanel <UploadsPanel
tenantid={user?.tenantid ?? 0} tenantid={user?.tenantid ?? 0}
range={dates.range}
onClearRange={dates.clear}
onUpload={() => setUploading(true)} onUpload={() => setUploading(true)}
{...(selected === null ? {} : { locationid: selected })} {...(selected === null ? {} : { locationid: selected })}
/> />

View File

@@ -140,9 +140,8 @@ export function UsersPage() {
return ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
isTabsInline
title="Users & access" title="Users & access"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Who can sign in to the console, and who can open a till."
actions={ actions={
<Button <Button
label={ label={

View File

@@ -8,6 +8,7 @@ import { useTenantLocations } from '@/queries/hooks';
import { AssistantScope } from '@/features/console/AssistantScope'; import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope'; import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents'; import { useLiveEvents } from '@/queries/useLiveEvents';
import { DateScopeProvider } from '@/components/shell/DateScope';
/** /**
* Store user — one branch, and only that branch. * Store user — one branch, and only that branch.
@@ -112,6 +113,7 @@ export function StoreUserShell() {
if (locationid === null) return <NoStore />; if (locationid === null) return <NoStore />;
return ( return (
<DateScopeProvider>
<BranchScopeProvider pin={locationid}> <BranchScopeProvider pin={locationid}>
<LiveWatch /> <LiveWatch />
<AssistantScope> <AssistantScope>
@@ -131,6 +133,7 @@ export function StoreUserShell() {
</AssistantScope> </AssistantScope>
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null} {isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
</BranchScopeProvider> </BranchScopeProvider>
</DateScopeProvider>
); );
} }

View File

@@ -74,8 +74,6 @@ export function StoreAccountPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="My account" title="My account"
count={current?.locationname}
description="Who you are signed in as, and the shop this account is tied to."
/> />
<div style={{ maxWidth: 560 }}> <div style={{ maxWidth: 560 }}>

View File

@@ -54,8 +54,6 @@ export function StoreCustomersPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Customers" title="Customers"
count={current?.locationname}
description="Everyone registered against this shop. Read-only — accounts are managed in the customer app."
actions={ actions={
<div style={{ width: 230 }}> <div style={{ width: 230 }}>
<TextInput <TextInput

View File

@@ -143,8 +143,6 @@ export function StoreProductsPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Products" title="Products"
count={current?.locationname}
description="What your shop can sell, what is on the shelf, and what you have asked head office for."
actions={ actions={
tab === 'requests' ? null : ( tab === 'requests' ? null : (
<div style={{ width: 230 }}> <div style={{ width: 230 }}>

View File

@@ -59,8 +59,6 @@ export function StoreStaffPage() {
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Counter staff" title="Counter staff"
count={current?.locationname}
description="Who can open the till at this counter. They sign in at the terminal, not here."
actions={ actions={
<Button <Button
label="Add cashier" label="Add cashier"

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
@@ -19,16 +20,27 @@ export function StoreUploadsPage() {
const { selected } = useBranchScope(); const { selected } = useBranchScope();
const locationid = selected ?? user?.locationid ?? 0; const locationid = selected ?? user?.locationid ?? 0;
const [isUploading, setUploading] = useState(false); const [isUploading, setUploading] = useState(false);
/*
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 ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader <PageHeader
title="Uploads" title="Uploads"
description="Spreadsheets sent to the catalogue service from this branch — what each one added, and whether it reached the shelf."
/> />
<UploadsPanel <UploadsPanel
tenantid={user?.tenantid ?? 0} tenantid={user?.tenantid ?? 0}
locationid={locationid} locationid={locationid}
range={dates.range}
onClearRange={dates.clear}
onUpload={() => setUploading(true)} onUpload={() => setUploading(true)}
/> />
{isUploading ? ( {isUploading ? (

View File

@@ -47,6 +47,7 @@ import {
type IngestProduct, type IngestProduct,
} from '@/api/ingest'; } from '@/api/ingest';
import { uploadsApi, type UploadReceipt } from '@/api/uploads'; import { uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { DateRange } from '@/api/insights';
import type { SheetProductRow } from '@/api/products'; import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows'; import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet'; import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
@@ -75,6 +76,17 @@ export interface UploadsPanelProps {
tenantid?: number; tenantid?: number;
/** Narrow to one branch. A Store user is pinned to theirs. */ /** Narrow to one branch. A Store user is pinned to theirs. */
locationid?: number; locationid?: number;
/**
* The date range, owned by the page header.
*
* Every page in the console filters by date from the same control in the same
* place; a second pair of date boxes inside one page's own toolbar meant this
* screen alone asked the question twice, in a different shape, a few pixels
* lower down.
*/
range?: DateRange;
/** Clears the header's date filter, offered by the empty state. */
onClearRange?: () => void;
/** /**
* Opens this workspace's upload flow. * Opens this workspace's upload flow.
* *
@@ -92,15 +104,21 @@ interface LiveReading {
products: IngestProduct[]; products: IngestProduct[];
} }
export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelProps) { export function UploadsPanel({
tenantid,
locationid,
range,
onClearRange,
onUpload,
}: UploadsPanelProps) {
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null); const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({}); const [live, setLive] = useState<Record<string, LiveReading>>({});
const [detail, setDetail] = useState<string | null>(null); const [detail, setDetail] = useState<string | null>(null);
const [tab, setTab] = useState<UploadTab>('all'); const [tab, setTab] = useState<UploadTab>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [from, setFrom] = useState(''); const from = range?.fromdate ?? '';
const [to, setTo] = useState(''); const to = range?.todate ?? '';
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -195,19 +213,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
}); });
}, [rows, live, tab, search, from, to]); }, [rows, live, tab, search, from, to]);
/* Counts are of everything, not of what is on screen: a tab that reported the /*
size of its own filtered view would always read the same as the list under Counts ignore the TAB but respect every other filter.
it and could never tell you there is a failure worth looking at. */
Ignoring the tab is the point — a tab reporting the size of its own filtered
view would always equal the list under it and could never tell you there is a
failure worth looking at. Respecting the search and the date is equally the
point, and was missed: with a date range that excluded everything, the list
said "Nothing matches" while the tab beside it said two, and the two
contradicted each other on the same screen.
*/
const counts = useMemo(() => { const counts = useMemo(() => {
const out = { all: rows.length, done: 0, failed: 0, running: 0 }; const scoped = rows.filter(
for (const receipt of rows) { (receipt) => matchesSearch(receipt, search) && matchesDate(receipt, from, to),
);
const out = { all: scoped.length, done: 0, failed: 0, running: 0 };
for (const receipt of scoped) {
const state = stateOf(receipt, live[receipt.batchid]?.batch); const state = stateOf(receipt, live[receipt.batchid]?.batch);
if (matchesTab(state, 'done')) out.done += 1; if (matchesTab(state, 'done')) out.done += 1;
if (matchesTab(state, 'failed')) out.failed += 1; if (matchesTab(state, 'failed')) out.failed += 1;
if (matchesTab(state, 'running')) out.running += 1; if (matchesTab(state, 'running')) out.running += 1;
} }
return out; return out;
}, [rows, live]); }, [rows, live, search, from, to]);
const open = shown.find((receipt) => receipt.batchid === detail) ?? null; const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` }); const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` });
@@ -284,22 +312,6 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
aria-label="Search by file name" aria-label="Search by file name"
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
/> />
<input
className="up-date"
type="date"
value={from}
max={to || undefined}
aria-label="Uploaded on or after"
onChange={(event) => setFrom(event.target.value)}
/>
<input
className="up-date"
type="date"
value={to}
min={from || undefined}
aria-label="Uploaded on or before"
onChange={(event) => setTo(event.target.value)}
/>
<Button <Button
label={isRefreshing ? 'Checking…' : 'Check again'} label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary" variant="secondary"
@@ -323,13 +335,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
{/* ── The list ─────────────────────────────────────────────────────── */} {/* ── The list ─────────────────────────────────────────────────────── */}
{shown.length === 0 ? ( {shown.length === 0 ? (
<Card padding={4} variant="transparent"> <Card padding={4} variant="transparent">
<VStack gap={0.5}> <VStack gap={1} align="start">
<Text type="label" weight="semibold"> <Text type="label" weight="semibold">
Nothing matches Nothing matches
</Text> </Text>
{/*
The date range is named as the likely cause, with the way out.
This page opens on the month like every other, and a receipt log's
most-wanted row is often older than that — an empty list that does
not say which filter emptied it sends people to the refresh button.
*/}
<Text type="body" size="sm" color="secondary"> <Text type="body" size="sm" color="secondary">
No upload matches this tab, search or date range. {rows.length > 0 && (from || to)
? `${rows.length} upload${rows.length === 1 ? ' is' : 's are'} outside the selected date range.`
: 'No upload matches this tab, search or date range.'}
</Text> </Text>
{onClearRange && (from || to) ? (
<Button
label="Clear date filter"
variant="secondary"
size="sm"
onClick={onClearRange}
/>
) : null}
</VStack> </VStack>
</Card> </Card>
) : ( ) : (