enhancement
This commit is contained in:
@@ -2,23 +2,32 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
export interface PageHeaderProps {
|
export interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
|
|
||||||
count?: string;
|
|
||||||
/** One line of context. */
|
|
||||||
description?: string;
|
|
||||||
/** Right-aligned actions, wrapping. */
|
/** Right-aligned actions, wrapping. */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
/** An optional tabs row directly under the header rule. */
|
/** An optional tabs row directly under the header rule. */
|
||||||
tabs?: ReactNode;
|
tabs?: ReactNode;
|
||||||
isLive?: boolean;
|
/**
|
||||||
|
* Put the tabs on the SAME line as the actions — tabs left, actions right —
|
||||||
|
* instead of on a row of their own beneath.
|
||||||
|
*
|
||||||
|
* Worth having now that the page titles are gone: with nothing above them the
|
||||||
|
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
|
||||||
|
* one gives the page back a row without crowding anything.
|
||||||
|
*/
|
||||||
|
isTabsInline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page frame header, built to KROW's `AdminPage` spec.
|
* The page frame header, built to KROW's `AdminPage` spec.
|
||||||
*
|
*
|
||||||
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
|
* Actions right-aligned and wrapping, with an optional tabs row beneath.
|
||||||
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
|
* Everything the page stacks below sits on a 24px rhythm.
|
||||||
* rhythm.
|
*
|
||||||
|
* The visible title, count, Live pill and description are gone from every page
|
||||||
|
* in all three workspaces. They restated what the chrome already says — the top
|
||||||
|
* bar names the section, the branch picker gives the count — and the
|
||||||
|
* description was a line of prose above data that nobody reads twice. The title
|
||||||
|
* survives as a screen-reader-only h1; see the note at the call.
|
||||||
*
|
*
|
||||||
* There was a hairline rule under all of this, with 16px of padding above it
|
* There was a hairline rule under all of this, with 16px of padding above it
|
||||||
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
||||||
@@ -31,9 +40,31 @@ export interface PageHeaderProps {
|
|||||||
* The tabs' spacing lives here rather than at each call site, so the five pages
|
* The tabs' spacing lives here rather than at each call site, so the five pages
|
||||||
* that have tabs cannot drift apart from each other again.
|
* that have tabs cannot drift apart from each other again.
|
||||||
*/
|
*/
|
||||||
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
|
||||||
|
const hasRow = Boolean(actions || (isTabsInline && tabs));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/*
|
||||||
|
The title is still here, and still read aloud — it is just not drawn.
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
No row at all when there is nothing to put in it.
|
||||||
|
|
||||||
|
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
|
<header
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -43,67 +74,9 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
{/* Left of the row when inline, so the tabs start at the page's left
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
edge and the actions stay on the right. */}
|
||||||
<h1
|
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
|
||||||
className="page-title"
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontFamily: 'var(--font-display)',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
color: 'var(--color-ink-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{count ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'var(--color-ink-4)',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{description ? (
|
|
||||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actions ? (
|
{actions ? (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -111,7 +84,9 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
|
) : null}
|
||||||
|
|
||||||
|
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
|
|||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { ROLE_LABEL } from '@/auth/roles';
|
import { ROLE_LABEL } from '@/auth/roles';
|
||||||
import { AssistantPanel } from './AssistantPanel';
|
import { AssistantPanel } from './AssistantPanel';
|
||||||
|
import { DateScopePicker } from './DateScope';
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -133,14 +134,26 @@ export function AppShell({
|
|||||||
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
||||||
on exactly the same left edge as the page title below them at every
|
on exactly the same left edge as the page title below them at every
|
||||||
width, including a 2560px monitor where the body is centred. */}
|
width, including a 2560px monitor where the body is centred. */}
|
||||||
|
{/*
|
||||||
|
Opaque, with no backdrop blur.
|
||||||
|
|
||||||
|
The blur cost more than it bought the moment a popover moved into this
|
||||||
|
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
|
||||||
|
`position: fixed` descendant, and the design system's popovers are fixed
|
||||||
|
and CSS-anchor-positioned — so the date picker's calendar rendered
|
||||||
|
inside the header at 0×0 and could not be opened at all. The same
|
||||||
|
control worked perfectly two pixels lower, on the page.
|
||||||
|
|
||||||
|
A solid background is the fix rather than a hack around it: the bar sits
|
||||||
|
on a near-white page, so at 85% opacity plus blur it was already almost
|
||||||
|
opaque, and nothing here reads differently for losing it.
|
||||||
|
*/}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
background: 'rgba(255,255,255,.85)',
|
background: 'var(--color-surface)',
|
||||||
backdropFilter: 'blur(24px)',
|
|
||||||
WebkitBackdropFilter: 'blur(24px)',
|
|
||||||
borderBottom: '1px solid var(--color-line)',
|
borderBottom: '1px solid var(--color-line)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -247,6 +260,13 @@ export function AppShell({
|
|||||||
the catalogue are real and stay. A control that looks like it
|
the catalogue are real and stay. A control that looks like it
|
||||||
works costs more trust than a missing one. */}
|
works costs more trust than a missing one. */}
|
||||||
|
|
||||||
|
{/* The date filter, beside the profile and common to every page.
|
||||||
|
|
||||||
|
Rendered here rather than by each page so the two questions the
|
||||||
|
console asks — which shop, and when — are both answered in the
|
||||||
|
chrome, and so the answer survives navigation. */}
|
||||||
|
<DateScopePicker />
|
||||||
|
|
||||||
{headerActions}
|
{headerActions}
|
||||||
|
|
||||||
{/* No notification bell. It was labelled "2 unread" with the dot
|
{/* No notification bell. It was labelled "2 unread" with the dot
|
||||||
|
|||||||
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. */}
|
lookup is needed to find the key. */}
|
||||||
<HealthScorePanel
|
<HealthScorePanel
|
||||||
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
|
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
|
||||||
|
category={product.category}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{product.highlights?.length || product.nutrients?.length ? (
|
{product.highlights?.length || product.nutrients?.length ? (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { DateRange } from '@/api/insights';
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '@/features/store-admin/DateRangePicker';
|
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { summariseBranch } from '@/features/store-admin/posStatus';
|
import { summariseBranch } from '@/features/store-admin/posStatus';
|
||||||
import {
|
import {
|
||||||
@@ -50,12 +49,11 @@ export function ConsolePage() {
|
|||||||
useBranchScope();
|
useBranchScope();
|
||||||
|
|
||||||
const base = isPinned ? '/store' : '/admin';
|
const base = isPinned ? '/store' : '/admin';
|
||||||
const [preset, setPreset] = useState<RangePreset>('today');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
|
||||||
|
|
||||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||||
const orders = useLocationSummary(tenantid || undefined);
|
const orders = useLocationSummary(tenantid || undefined);
|
||||||
const posNow = usePosSalesByBranch(branchIds, range);
|
const posNow = usePosSalesByBranch(branchIds, dates.range);
|
||||||
const posHealth = usePosHealthByBranch(branchIds);
|
const posHealth = usePosHealthByBranch(branchIds);
|
||||||
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
|
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
|
||||||
allBranches: true,
|
allBranches: true,
|
||||||
@@ -68,7 +66,7 @@ export function ConsolePage() {
|
|||||||
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
|
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
|
||||||
[posNow],
|
[posNow],
|
||||||
);
|
);
|
||||||
const series = useConsoleSeries(tenantid, selected, range, posNowData);
|
const series = useConsoleSeries(tenantid, selected, dates.range, posNowData);
|
||||||
|
|
||||||
// One instant for the whole board, so two tills read a second apart are not
|
// One instant for the whole board, so two tills read a second apart are not
|
||||||
// judged against two different "nows".
|
// judged against two different "nows".
|
||||||
@@ -130,27 +128,7 @@ export function ConsolePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="console">
|
<div className="console">
|
||||||
<header className="console-head">
|
<h1 className="sr-only">Console</h1>
|
||||||
<div className="console-title">
|
|
||||||
<div className="console-title-row">
|
|
||||||
<h1>Console</h1>
|
|
||||||
<span className="console-scope">{scope.countLabel}</span>
|
|
||||||
<span className="console-live"><i />Live</span>
|
|
||||||
</div>
|
|
||||||
<p className="console-blurb">{scope.blurb}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="console-actions">
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<KpiStrip totals={totals} isLoading={isBusy} />
|
<KpiStrip totals={totals} isLoading={isBusy} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||||
|
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nearle Admin — the platform workspace.
|
* Nearle Admin — the platform workspace.
|
||||||
@@ -15,5 +16,9 @@ const NAV: readonly NavEntry[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function NearleAdminShell() {
|
export function NearleAdminShell() {
|
||||||
return <AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />;
|
return (
|
||||||
|
<DateScopeProvider>
|
||||||
|
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
|
||||||
|
</DateScopeProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export function GlobalCataloguePage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Global catalogue"
|
title="Global catalogue"
|
||||||
description="Read the FMCG catalogue, or upload a merchant's own product list."
|
|
||||||
actions={
|
actions={
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="What to do"
|
label="What to do"
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ export function OnboardTenantPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tenant provisioned"
|
title="Tenant provisioned"
|
||||||
description="The enterprise is registered and its primary Administrator account has been created."
|
|
||||||
/>
|
/>
|
||||||
<Card padding={4} elevation="low">
|
<Card padding={4} elevation="low">
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
@@ -171,7 +170,6 @@ export function OnboardTenantPage() {
|
|||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Onboard tenant"
|
title="Onboard tenant"
|
||||||
description="Register a merchant group, commission its first outlet, and create its Administrator account."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -207,7 +205,6 @@ export function OnboardTenantPage() {
|
|||||||
value={form.primaryemail}
|
value={form.primaryemail}
|
||||||
onChange={set('primaryemail')}
|
onChange={set('primaryemail')}
|
||||||
placeholder="admin@kaveri.com"
|
placeholder="admin@kaveri.com"
|
||||||
description="The Administrator account is created against this address."
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||||
|
|||||||
@@ -178,11 +178,6 @@ export function StoreDetailPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={tenant?.tenantname ?? 'Tenant'}
|
title={tenant?.tenantname ?? 'Tenant'}
|
||||||
count={tenant?.companyname}
|
|
||||||
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
|
|
||||||
tenant?.city ?? '—'
|
|
||||||
}`}
|
|
||||||
isLive
|
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={1}>
|
<HStack gap={1}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -187,8 +187,6 @@ export function StoresPage() {
|
|||||||
detail) poll on a real interval. */}
|
detail) poll on a real interval. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Stores"
|
title="Stores"
|
||||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
|
||||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
|
||||||
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
@@ -13,17 +14,26 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
|||||||
*/
|
*/
|
||||||
export function UploadsPage() {
|
export function UploadsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
/*
|
||||||
|
No date filter by default, unlike the reporting pages.
|
||||||
|
|
||||||
|
This is a receipt log, and the reason somebody opens it is to find an upload
|
||||||
|
from a while ago. Starting at month-to-date hid every receipt older than the
|
||||||
|
1st — the list read "Nothing matches" while the tab beside it said there were
|
||||||
|
two. The read already returns only the most recent fifty, so the range is a
|
||||||
|
narrowing rather than the thing that makes the page tractable.
|
||||||
|
*/
|
||||||
|
const dates = useDateScope();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Uploads"
|
title="Uploads"
|
||||||
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
|
|
||||||
/>
|
/>
|
||||||
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
|
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
|
||||||
different action from a merchant sending their own list — so this goes
|
different action from a merchant sending their own list — so this goes
|
||||||
to the page that owns it rather than opening a merchant's drawer. */}
|
to the page that owns it rather than opening a merchant's drawer. */}
|
||||||
<UploadsPanel onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
|
<UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
|
|||||||
import { nutritionApi } from '@/api/nutrition';
|
import { nutritionApi } from '@/api/nutrition';
|
||||||
import type { Product } from '@/api/types';
|
import type { Product } from '@/api/types';
|
||||||
import { DrawerCard, Note, Row, Section } from './drawerKit';
|
import { DrawerCard, Note, Row, Section } from './drawerKit';
|
||||||
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
|
import { BAND_COLOR, BAND_LABEL, facts, isEdible, present } from './healthScore';
|
||||||
import './pages/deliveries.css';
|
import './pages/deliveries.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +26,40 @@ import './pages/deliveries.css';
|
|||||||
* confident one — see the caveat, which is shown whenever the service's own
|
* confident one — see the caveat, which is shown whenever the service's own
|
||||||
* match confidence is below 70%.
|
* match confidence is below 70%.
|
||||||
*/
|
*/
|
||||||
export function HealthScorePanel({ product }: { product: Product }) {
|
export function HealthScorePanel({
|
||||||
|
product,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
product: Product;
|
||||||
|
/**
|
||||||
|
* The product's category, used to decide whether a nutrition score means
|
||||||
|
* anything for it at all. Passed in because the two callers hold it in
|
||||||
|
* different fields — a tenant product has `categoryname`, a catalogue row has
|
||||||
|
* `category` — and neither is on the narrow shape this panel receives.
|
||||||
|
*/
|
||||||
|
category?: string | null;
|
||||||
|
}) {
|
||||||
const brand = (product.productbrand ?? '').trim();
|
const brand = (product.productbrand ?? '').trim();
|
||||||
const imageId = (product.imageid ?? '').trim();
|
const imageId = (product.imageid ?? '').trim();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Soap does not have a health score, and the service will give you one anyway.
|
||||||
|
|
||||||
|
Measured again 7 Sep 2026: `GET /nutrition/Godrej/godrej_hit_spray_1101d017`
|
||||||
|
returns `health_score: 80`, `data_status: "verified"` — an "excellent" score
|
||||||
|
for insecticide. Palmolive and Pantene score 37.5 through the same route.
|
||||||
|
|
||||||
|
The catalogue team's own guidance is to hide the field entirely for non-food
|
||||||
|
rather than show an empty slot, and their per-product endpoint still does not
|
||||||
|
publish the edibility label that gates their LIST endpoint — `data_status` is
|
||||||
|
"unavailable" for both a soap and an unscored food, and `category` is
|
||||||
|
populated for both, so nothing in the response separates them. Our own
|
||||||
|
category does, so the decision is made here until they publish theirs.
|
||||||
|
|
||||||
|
Before the network call, deliberately: there is nothing to ask about.
|
||||||
|
*/
|
||||||
|
if (!isEdible(category)) return null;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['nutrition', brand, imageId],
|
queryKey: ['nutrition', brand, imageId],
|
||||||
queryFn: () => nutritionApi.forProduct(brand, imageId),
|
queryFn: () => nutritionApi.forProduct(brand, imageId),
|
||||||
@@ -89,8 +119,9 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
|||||||
return (
|
return (
|
||||||
<Section title="Health score">
|
<Section title="Health score">
|
||||||
<Note>
|
<Note>
|
||||||
This product is in the catalogue but has not been scored yet. It will appear here, and in
|
Nutrition information not available for this product. The catalogue service has already
|
||||||
the app, once it has.
|
searched its nutrition databases and found nothing usable, so this gap closes only by
|
||||||
|
entering the per-100g figures by hand — not by waiting.
|
||||||
</Note>
|
</Note>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -172,7 +172,10 @@ export function ProductDrawer({
|
|||||||
{/* ── Health score ──────────────────────────────────────────────────
|
{/* ── Health score ──────────────────────────────────────────────────
|
||||||
Under Sizes and above the shop detail: it is what a SHOPPER reads,
|
Under Sizes and above the shop detail: it is what a SHOPPER reads,
|
||||||
and it belongs with the other shopper-facing facts. */}
|
and it belongs with the other shopper-facing facts. */}
|
||||||
<HealthScorePanel product={product} />
|
<HealthScorePanel
|
||||||
|
product={product}
|
||||||
|
category={product.categoryname || product.Subcategoryname}
|
||||||
|
/>
|
||||||
|
|
||||||
{product.productdesc ? (
|
{product.productdesc ? (
|
||||||
<Section title="Description">
|
<Section title="Description">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
|
|||||||
import { AssistantScope } from '@/features/console/AssistantScope';
|
import { AssistantScope } from '@/features/console/AssistantScope';
|
||||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||||
|
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store Admin — the merchant workspace.
|
* Store Admin — the merchant workspace.
|
||||||
@@ -80,6 +81,7 @@ const MANAGE: readonly MenuEntry[] = [
|
|||||||
|
|
||||||
export function StoreAdminShell() {
|
export function StoreAdminShell() {
|
||||||
return (
|
return (
|
||||||
|
<DateScopeProvider>
|
||||||
<BranchScopeProvider>
|
<BranchScopeProvider>
|
||||||
<LiveWatch />
|
<LiveWatch />
|
||||||
<OnboardingGate />
|
<OnboardingGate />
|
||||||
@@ -93,6 +95,7 @@ export function StoreAdminShell() {
|
|||||||
/>
|
/>
|
||||||
</AssistantScope>
|
</AssistantScope>
|
||||||
</BranchScopeProvider>
|
</BranchScopeProvider>
|
||||||
|
</DateScopeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
groupByStore,
|
groupByStore,
|
||||||
isToday,
|
isToday,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
shiftDay,
|
|
||||||
stopKey,
|
stopKey,
|
||||||
stopValue,
|
stopValue,
|
||||||
toStops,
|
toStops,
|
||||||
@@ -377,14 +376,6 @@ test('the day is worth the work not yet sent out, too', () => {
|
|||||||
|
|
||||||
/* ── The day ─────────────────────────────────────────────────────────────── */
|
/* ── The day ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
test('stepping a day stays in local time', () => {
|
|
||||||
// Parsing the string would shift the whole board by a day in some zones.
|
|
||||||
assert.equal(shiftDay('2026-09-05', -1), '2026-09-04');
|
|
||||||
assert.equal(shiftDay('2026-09-05', 1), '2026-09-06');
|
|
||||||
assert.equal(shiftDay('2026-01-01', -1), '2025-12-31', 'across a year boundary');
|
|
||||||
assert.equal(shiftDay('2026-03-01', -1), '2026-02-28', 'across a month boundary');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('today is today', () => {
|
test('today is today', () => {
|
||||||
assert.equal(isToday(ymd(new Date())), true);
|
assert.equal(isToday(ymd(new Date())), true);
|
||||||
assert.equal(isToday('2020-01-01'), false);
|
assert.equal(isToday('2020-01-01'), false);
|
||||||
|
|||||||
@@ -421,21 +421,12 @@ export function dayTotals(stops: readonly Stop[]): DayTotals {
|
|||||||
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
|
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One day as Fiesta wants it, and the stepper's arithmetic. */
|
/** One day as Fiesta wants it. */
|
||||||
export function ymd(date: Date): string {
|
export function ymd(date: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shiftDay(day: string, by: number): string {
|
|
||||||
const [y, m, d] = day.split('-').map(Number);
|
|
||||||
// Local midnight, then step. Constructing from the parts rather than parsing
|
|
||||||
// the string avoids the UTC shift that would move the whole board a day.
|
|
||||||
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
|
|
||||||
date.setDate(date.getDate() + by);
|
|
||||||
return ymd(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isToday(day: string): boolean {
|
export function isToday(day: string): boolean {
|
||||||
return day === ymd(new Date());
|
return day === ymd(new Date());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,3 +254,52 @@ test('a declared allergen is shown regardless of confidence', () => {
|
|||||||
assert.deepEqual(shown.allergens, ['Gluten']);
|
assert.deepEqual(shown.allergens, ['Gluten']);
|
||||||
assert.equal(shown.allergensUnconfirmed, false);
|
assert.equal(shown.allergensUnconfirmed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── Edibility, wired in 7 Sep 2026 ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('a health score is never shown for something you do not eat', () => {
|
||||||
|
/*
|
||||||
|
The service will happily score insecticide. Measured 7 Sep 2026:
|
||||||
|
GET /nutrition/Godrej/godrej_hit_spray_1101d017 returns health_score 80,
|
||||||
|
data_status "verified" — an "excellent" score for HIT spray. Palmolive soap
|
||||||
|
and Pantene shampoo both score 37.5 by the same route.
|
||||||
|
|
||||||
|
Their per-product endpoint does not publish the edibility label that gates
|
||||||
|
their list endpoint, so the decision has to be made from our own category.
|
||||||
|
*/
|
||||||
|
assert.equal(isEdible('Home Care'), false);
|
||||||
|
assert.equal(isEdible('Personal Care'), false);
|
||||||
|
assert.equal(isEdible('Detergents'), false);
|
||||||
|
assert.equal(isEdible('Oral Care'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"General" is not good enough to call something food', () => {
|
||||||
|
// It carries Palmolive soap and P&G alongside anything the scraper could not
|
||||||
|
// place. Ambiguous is not a basis for showing a nutrition score.
|
||||||
|
assert.equal(isEdible('General'), false);
|
||||||
|
assert.equal(isEdible('general'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the real catalogue food categories are recognised', () => {
|
||||||
|
// Taken from live rows, not invented — these are what the drawer receives.
|
||||||
|
for (const category of [
|
||||||
|
'Spices & Masalas',
|
||||||
|
'Pulses, Grains & Spices',
|
||||||
|
'Health Drinks',
|
||||||
|
'Dairy',
|
||||||
|
'Snacks & Namkeen',
|
||||||
|
'Edible Oils',
|
||||||
|
'Biscuits & Cookies',
|
||||||
|
'Beverages',
|
||||||
|
]) {
|
||||||
|
assert.equal(isEdible(category), true, category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing category is treated as not food', () => {
|
||||||
|
// Hiding a panel on a food product costs one line of "not available".
|
||||||
|
// Showing one on a bottle of insecticide costs rather more.
|
||||||
|
assert.equal(isEdible(undefined), false);
|
||||||
|
assert.equal(isEdible(null), false);
|
||||||
|
assert.equal(isEdible(' '), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
useStockRequests,
|
useStockRequests,
|
||||||
} from '@/queries/hooks';
|
} from '@/queries/hooks';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
||||||
import { branchLabel, count, money, percent, share } from '../format';
|
import { branchLabel, count, money, percent, share } from '../format';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
@@ -52,7 +51,7 @@ import {
|
|||||||
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
||||||
*/
|
*/
|
||||||
export function ConsolePage() {
|
export function ConsolePage() {
|
||||||
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
|
const { scoped, selected, tenantid, isLoading, isPinned } = useBranchScope();
|
||||||
/**
|
/**
|
||||||
* Where this page's links point.
|
* Where this page's links point.
|
||||||
*
|
*
|
||||||
@@ -62,13 +61,12 @@ export function ConsolePage() {
|
|||||||
* does nothing.
|
* does nothing.
|
||||||
*/
|
*/
|
||||||
const base = isPinned ? '/store' : '/admin';
|
const base = isPinned ? '/store' : '/admin';
|
||||||
const [preset, setPreset] = useState<RangePreset>('today');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
|
||||||
|
|
||||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||||
|
|
||||||
const orders = useLocationSummary(tenantid || undefined);
|
const orders = useLocationSummary(tenantid || undefined);
|
||||||
const posSales = usePosSalesByBranch(branchIds, range);
|
const posSales = usePosSalesByBranch(branchIds, dates.range);
|
||||||
const posHealth = usePosHealthByBranch(branchIds);
|
const posHealth = usePosHealthByBranch(branchIds);
|
||||||
|
|
||||||
const requests = useStockRequests(
|
const requests = useStockRequests(
|
||||||
@@ -134,23 +132,6 @@ export function ConsolePage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Console"
|
title="Console"
|
||||||
count={
|
|
||||||
selected === null
|
|
||||||
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
|
|
||||||
: (current?.locationname ?? undefined)
|
|
||||||
}
|
|
||||||
description="Everything trading right now — by channel, by branch, and what is waiting on you."
|
|
||||||
isLive
|
|
||||||
actions={
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
||||||
import { useCounterLabels } from '../counterLabels';
|
import { useCounterLabels } from '../counterLabels';
|
||||||
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
||||||
import { branchLabel, count, money } from '../format';
|
import { branchLabel, count, money } from '../format';
|
||||||
@@ -49,9 +48,8 @@ const STATE_TONE: Record<CounterState, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function CountersPage() {
|
export function CountersPage() {
|
||||||
const { branches, scoped, selected, current, isPinned } = useBranchScope();
|
const { branches, scoped, isPinned } = useBranchScope();
|
||||||
const [preset, setPreset] = useState<RangePreset>('today');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
|
||||||
const [chip, setChip] = useState<CounterState | 'all'>('all');
|
const [chip, setChip] = useState<CounterState | 'all'>('all');
|
||||||
const [branchId, setBranchId] = useState<number | null>(null);
|
const [branchId, setBranchId] = useState<number | null>(null);
|
||||||
const [term, setTerm] = useState('');
|
const [term, setTerm] = useState('');
|
||||||
@@ -59,7 +57,7 @@ export function CountersPage() {
|
|||||||
const [open, setOpen] = useState<CounterRow | null>(null);
|
const [open, setOpen] = useState<CounterRow | null>(null);
|
||||||
|
|
||||||
const labels = useCounterLabels();
|
const labels = useCounterLabels();
|
||||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
|
||||||
|
|
||||||
const rows = useMemo(() => rowsOf(board), [board]);
|
const rows = useMemo(() => rowsOf(board), [board]);
|
||||||
const counts = useMemo(() => countsOf(rows), [rows]);
|
const counts = useMemo(() => countsOf(rows), [rows]);
|
||||||
@@ -81,31 +79,7 @@ export function CountersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="console">
|
<div className="console">
|
||||||
<header className="console-head">
|
<h1 className="sr-only">Counters</h1>
|
||||||
<div className="console-title">
|
|
||||||
<div className="console-title-row">
|
|
||||||
<h1>Counters</h1>
|
|
||||||
<span className="console-scope">
|
|
||||||
{selected === null
|
|
||||||
? `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`
|
|
||||||
: branchLabel(current?.locationname) || 'This branch'}
|
|
||||||
</span>
|
|
||||||
<span className="console-live"><i />Live</span>
|
|
||||||
</div>
|
|
||||||
<p className="console-blurb">Monitor your counters, activity and connection status.</p>
|
|
||||||
</div>
|
|
||||||
<div className="console-actions">
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="kpi-strip">
|
<div className="kpi-strip">
|
||||||
<Kpi
|
<Kpi
|
||||||
icon={<Monitor size={17} />}
|
icon={<Monitor size={17} />}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useMemo, useState, type ReactNode } from 'react';
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import {
|
import {
|
||||||
Bike,
|
Bike,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
IndianRupee,
|
IndianRupee,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
|
|||||||
import { AssignBar } from '../AssignBar';
|
import { AssignBar } from '../AssignBar';
|
||||||
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
|
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
|
||||||
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
|
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { count, money, moneyExact } from '../format';
|
import { count, money, moneyExact } from '../format';
|
||||||
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
|
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
|
||||||
@@ -37,7 +35,6 @@ import {
|
|||||||
groupByStore,
|
groupByStore,
|
||||||
isToday,
|
isToday,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
shiftDay,
|
|
||||||
stopAddress,
|
stopAddress,
|
||||||
stopCustomer,
|
stopCustomer,
|
||||||
stopKey,
|
stopKey,
|
||||||
@@ -48,7 +45,6 @@ import {
|
|||||||
toStops,
|
toStops,
|
||||||
UNASSIGNED,
|
UNASSIGNED,
|
||||||
WAITING,
|
WAITING,
|
||||||
ymd,
|
|
||||||
type Group,
|
type Group,
|
||||||
type Stop,
|
type Stop,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
@@ -56,10 +52,12 @@ import {
|
|||||||
import './dispatch.css';
|
import './dispatch.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch — one day's deliveries, read three ways.
|
* Dispatch — the day's deliveries, read three ways.
|
||||||
*
|
*
|
||||||
* A day at a time, because that is how a round is worked: everything here is
|
* Scoped by the console's shared date filter in the top bar, like every other
|
||||||
* scoped to a single date, not a range. No batch windows — order placement in
|
* page. A single day is the shape this was built for — that is how a round is
|
||||||
|
* worked — and the labels adapt when a wider range is chosen rather than
|
||||||
|
* calling a week of a rider's stops a "round". No batch windows — order placement in
|
||||||
* this tenant clusters so hard into one hour that a morning/afternoon/evening
|
* this tenant clusters so hard into one hour that a morning/afternoon/evening
|
||||||
* split would put ninety per cent in one bucket and call it navigation.
|
* split would put ninety per cent in one bucket and call it navigation.
|
||||||
*
|
*
|
||||||
@@ -92,14 +90,14 @@ import './dispatch.css';
|
|||||||
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
||||||
|
|
||||||
export function DispatchPage() {
|
export function DispatchPage() {
|
||||||
const { branches, selected, current, tenantid } = useBranchScope();
|
const { branches, selected, tenantid } = useBranchScope();
|
||||||
const [day, setDay] = useDayParam();
|
const dates = useDateScope();
|
||||||
const [mode, setMode] = useState<ViewMode>('riders');
|
const [mode, setMode] = useState<ViewMode>('riders');
|
||||||
const [focused, setFocused] = useState<string | null>(null);
|
const [focused, setFocused] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<Stop | null>(null);
|
const [detail, setDetail] = useState<Stop | null>(null);
|
||||||
|
|
||||||
const query = tenantid
|
const query = tenantid
|
||||||
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
|
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const deliveries = useDeliveries(query);
|
const deliveries = useDeliveries(query);
|
||||||
@@ -121,6 +119,16 @@ export function DispatchPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A one-day range is still the shape this board was built for, so it keeps the
|
||||||
|
wording it had: "Rounds", "on this day". A wider range is a legitimate thing
|
||||||
|
to ask for and the groupings all still hold — but a rider's group is then
|
||||||
|
several rounds run together, and the labels say so rather than pretending.
|
||||||
|
*/
|
||||||
|
const isOneDay = Boolean(
|
||||||
|
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
|
||||||
|
);
|
||||||
|
|
||||||
const stops = useMemo(
|
const stops = useMemo(
|
||||||
() => toStops(deliveries.data ?? [], orders.data ?? []),
|
() => toStops(deliveries.data ?? [], orders.data ?? []),
|
||||||
[deliveries.data, orders.data],
|
[deliveries.data, orders.data],
|
||||||
@@ -194,12 +202,6 @@ export function DispatchPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dispatch"
|
title="Dispatch"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
description="One day of deliveries — by rider, by shop, or by customer."
|
|
||||||
isLive
|
|
||||||
actions={
|
|
||||||
<DayStepper day={day} onChange={setDay} />
|
|
||||||
}
|
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<ModeTab
|
<ModeTab
|
||||||
@@ -230,7 +232,7 @@ export function DispatchPage() {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
label="Stops"
|
label="Stops"
|
||||||
value={count(totals.stops)}
|
value={count(totals.stops)}
|
||||||
note={isToday(day) ? 'today' : 'on this day'}
|
note={isOneDay ? (isToday(dates.range.fromdate ?? '') ? 'today' : 'on this day') : 'in this range'}
|
||||||
tone="accent"
|
tone="accent"
|
||||||
icon={<Package size={15} />}
|
icon={<Package size={15} />}
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +262,13 @@ export function DispatchPage() {
|
|||||||
<div className="dispatch-body">
|
<div className="dispatch-body">
|
||||||
<div className="dispatch-rail">
|
<div className="dispatch-rail">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<Text type="label" size="xsm" color="secondary">
|
||||||
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
|
{mode === 'riders'
|
||||||
|
? isOneDay
|
||||||
|
? 'Rounds'
|
||||||
|
: 'Riders'
|
||||||
|
: mode === 'stores'
|
||||||
|
? 'Shops'
|
||||||
|
: 'Customers'}
|
||||||
</Text>
|
</Text>
|
||||||
<GroupList
|
<GroupList
|
||||||
groups={groups}
|
groups={groups}
|
||||||
@@ -301,7 +309,11 @@ export function DispatchPage() {
|
|||||||
<MapPin size={22} />
|
<MapPin size={22} />
|
||||||
</span>
|
</span>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
{groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
|
{groups.length > 0
|
||||||
|
? 'Pick one to see its stops'
|
||||||
|
: isOneDay
|
||||||
|
? 'Nothing out on this day'
|
||||||
|
: 'Nothing out in this range'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
type="body"
|
type="body"
|
||||||
@@ -326,98 +338,6 @@ export function DispatchPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── The day ─────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which day is on screen, held in the URL.
|
|
||||||
*
|
|
||||||
* The same reasoning `BranchScope` gives for the branch: "Dispatch, last
|
|
||||||
* Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
|
|
||||||
* not silently throw the operator back to today while they are reading a round
|
|
||||||
* that belongs to another date.
|
|
||||||
*
|
|
||||||
* A malformed or future value falls back to today rather than 400ing the
|
|
||||||
* delivery read — the param is user-editable, so it is untrusted input.
|
|
||||||
*/
|
|
||||||
function useDayParam(): [string, (next: string) => void] {
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const today = ymd(new Date());
|
|
||||||
const raw = params.get('day') ?? '';
|
|
||||||
const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
|
|
||||||
|
|
||||||
const set = (next: string) => {
|
|
||||||
const clamped = next > today ? today : next;
|
|
||||||
const merged = new URLSearchParams(params);
|
|
||||||
// Today is the default, so it is left OUT of the URL: the common case gets
|
|
||||||
// a clean address, and only a deliberate look back carries a param.
|
|
||||||
if (clamped === today) merged.delete('day');
|
|
||||||
else merged.set('day', clamped);
|
|
||||||
setParams(merged, { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return [day, set];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A day at a time.
|
|
||||||
*
|
|
||||||
* Not a range picker: a round belongs to a date, and the arrows are how an
|
|
||||||
* operator checks yesterday without thinking about a calendar. Forward is
|
|
||||||
* disabled on today — dispatch has nothing to say about tomorrow.
|
|
||||||
*/
|
|
||||||
function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
|
|
||||||
const today = isToday(day);
|
|
||||||
const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
|
||||||
weekday: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="day-stepper">
|
|
||||||
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
|
|
||||||
<ChevronLeft size={15} />
|
|
||||||
</button>
|
|
||||||
{/*
|
|
||||||
The label doubles as the picker. Arrows answer "yesterday", which is
|
|
||||||
most of the traffic; a date a month back would otherwise be thirty
|
|
||||||
clicks. The input sits transparent over the label rather than replacing
|
|
||||||
it, so the day still reads as "Sat, Sep 5" instead of as a form field,
|
|
||||||
and clicking it opens the browser's own calendar — a real control,
|
|
||||||
keyboard-reachable, with no picker to write or to get wrong.
|
|
||||||
*/}
|
|
||||||
<span className="day-label">
|
|
||||||
<span aria-hidden>{label}</span>
|
|
||||||
{today ? <em aria-hidden>today</em> : null}
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="Pick a day"
|
|
||||||
value={day}
|
|
||||||
max={ymd(new Date())}
|
|
||||||
onChange={(event) => {
|
|
||||||
// Clearing the field yields '' — keep the day rather than blanking
|
|
||||||
// the board.
|
|
||||||
if (event.target.value) onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Next day"
|
|
||||||
disabled={today}
|
|
||||||
onClick={() => onChange(shiftDay(day, 1))}
|
|
||||||
>
|
|
||||||
<ChevronRight size={15} />
|
|
||||||
</button>
|
|
||||||
{!today ? (
|
|
||||||
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function GroupList({
|
function GroupList({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
|||||||
* backend catch up. Nothing on screen claims the selection is in force.
|
* backend catch up. Nothing on screen claims the selection is in force.
|
||||||
*/
|
*/
|
||||||
export function InventoryPage() {
|
export function InventoryPage() {
|
||||||
const { branches, selected, current, tenantid } = useBranchScope();
|
const { selected, tenantid } = useBranchScope();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tab comes from the URL, so a link can name one.
|
* The tab comes from the URL, so a link can name one.
|
||||||
@@ -112,9 +112,8 @@ export function InventoryPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
isTabsInline
|
||||||
title="Inventory"
|
title="Inventory"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
|
||||||
/* Upload sheet lives here, not in the tab row, so its position is
|
/* Upload sheet lives here, not in the tab row, so its position is
|
||||||
fixed — it no longer appears and disappears as the tab changes, and
|
fixed — it no longer appears and disappears as the tab changes, and
|
||||||
nothing below shifts sideways when it does.
|
nothing below shifts sideways when it does.
|
||||||
@@ -129,7 +128,7 @@ export function InventoryPage() {
|
|||||||
stated twice on one screen invites the reader to check whether the
|
stated twice on one screen invites the reader to check whether the
|
||||||
two agree. */
|
two agree. */
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={0.5} align="center" wrap="wrap">
|
<HStack gap={1} align="center" wrap="wrap">
|
||||||
<BarAction
|
<BarAction
|
||||||
label="Upload sheet"
|
label="Upload sheet"
|
||||||
icon={<UploadCloud size={14} />}
|
icon={<UploadCloud size={14} />}
|
||||||
@@ -181,7 +180,10 @@ export function InventoryPage() {
|
|||||||
) : tab === 'catalogue' ? (
|
) : tab === 'catalogue' ? (
|
||||||
<CataloguePanel />
|
<CataloguePanel />
|
||||||
) : tab === 'requests' ? (
|
) : tab === 'requests' ? (
|
||||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
<RequestsPanel
|
||||||
|
requests={requests.data ?? []}
|
||||||
|
isLoading={requests.isLoading}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StockPanel />
|
<StockPanel />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function OnboardBranchPage() {
|
|||||||
if (mutation.isSuccess) {
|
if (mutation.isSuccess) {
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
|
<PageHeader title="Branch commissioned" />
|
||||||
<Card padding={4} elevation="low">
|
<Card padding={4} elevation="low">
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack align="center" gap={1.5}>
|
<HStack align="center" gap={1.5}>
|
||||||
@@ -220,7 +220,6 @@ export function OnboardBranchPage() {
|
|||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Onboard branch"
|
title="Onboard branch"
|
||||||
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
@@ -19,13 +20,11 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
||||||
import { branchLabel, count, money, percent, share } from '../format';
|
import { branchLabel, count, money, percent, share } from '../format';
|
||||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
@@ -42,14 +41,13 @@ type Tab = 'revenue' | 'sales' | 'inventory';
|
|||||||
* `posorders` would not be.
|
* `posorders` would not be.
|
||||||
*/
|
*/
|
||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
const { scoped, tenantid } = useBranchScope();
|
||||||
const [tab, setTab] = useState<Tab>('revenue');
|
const [tab, setTab] = useState<Tab>('revenue');
|
||||||
const [preset, setPreset] = useState<RangePreset>('month');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
|
||||||
|
|
||||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||||
const orders = useLocationSummary(tenantid || undefined);
|
const orders = useLocationSummary(tenantid || undefined);
|
||||||
const posSales = usePosSalesByBranch(branchIds, range);
|
const posSales = usePosSalesByBranch(branchIds, dates.range);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -101,18 +99,6 @@ export function ReportsPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
description="Revenue and stock over a period you choose, compared where a comparison is sound."
|
|
||||||
actions={
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
|
||||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
usePosSalesByBranch,
|
usePosSalesByBranch,
|
||||||
} from '@/queries/hooks';
|
} from '@/queries/hooks';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
||||||
import { branchLabel, count, money, moneyExact } from '../format';
|
import { branchLabel, count, money, moneyExact } from '../format';
|
||||||
import {
|
import {
|
||||||
DELIVERY_STATUS,
|
DELIVERY_STATUS,
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
syncLagMs,
|
syncLagMs,
|
||||||
type StatusKey,
|
type StatusKey,
|
||||||
} from '../orderStatus';
|
} from '../orderStatus';
|
||||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
|
||||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||||
import { shortAge } from '../posStatus';
|
import { shortAge } from '../posStatus';
|
||||||
import { AssignBar } from '../AssignBar';
|
import { AssignBar } from '../AssignBar';
|
||||||
@@ -60,17 +58,15 @@ type Tab = 'orders' | 'deliveries' | 'counter';
|
|||||||
* every row would be a column of dashes.
|
* every row would be a column of dashes.
|
||||||
*/
|
*/
|
||||||
export function SalesPage() {
|
export function SalesPage() {
|
||||||
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
|
const { scoped, selected, branches, tenantid } = useBranchScope();
|
||||||
const [isUploading, setUploading] = useState(false);
|
|
||||||
const [tab, setTab] = useState<Tab>('orders');
|
const [tab, setTab] = useState<Tab>('orders');
|
||||||
const [status, setStatus] = useState<StatusKey>('all');
|
const [status, setStatus] = useState<StatusKey>('all');
|
||||||
const [preset, setPreset] = useState<RangePreset>('month');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
/* What the tables are currently showing. Any change to it sends every pager
|
/* What the tables are currently showing. Any change to it sends every pager
|
||||||
back to page 1 — landing on page 4 of a search you just typed reads as an
|
back to page 1 — landing on page 4 of a search you just typed reads as an
|
||||||
empty result. */
|
empty result. */
|
||||||
const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`;
|
const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`;
|
||||||
|
|
||||||
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
|
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
|
||||||
null,
|
null,
|
||||||
@@ -86,15 +82,15 @@ export function SalesPage() {
|
|||||||
tenantid,
|
tenantid,
|
||||||
locationid: selected ?? undefined,
|
locationid: selected ?? undefined,
|
||||||
keyword: keyword.trim() || undefined,
|
keyword: keyword.trim() || undefined,
|
||||||
...range,
|
...dates.range,
|
||||||
pagesize: 200,
|
pagesize: 200,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const orders = useOrders(query);
|
const orders = useOrders(query);
|
||||||
const deliveries = useDeliveries(query);
|
const deliveries = useDeliveries(query);
|
||||||
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 });
|
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
|
||||||
const posSummary = usePosSalesByBranch(branchIds, range);
|
const posSummary = usePosSalesByBranch(branchIds, dates.range);
|
||||||
|
|
||||||
const allOrders = orders.data ?? [];
|
const allOrders = orders.data ?? [];
|
||||||
const allDeliveries = deliveries.data ?? [];
|
const allDeliveries = deliveries.data ?? [];
|
||||||
@@ -218,30 +214,6 @@ export function SalesPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Sales"
|
title="Sales"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
|
|
||||||
isLive
|
|
||||||
actions={
|
|
||||||
<HStack gap={0.5} align="center" wrap="wrap">
|
|
||||||
{/* Counter sales that never touched a till. Sits beside the date
|
|
||||||
range rather than under the Counter tab because it is how the
|
|
||||||
figures on every tab get there for a shop with no POS. */}
|
|
||||||
<BarAction
|
|
||||||
label="Upload counter sales"
|
|
||||||
icon={<Upload size={14} />}
|
|
||||||
isDisabled={!tenantid}
|
|
||||||
onClick={() => setUploading(true)}
|
|
||||||
/>
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
@@ -482,15 +454,6 @@ export function SalesPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isUploading && tenantid ? (
|
|
||||||
<CounterSalesDrawer
|
|
||||||
tenantid={tenantid}
|
|
||||||
{...(isPinned && current ? { locationid: current.locationid } : {})}
|
|
||||||
branchIds={branches.map((branch) => branch.locationid)}
|
|
||||||
onClose={() => setUploading(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
|
|
||||||
{/* The row is re-read from the live list on every render rather than
|
{/* The row is re-read from the live list on every render rather than
|
||||||
used as captured, because the drawer can now CHANGE the row it is
|
used as captured, because the drawer can now CHANGE the row it is
|
||||||
@@ -1427,48 +1390,3 @@ function Td({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A header action drawn like a tab, so the two rows read as one bar. Same shape
|
|
||||||
* as Inventory's — copied rather than shared because the two pages own their
|
|
||||||
* chrome, and a shared one would have to take a theme prop to stay identical.
|
|
||||||
*/
|
|
||||||
function BarAction({
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
isDisabled,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
}) {
|
|
||||||
const [isHovered, setHovered] = useState(false);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
height: 32,
|
|
||||||
padding: '0 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: 0,
|
|
||||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
|
||||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
||||||
transition: 'background .2s, color .2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ export function ShopProfilePage() {
|
|||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Shop profile"
|
title="Shop profile"
|
||||||
description="What shoppers see about your business, and how to reach you."
|
|
||||||
{...(gaps.length > 0
|
{...(gaps.length > 0
|
||||||
? { count: `${gaps.length} still to fill in` }
|
? { count: `${gaps.length} still to fill in` }
|
||||||
: {})}
|
: {})}
|
||||||
@@ -175,7 +174,6 @@ export function ShopProfilePage() {
|
|||||||
value={form['tenantimage'] ?? ''}
|
value={form['tenantimage'] ?? ''}
|
||||||
onChange={set('tenantimage')}
|
onChange={set('tenantimage')}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
description="The picture shoppers see beside your shop in the app."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -190,7 +188,6 @@ export function ShopProfilePage() {
|
|||||||
label="FSSAI / trade licence"
|
label="FSSAI / trade licence"
|
||||||
value={form['licenseno'] ?? ''}
|
value={form['licenseno'] ?? ''}
|
||||||
onChange={set('licenseno')}
|
onChange={set('licenseno')}
|
||||||
description="Shown to shoppers. Required to display for a food business."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
@@ -16,12 +17,10 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Send,
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
||||||
import { branchLabel } from '../format';
|
import { branchLabel } from '../format';
|
||||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||||
@@ -48,14 +47,13 @@ import { usePaged } from '@/components/usePaged';
|
|||||||
* correct output, and it is the one the old design could not produce.
|
* correct output, and it is the one the old design could not produce.
|
||||||
*/
|
*/
|
||||||
export function TerminalsPage() {
|
export function TerminalsPage() {
|
||||||
const { branches, scoped, selected, current } = useBranchScope();
|
const { scoped } = useBranchScope();
|
||||||
const [preset, setPreset] = useState<RangePreset>('today');
|
const dates = useDateScope();
|
||||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
|
||||||
const [showFine, setShowFine] = useState(false);
|
const [showFine, setShowFine] = useState(false);
|
||||||
const [showHidden, setShowHidden] = useState(false);
|
const [showHidden, setShowHidden] = useState(false);
|
||||||
const labels = useCounterLabels();
|
const labels = useCounterLabels();
|
||||||
|
|
||||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
|
||||||
|
|
||||||
const needs = board.now.length + board.look.length;
|
const needs = board.now.length + board.look.length;
|
||||||
/**
|
/**
|
||||||
@@ -79,19 +77,7 @@ export function TerminalsPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Counters"
|
title="Counters"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
{...(summary ? { description: summary } : {})}
|
{...(summary ? { description: summary } : {})}
|
||||||
isLive
|
|
||||||
actions={
|
|
||||||
<DateRangePicker
|
|
||||||
preset={preset}
|
|
||||||
range={range}
|
|
||||||
onChange={(nextPreset, nextRange) => {
|
|
||||||
setPreset(nextPreset);
|
|
||||||
setRange(nextRange);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* The console's opening move on every operations page — Console, Sales,
|
{/* The console's opening move on every operations page — Console, Sales,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
@@ -22,15 +23,26 @@ export function UploadsPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { selected } = useBranchScope();
|
const { selected } = useBranchScope();
|
||||||
const [isUploading, setUploading] = useState(false);
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
/*
|
||||||
|
No date filter by default, unlike the reporting pages.
|
||||||
|
|
||||||
|
This is a receipt log, and the reason somebody opens it is to find an upload
|
||||||
|
from a while ago. Starting at month-to-date hid every receipt older than the
|
||||||
|
1st — the list read "Nothing matches" while the tab beside it said there were
|
||||||
|
two. The read already returns only the most recent fifty, so the range is a
|
||||||
|
narrowing rather than the thing that makes the page tractable.
|
||||||
|
*/
|
||||||
|
const dates = useDateScope();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Uploads"
|
title="Uploads"
|
||||||
description="Spreadsheets sent to the catalogue service — what each one added, and whether it reached a shelf."
|
|
||||||
/>
|
/>
|
||||||
<UploadsPanel
|
<UploadsPanel
|
||||||
tenantid={user?.tenantid ?? 0}
|
tenantid={user?.tenantid ?? 0}
|
||||||
|
range={dates.range}
|
||||||
|
onClearRange={dates.clear}
|
||||||
onUpload={() => setUploading(true)}
|
onUpload={() => setUploading(true)}
|
||||||
{...(selected === null ? {} : { locationid: selected })}
|
{...(selected === null ? {} : { locationid: selected })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -140,9 +140,8 @@ export function UsersPage() {
|
|||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
isTabsInline
|
||||||
title="Users & access"
|
title="Users & access"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
|
||||||
description="Who can sign in to the console, and who can open a till."
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTenantLocations } from '@/queries/hooks';
|
|||||||
import { AssistantScope } from '@/features/console/AssistantScope';
|
import { AssistantScope } from '@/features/console/AssistantScope';
|
||||||
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||||
|
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store user — one branch, and only that branch.
|
* Store user — one branch, and only that branch.
|
||||||
@@ -112,6 +113,7 @@ export function StoreUserShell() {
|
|||||||
if (locationid === null) return <NoStore />;
|
if (locationid === null) return <NoStore />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DateScopeProvider>
|
||||||
<BranchScopeProvider pin={locationid}>
|
<BranchScopeProvider pin={locationid}>
|
||||||
<LiveWatch />
|
<LiveWatch />
|
||||||
<AssistantScope>
|
<AssistantScope>
|
||||||
@@ -131,6 +133,7 @@ export function StoreUserShell() {
|
|||||||
</AssistantScope>
|
</AssistantScope>
|
||||||
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
|
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
|
||||||
</BranchScopeProvider>
|
</BranchScopeProvider>
|
||||||
|
</DateScopeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ export function StoreAccountPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="My account"
|
title="My account"
|
||||||
count={current?.locationname}
|
|
||||||
description="Who you are signed in as, and the shop this account is tied to."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export function StoreCustomersPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Customers"
|
title="Customers"
|
||||||
count={current?.locationname}
|
|
||||||
description="Everyone registered against this shop. Read-only — accounts are managed in the customer app."
|
|
||||||
actions={
|
actions={
|
||||||
<div style={{ width: 230 }}>
|
<div style={{ width: 230 }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -143,8 +143,6 @@ export function StoreProductsPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Products"
|
title="Products"
|
||||||
count={current?.locationname}
|
|
||||||
description="What your shop can sell, what is on the shelf, and what you have asked head office for."
|
|
||||||
actions={
|
actions={
|
||||||
tab === 'requests' ? null : (
|
tab === 'requests' ? null : (
|
||||||
<div style={{ width: 230 }}>
|
<div style={{ width: 230 }}>
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ export function StoreStaffPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Counter staff"
|
title="Counter staff"
|
||||||
count={current?.locationname}
|
|
||||||
description="Who can open the till at this counter. They sign in at the terminal, not here."
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
label="Add cashier"
|
label="Add cashier"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
@@ -19,16 +20,27 @@ export function StoreUploadsPage() {
|
|||||||
const { selected } = useBranchScope();
|
const { selected } = useBranchScope();
|
||||||
const locationid = selected ?? user?.locationid ?? 0;
|
const locationid = selected ?? user?.locationid ?? 0;
|
||||||
const [isUploading, setUploading] = useState(false);
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
/*
|
||||||
|
No date filter by default, unlike the reporting pages.
|
||||||
|
|
||||||
|
This is a receipt log, and the reason somebody opens it is to find an upload
|
||||||
|
from a while ago. Starting at month-to-date hid every receipt older than the
|
||||||
|
1st — the list read "Nothing matches" while the tab beside it said there were
|
||||||
|
two. The read already returns only the most recent fifty, so the range is a
|
||||||
|
narrowing rather than the thing that makes the page tractable.
|
||||||
|
*/
|
||||||
|
const dates = useDateScope();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Uploads"
|
title="Uploads"
|
||||||
description="Spreadsheets sent to the catalogue service from this branch — what each one added, and whether it reached the shelf."
|
|
||||||
/>
|
/>
|
||||||
<UploadsPanel
|
<UploadsPanel
|
||||||
tenantid={user?.tenantid ?? 0}
|
tenantid={user?.tenantid ?? 0}
|
||||||
locationid={locationid}
|
locationid={locationid}
|
||||||
|
range={dates.range}
|
||||||
|
onClearRange={dates.clear}
|
||||||
onUpload={() => setUploading(true)}
|
onUpload={() => setUploading(true)}
|
||||||
/>
|
/>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
type IngestProduct,
|
type IngestProduct,
|
||||||
} from '@/api/ingest';
|
} from '@/api/ingest';
|
||||||
import { uploadsApi, type UploadReceipt } from '@/api/uploads';
|
import { uploadsApi, type UploadReceipt } from '@/api/uploads';
|
||||||
|
import type { DateRange } from '@/api/insights';
|
||||||
import type { SheetProductRow } from '@/api/products';
|
import type { SheetProductRow } from '@/api/products';
|
||||||
import { readStoredRows } from './shelveRows';
|
import { readStoredRows } from './shelveRows';
|
||||||
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
||||||
@@ -75,6 +76,17 @@ export interface UploadsPanelProps {
|
|||||||
tenantid?: number;
|
tenantid?: number;
|
||||||
/** Narrow to one branch. A Store user is pinned to theirs. */
|
/** Narrow to one branch. A Store user is pinned to theirs. */
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
/**
|
||||||
|
* The date range, owned by the page header.
|
||||||
|
*
|
||||||
|
* Every page in the console filters by date from the same control in the same
|
||||||
|
* place; a second pair of date boxes inside one page's own toolbar meant this
|
||||||
|
* screen alone asked the question twice, in a different shape, a few pixels
|
||||||
|
* lower down.
|
||||||
|
*/
|
||||||
|
range?: DateRange;
|
||||||
|
/** Clears the header's date filter, offered by the empty state. */
|
||||||
|
onClearRange?: () => void;
|
||||||
/**
|
/**
|
||||||
* Opens this workspace's upload flow.
|
* Opens this workspace's upload flow.
|
||||||
*
|
*
|
||||||
@@ -92,15 +104,21 @@ interface LiveReading {
|
|||||||
products: IngestProduct[];
|
products: IngestProduct[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelProps) {
|
export function UploadsPanel({
|
||||||
|
tenantid,
|
||||||
|
locationid,
|
||||||
|
range,
|
||||||
|
onClearRange,
|
||||||
|
onUpload,
|
||||||
|
}: UploadsPanelProps) {
|
||||||
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
|
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [live, setLive] = useState<Record<string, LiveReading>>({});
|
const [live, setLive] = useState<Record<string, LiveReading>>({});
|
||||||
const [detail, setDetail] = useState<string | null>(null);
|
const [detail, setDetail] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<UploadTab>('all');
|
const [tab, setTab] = useState<UploadTab>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [from, setFrom] = useState('');
|
const from = range?.fromdate ?? '';
|
||||||
const [to, setTo] = useState('');
|
const to = range?.todate ?? '';
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -195,19 +213,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
|
|||||||
});
|
});
|
||||||
}, [rows, live, tab, search, from, to]);
|
}, [rows, live, tab, search, from, to]);
|
||||||
|
|
||||||
/* Counts are of everything, not of what is on screen: a tab that reported the
|
/*
|
||||||
size of its own filtered view would always read the same as the list under
|
Counts ignore the TAB but respect every other filter.
|
||||||
it and could never tell you there is a failure worth looking at. */
|
|
||||||
|
Ignoring the tab is the point — a tab reporting the size of its own filtered
|
||||||
|
view would always equal the list under it and could never tell you there is a
|
||||||
|
failure worth looking at. Respecting the search and the date is equally the
|
||||||
|
point, and was missed: with a date range that excluded everything, the list
|
||||||
|
said "Nothing matches" while the tab beside it said two, and the two
|
||||||
|
contradicted each other on the same screen.
|
||||||
|
*/
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const out = { all: rows.length, done: 0, failed: 0, running: 0 };
|
const scoped = rows.filter(
|
||||||
for (const receipt of rows) {
|
(receipt) => matchesSearch(receipt, search) && matchesDate(receipt, from, to),
|
||||||
|
);
|
||||||
|
const out = { all: scoped.length, done: 0, failed: 0, running: 0 };
|
||||||
|
for (const receipt of scoped) {
|
||||||
const state = stateOf(receipt, live[receipt.batchid]?.batch);
|
const state = stateOf(receipt, live[receipt.batchid]?.batch);
|
||||||
if (matchesTab(state, 'done')) out.done += 1;
|
if (matchesTab(state, 'done')) out.done += 1;
|
||||||
if (matchesTab(state, 'failed')) out.failed += 1;
|
if (matchesTab(state, 'failed')) out.failed += 1;
|
||||||
if (matchesTab(state, 'running')) out.running += 1;
|
if (matchesTab(state, 'running')) out.running += 1;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [rows, live]);
|
}, [rows, live, search, from, to]);
|
||||||
|
|
||||||
const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
|
const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
|
||||||
const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` });
|
const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` });
|
||||||
@@ -284,22 +312,6 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
|
|||||||
aria-label="Search by file name"
|
aria-label="Search by file name"
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
className="up-date"
|
|
||||||
type="date"
|
|
||||||
value={from}
|
|
||||||
max={to || undefined}
|
|
||||||
aria-label="Uploaded on or after"
|
|
||||||
onChange={(event) => setFrom(event.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="up-date"
|
|
||||||
type="date"
|
|
||||||
value={to}
|
|
||||||
min={from || undefined}
|
|
||||||
aria-label="Uploaded on or before"
|
|
||||||
onChange={(event) => setTo(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
label={isRefreshing ? 'Checking…' : 'Check again'}
|
label={isRefreshing ? 'Checking…' : 'Check again'}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -323,13 +335,29 @@ export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelPro
|
|||||||
{/* ── The list ─────────────────────────────────────────────────────── */}
|
{/* ── The list ─────────────────────────────────────────────────────── */}
|
||||||
{shown.length === 0 ? (
|
{shown.length === 0 ? (
|
||||||
<Card padding={4} variant="transparent">
|
<Card padding={4} variant="transparent">
|
||||||
<VStack gap={0.5}>
|
<VStack gap={1} align="start">
|
||||||
<Text type="label" weight="semibold">
|
<Text type="label" weight="semibold">
|
||||||
Nothing matches
|
Nothing matches
|
||||||
</Text>
|
</Text>
|
||||||
|
{/*
|
||||||
|
The date range is named as the likely cause, with the way out.
|
||||||
|
This page opens on the month like every other, and a receipt log's
|
||||||
|
most-wanted row is often older than that — an empty list that does
|
||||||
|
not say which filter emptied it sends people to the refresh button.
|
||||||
|
*/}
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
No upload matches this tab, search or date range.
|
{rows.length > 0 && (from || to)
|
||||||
|
? `${rows.length} upload${rows.length === 1 ? ' is' : 's are'} outside the selected date range.`
|
||||||
|
: 'No upload matches this tab, search or date range.'}
|
||||||
</Text>
|
</Text>
|
||||||
|
{onClearRange && (from || to) ? (
|
||||||
|
<Button
|
||||||
|
label="Clear date filter"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearRange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user