enhancement
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
85
src/components/shell/DateScope.tsx
Normal file
85
src/components/shell/DateScope.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ──────────────────────────── */}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user