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 {
title: string;
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
count?: string;
/** One line of context. */
description?: string;
/** Right-aligned actions, wrapping. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
tabs?: ReactNode;
isLive?: boolean;
/**
* Put the tabs on the SAME line as the actions — tabs left, actions right —
* instead of on a row of their own beneath.
*
* Worth having now that the page titles are gone: with nothing above them the
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
* one gives the page back a row without crowding anything.
*/
isTabsInline?: boolean;
}
/**
* The page frame header, built to KROW's `AdminPage` spec.
*
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
* rhythm.
* Actions right-aligned and wrapping, with an optional tabs row beneath.
* Everything the page stacks below sits on a 24px rhythm.
*
* The visible title, count, Live pill and description are gone from every page
* in all three workspaces. They restated what the chrome already says — the top
* bar names the section, the branch picker gives the count — and the
* description was a line of prose above data that nobody reads twice. The title
* survives as a screen-reader-only h1; see the note at the call.
*
* There was a hairline rule under all of this, with 16px of padding above it
* and another 12px below before the tabs — 28px of nothing plus a line, on
@@ -31,87 +40,53 @@ export interface PageHeaderProps {
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
*/
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
const hasRow = Boolean(actions || (isTabsInline && tabs));
return (
<>
<header
style={{
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>
{/*
The title is still here, and still read aloud — it is just not drawn.
{count ? (
<span
style={{
fontSize: 13,
color: 'var(--color-ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
{count}
</span>
) : null}
Removing it visually was the ask; removing it outright would leave every
page in the console with no h1, no document outline and nothing for a
screen reader to announce on navigation. `sr-only` is absolutely
positioned, so it is out of flow and costs no layout.
*/}
<h1 className="sr-only">{title}</h1>
{isLive ? (
<span
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>
{/*
No row at all when there is nothing to put in it.
{description ? (
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{description}
</p>
It used to render an empty <header> regardless. At zero height that
looks free, but it is still a flex child, so it collected the column's
12px gap and pushed everything down — a page with no actions sat 36px
below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches.
*/}
{hasRow ? (
<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}
</div>
</header>
) : null}
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
</>
);
}

View File

@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
export interface NavEntry {
to: string;
@@ -133,14 +134,26 @@ export function AppShell({
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
on exactly the same left edge as the page title below them at every
width, including a 2560px monitor where the body is centred. */}
{/*
Opaque, with no backdrop blur.
The blur cost more than it bought the moment a popover moved into this
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
`position: fixed` descendant, and the design system's popovers are fixed
and CSS-anchor-positioned — so the date picker's calendar rendered
inside the header at 0×0 and could not be opened at all. The same
control worked perfectly two pixels lower, on the page.
A solid background is the fix rather than a hack around it: the bar sits
on a near-white page, so at 85% opacity plus blur it was already almost
opaque, and nothing here reads differently for losing it.
*/}
<header
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'rgba(255,255,255,.85)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
background: 'var(--color-surface)',
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
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}
{/* 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. */}
<HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
category={product.category}
/>
{product.highlights?.length || product.nutrients?.length ? (

View File

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

View File

@@ -1,4 +1,5 @@
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
import { DateScopeProvider } from '@/components/shell/DateScope';
/**
* Nearle Admin — the platform workspace.
@@ -15,5 +16,9 @@ const NAV: readonly NavEntry[] = [
];
export function NearleAdminShell() {
return <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}>
<PageHeader
title="Global catalogue"
description="Read the FMCG catalogue, or upload a merchant's own product list."
actions={
<SegmentedControl
label="What to do"

View File

@@ -130,7 +130,6 @@ export function OnboardTenantPage() {
<VStack gap={3}>
<PageHeader
title="Tenant provisioned"
description="The enterprise is registered and its primary Administrator account has been created."
/>
<Card padding={4} elevation="low">
<VStack gap={3}>
@@ -171,7 +170,6 @@ export function OnboardTenantPage() {
<PageBody measure="reading">
<PageHeader
title="Onboard tenant"
description="Register a merchant group, commission its first outlet, and create its Administrator account."
/>
<form onSubmit={handleSubmit}>
@@ -207,7 +205,6 @@ export function OnboardTenantPage() {
value={form.primaryemail}
onChange={set('primaryemail')}
placeholder="admin@kaveri.com"
description="The Administrator account is created against this address."
/>
<TextInput
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}>
<PageHeader
title={tenant?.tenantname ?? 'Tenant'}
count={tenant?.companyname}
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
tenant?.city ?? '—'
}`}
isLive
actions={
<HStack gap={1}>
<Button

View File

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

View File

@@ -1,3 +1,4 @@
import { useDateScope } from '@/components/shell/DateScope';
import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
@@ -13,17 +14,26 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
*/
export function UploadsPage() {
const navigate = useNavigate();
/*
No date filter by default, unlike the reporting pages.
This is a receipt log, and the reason somebody opens it is to find an upload
from a while ago. Starting at month-to-date hid every receipt older than the
1st — the list read "Nothing matches" while the tab beside it said there were
two. The read already returns only the most recent fifty, so the range is a
narrowing rather than the thing that makes the page tractable.
*/
const dates = useDateScope();
return (
<VStack gap={3}>
<PageHeader
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
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. */}
<UploadsPanel onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
<UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
</VStack>
);
}

View File

@@ -3,7 +3,7 @@ import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition';
import type { Product } from '@/api/types';
import { DrawerCard, Note, Row, Section } from './drawerKit';
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
import { BAND_COLOR, BAND_LABEL, facts, isEdible, present } from './healthScore';
import './pages/deliveries.css';
/**
@@ -26,10 +26,40 @@ import './pages/deliveries.css';
* confident one — see the caveat, which is shown whenever the service's own
* match confidence is below 70%.
*/
export function HealthScorePanel({ product }: { product: Product }) {
export function HealthScorePanel({
product,
category,
}: {
product: Product;
/**
* The product's category, used to decide whether a nutrition score means
* anything for it at all. Passed in because the two callers hold it in
* different fields — a tenant product has `categoryname`, a catalogue row has
* `category` — and neither is on the narrow shape this panel receives.
*/
category?: string | null;
}) {
const brand = (product.productbrand ?? '').trim();
const imageId = (product.imageid ?? '').trim();
/*
Soap does not have a health score, and the service will give you one anyway.
Measured again 7 Sep 2026: `GET /nutrition/Godrej/godrej_hit_spray_1101d017`
returns `health_score: 80`, `data_status: "verified"` — an "excellent" score
for insecticide. Palmolive and Pantene score 37.5 through the same route.
The catalogue team's own guidance is to hide the field entirely for non-food
rather than show an empty slot, and their per-product endpoint still does not
publish the edibility label that gates their LIST endpoint — `data_status` is
"unavailable" for both a soap and an unscored food, and `category` is
populated for both, so nothing in the response separates them. Our own
category does, so the decision is made here until they publish theirs.
Before the network call, deliberately: there is nothing to ask about.
*/
if (!isEdible(category)) return null;
const query = useQuery({
queryKey: ['nutrition', brand, imageId],
queryFn: () => nutritionApi.forProduct(brand, imageId),
@@ -89,8 +119,9 @@ export function HealthScorePanel({ product }: { product: Product }) {
return (
<Section title="Health score">
<Note>
This product is in the catalogue but has not been scored yet. It will appear here, and in
the app, once it has.
Nutrition information not available for this product. The catalogue service has already
searched its nutrition databases and found nothing usable, so this gap closes only by
entering the per-100g figures by hand — not by waiting.
</Note>
</Section>
);

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {
groupByStore,
isToday,
lastSeen,
shiftDay,
stopKey,
stopValue,
toStops,
@@ -377,14 +376,6 @@ test('the day is worth the work not yet sent out, too', () => {
/* ── The day ─────────────────────────────────────────────────────────────── */
test('stepping a day stays in local time', () => {
// Parsing the string would shift the whole board by a day in some zones.
assert.equal(shiftDay('2026-09-05', -1), '2026-09-04');
assert.equal(shiftDay('2026-09-05', 1), '2026-09-06');
assert.equal(shiftDay('2026-01-01', -1), '2025-12-31', 'across a year boundary');
assert.equal(shiftDay('2026-03-01', -1), '2026-02-28', 'across a month boundary');
});
test('today is today', () => {
assert.equal(isToday(ymd(new Date())), true);
assert.equal(isToday('2020-01-01'), false);

View File

@@ -421,21 +421,12 @@ export function dayTotals(stops: readonly Stop[]): DayTotals {
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
}
/** One day as Fiesta wants it, and the stepper's arithmetic. */
/** One day as Fiesta wants it. */
export function ymd(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function shiftDay(day: string, by: number): string {
const [y, m, d] = day.split('-').map(Number);
// Local midnight, then step. Constructing from the parts rather than parsing
// the string avoids the UTC shift that would move the whole board a day.
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
date.setDate(date.getDate() + by);
return ymd(date);
}
export function isToday(day: string): boolean {
return day === ymd(new Date());
}

View File

@@ -254,3 +254,52 @@ test('a declared allergen is shown regardless of confidence', () => {
assert.deepEqual(shown.allergens, ['Gluten']);
assert.equal(shown.allergensUnconfirmed, false);
});
/* ── Edibility, wired in 7 Sep 2026 ──────────────────────────────────────── */
test('a health score is never shown for something you do not eat', () => {
/*
The service will happily score insecticide. Measured 7 Sep 2026:
GET /nutrition/Godrej/godrej_hit_spray_1101d017 returns health_score 80,
data_status "verified" — an "excellent" score for HIT spray. Palmolive soap
and Pantene shampoo both score 37.5 by the same route.
Their per-product endpoint does not publish the edibility label that gates
their list endpoint, so the decision has to be made from our own category.
*/
assert.equal(isEdible('Home Care'), false);
assert.equal(isEdible('Personal Care'), false);
assert.equal(isEdible('Detergents'), false);
assert.equal(isEdible('Oral Care'), false);
});
test('"General" is not good enough to call something food', () => {
// It carries Palmolive soap and P&G alongside anything the scraper could not
// place. Ambiguous is not a basis for showing a nutrition score.
assert.equal(isEdible('General'), false);
assert.equal(isEdible('general'), false);
});
test('the real catalogue food categories are recognised', () => {
// Taken from live rows, not invented — these are what the drawer receives.
for (const category of [
'Spices & Masalas',
'Pulses, Grains & Spices',
'Health Drinks',
'Dairy',
'Snacks & Namkeen',
'Edible Oils',
'Biscuits & Cookies',
'Beverages',
]) {
assert.equal(isEdible(category), true, category);
}
});
test('a missing category is treated as not food', () => {
// Hiding a panel on a food product costs one line of "not available".
// Showing one on a bottle of insecticide costs rather more.
assert.equal(isEdible(undefined), false);
assert.equal(isEdible(null), false);
assert.equal(isEdible(' '), false);
});

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 { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
@@ -16,7 +17,6 @@ import {
ShoppingCart,
TrendingDown,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
@@ -27,7 +27,6 @@ import {
useStockRequests,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -52,7 +51,7 @@ import {
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/
export function ConsolePage() {
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
const { scoped, selected, tenantid, isLoading, isPinned } = useBranchScope();
/**
* Where this page's links point.
*
@@ -62,13 +61,12 @@ export function ConsolePage() {
* does nothing.
*/
const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posSales = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
const requests = useStockRequests(
@@ -134,23 +132,6 @@ export function ConsolePage() {
<VStack gap={3}>
<PageHeader
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 ──────────────────────────── */}

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 type { DateRange } from '@/api/insights';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { useCounterLabels } from '../counterLabels';
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
import { branchLabel, count, money } from '../format';
@@ -49,9 +48,8 @@ const STATE_TONE: Record<CounterState, string> = {
};
export function CountersPage() {
const { branches, scoped, selected, current, isPinned } = useBranchScope();
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const { branches, scoped, isPinned } = useBranchScope();
const dates = useDateScope();
const [chip, setChip] = useState<CounterState | 'all'>('all');
const [branchId, setBranchId] = useState<number | null>(null);
const [term, setTerm] = useState('');
@@ -59,7 +57,7 @@ export function CountersPage() {
const [open, setOpen] = useState<CounterRow | null>(null);
const labels = useCounterLabels();
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
const rows = useMemo(() => rowsOf(board), [board]);
const counts = useMemo(() => countsOf(rows), [rows]);
@@ -81,31 +79,7 @@ export function CountersPage() {
return (
<div className="console">
<header className="console-head">
<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>
<h1 className="sr-only">Counters</h1>
<div className="kpi-strip">
<Kpi
icon={<Monitor size={17} />}

View File

@@ -1,13 +1,10 @@
import { useMemo, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
ChevronLeft,
ChevronRight,
IndianRupee,
MapPin,
Package,
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -37,7 +35,6 @@ import {
groupByStore,
isToday,
lastSeen,
shiftDay,
stopAddress,
stopCustomer,
stopKey,
@@ -48,7 +45,6 @@ import {
toStops,
UNASSIGNED,
WAITING,
ymd,
type Group,
type Stop,
type ViewMode,
@@ -56,10 +52,12 @@ import {
import './dispatch.css';
/**
* Dispatch — one day's deliveries, read three ways.
* Dispatch — the day's deliveries, read three ways.
*
* A day at a time, because that is how a round is worked: everything here is
* scoped to a single date, not a range. No batch windows — order placement in
* Scoped by the console's shared date filter in the top bar, like every other
* page. A single day is the shape this was built for — that is how a round is
* worked — and the labels adapt when a wider range is chosen rather than
* calling a week of a rider's stops a "round". No batch windows — order placement in
* this tenant clusters so hard into one hour that a morning/afternoon/evening
* split would put ninety per cent in one bucket and call it navigation.
*
@@ -92,14 +90,14 @@ import './dispatch.css';
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
export function DispatchPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const [day, setDay] = useDayParam();
const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
@@ -121,6 +119,16 @@ export function DispatchPage() {
: undefined,
);
/*
A one-day range is still the shape this board was built for, so it keeps the
wording it had: "Rounds", "on this day". A wider range is a legitimate thing
to ask for and the groupings all still hold — but a rider's group is then
several rounds run together, and the labels say so rather than pretending.
*/
const isOneDay = Boolean(
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
);
const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data],
@@ -194,12 +202,6 @@ export function DispatchPage() {
<VStack gap={3}>
<PageHeader
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={
<HStack gap={0.5} wrap="wrap">
<ModeTab
@@ -230,7 +232,7 @@ export function DispatchPage() {
<KpiCard
label="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"
icon={<Package size={15} />}
/>
@@ -260,7 +262,13 @@ export function DispatchPage() {
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
@@ -301,7 +309,11 @@ export function DispatchPage() {
<MapPin size={22} />
</span>
<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
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 ────────────────────────────────────────────────────────────── */
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.
*/
export function InventoryPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const { selected, tenantid } = useBranchScope();
/**
* The tab comes from the URL, so a link can name one.
@@ -112,9 +112,8 @@ export function InventoryPage() {
) : null}
<PageHeader
isTabsInline
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
fixed — it no longer appears and disappears as the tab changes, and
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
two agree. */
actions={
<HStack gap={0.5} align="center" wrap="wrap">
<HStack gap={1} align="center" wrap="wrap">
<BarAction
label="Upload sheet"
icon={<UploadCloud size={14} />}
@@ -181,7 +180,10 @@ export function InventoryPage() {
) : tab === 'catalogue' ? (
<CataloguePanel />
) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
<RequestsPanel
requests={requests.data ?? []}
isLoading={requests.isLoading}
/>
) : (
<StockPanel />
)}

View File

@@ -185,7 +185,7 @@ export function OnboardBranchPage() {
if (mutation.isSuccess) {
return (
<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">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
@@ -220,7 +220,6 @@ export function OnboardBranchPage() {
<PageBody measure="reading">
<PageHeader
title="Onboard branch"
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
/>
<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 { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
@@ -19,13 +20,11 @@ import {
TrendingDown,
Warehouse,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { TablePager } from '@/components/TablePager';
@@ -42,14 +41,13 @@ type Tab = 'revenue' | 'sales' | 'inventory';
* `posorders` would not be.
*/
export function ReportsPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
const { scoped, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('revenue');
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posSales = usePosSalesByBranch(branchIds, dates.range);
const rows = useMemo(
() =>
@@ -101,18 +99,6 @@ export function ReportsPage() {
<VStack gap={3}>
<PageHeader
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={
<HStack gap={0.5} wrap="wrap">
<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 { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
@@ -17,7 +17,6 @@ import {
usePosSalesByBranch,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, moneyExact } from '../format';
import {
DELIVERY_STATUS,
@@ -31,7 +30,6 @@ import {
syncLagMs,
type StatusKey,
} from '../orderStatus';
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar';
@@ -60,17 +58,15 @@ type Tab = 'orders' | 'deliveries' | 'counter';
* every row would be a column of dashes.
*/
export function SalesPage() {
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const { scoped, selected, branches, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all');
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const dates = useDateScope();
const [keyword, setKeyword] = useState('');
/* What the tables are currently showing. Any change to it sends every pager
back to page 1 — landing on page 4 of a search you just typed reads as an
empty result. */
const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`;
const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`;
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
null,
@@ -86,15 +82,15 @@ export function SalesPage() {
tenantid,
locationid: selected ?? undefined,
keyword: keyword.trim() || undefined,
...range,
...dates.range,
pagesize: 200,
}
: undefined;
const orders = useOrders(query);
const deliveries = useDeliveries(query);
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, range);
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, dates.range);
const allOrders = orders.data ?? [];
const allDeliveries = deliveries.data ?? [];
@@ -218,30 +214,6 @@ export function SalesPage() {
<VStack gap={3}>
<PageHeader
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={
<HStack gap={0.5} wrap="wrap">
<TabButton
@@ -482,15 +454,6 @@ export function SalesPage() {
</>
) : 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
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">
<PageHeader
title="Shop profile"
description="What shoppers see about your business, and how to reach you."
{...(gaps.length > 0
? { count: `${gaps.length} still to fill in` }
: {})}
@@ -175,7 +174,6 @@ export function ShopProfilePage() {
value={form['tenantimage'] ?? ''}
onChange={set('tenantimage')}
placeholder="https://…"
description="The picture shoppers see beside your shop in the app."
/>
</div>
</HStack>
@@ -190,7 +188,6 @@ export function ShopProfilePage() {
label="FSSAI / trade licence"
value={form['licenseno'] ?? ''}
onChange={set('licenseno')}
description="Shown to shoppers. Required to display for a food business."
/>
<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 { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
@@ -16,12 +17,10 @@ import {
Pencil,
Send,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel } from '../format';
import { useCounterLabels, type CounterLabels } from '../counterLabels';
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.
*/
export function TerminalsPage() {
const { branches, scoped, selected, current } = useBranchScope();
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const { scoped } = useBranchScope();
const dates = useDateScope();
const [showFine, setShowFine] = useState(false);
const [showHidden, setShowHidden] = useState(false);
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;
/**
@@ -79,19 +77,7 @@ export function TerminalsPage() {
<VStack gap={3}>
<PageHeader
title="Counters"
count={selected === null ? `${branches.length} branches` : current?.locationname}
{...(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,

View File

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

View File

@@ -140,9 +140,8 @@ export function UsersPage() {
return (
<VStack gap={3}>
<PageHeader
isTabsInline
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={
<Button
label={

View File

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

View File

@@ -74,8 +74,6 @@ export function StoreAccountPage() {
<VStack gap={3}>
<PageHeader
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 }}>

View File

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

View File

@@ -143,8 +143,6 @@ export function StoreProductsPage() {
<VStack gap={3}>
<PageHeader
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={
tab === 'requests' ? null : (
<div style={{ width: 230 }}>

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ import {
type IngestProduct,
} from '@/api/ingest';
import { uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { DateRange } from '@/api/insights';
import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
@@ -75,6 +76,17 @@ export interface UploadsPanelProps {
tenantid?: number;
/** Narrow to one branch. A Store user is pinned to theirs. */
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.
*
@@ -92,15 +104,21 @@ interface LiveReading {
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 [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({});
const [detail, setDetail] = useState<string | null>(null);
const [tab, setTab] = useState<UploadTab>('all');
const [search, setSearch] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const from = range?.fromdate ?? '';
const to = range?.todate ?? '';
const [isRefreshing, setIsRefreshing] = useState(false);
const load = useCallback(async () => {
@@ -195,19 +213,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
});
}, [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
it and could never tell you there is a failure worth looking at. */
/*
Counts ignore the TAB but respect every other filter.
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 out = { all: rows.length, done: 0, failed: 0, running: 0 };
for (const receipt of rows) {
const scoped = rows.filter(
(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);
if (matchesTab(state, 'done')) out.done += 1;
if (matchesTab(state, 'failed')) out.failed += 1;
if (matchesTab(state, 'running')) out.running += 1;
}
return out;
}, [rows, live]);
}, [rows, live, search, from, to]);
const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
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"
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
label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary"
@@ -323,13 +335,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
{/* ── The list ─────────────────────────────────────────────────────── */}
{shown.length === 0 ? (
<Card padding={4} variant="transparent">
<VStack gap={0.5}>
<VStack gap={1} align="start">
<Text type="label" weight="semibold">
Nothing matches
</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">
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>
{onClearRange && (from || to) ? (
<Button
label="Clear date filter"
variant="secondary"
size="sm"
onClick={onClearRange}
/>
) : null}
</VStack>
</Card>
) : (