enhancement

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
import { useMemo, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
ChevronLeft,
ChevronRight,
IndianRupee,
MapPin,
Package,
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -37,7 +35,6 @@ import {
groupByStore,
isToday,
lastSeen,
shiftDay,
stopAddress,
stopCustomer,
stopKey,
@@ -48,7 +45,6 @@ import {
toStops,
UNASSIGNED,
WAITING,
ymd,
type Group,
type Stop,
type ViewMode,
@@ -56,10 +52,12 @@ import {
import './dispatch.css';
/**
* Dispatch — one day's deliveries, read three ways.
* Dispatch — the day's deliveries, read three ways.
*
* A day at a time, because that is how a round is worked: everything here is
* scoped to a single date, not a range. No batch windows — order placement in
* Scoped by the console's shared date filter in the top bar, like every other
* page. A single day is the shape this was built for — that is how a round is
* worked — and the labels adapt when a wider range is chosen rather than
* calling a week of a rider's stops a "round". No batch windows — order placement in
* this tenant clusters so hard into one hour that a morning/afternoon/evening
* split would put ninety per cent in one bucket and call it navigation.
*
@@ -92,14 +90,14 @@ import './dispatch.css';
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
export function DispatchPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const [day, setDay] = useDayParam();
const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
@@ -121,6 +119,16 @@ export function DispatchPage() {
: undefined,
);
/*
A one-day range is still the shape this board was built for, so it keeps the
wording it had: "Rounds", "on this day". A wider range is a legitimate thing
to ask for and the groupings all still hold — but a rider's group is then
several rounds run together, and the labels say so rather than pretending.
*/
const isOneDay = Boolean(
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
);
const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data],
@@ -194,12 +202,6 @@ export function DispatchPage() {
<VStack gap={3}>
<PageHeader
title="Dispatch"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="One day of deliveries — by rider, by shop, or by customer."
isLive
actions={
<DayStepper day={day} onChange={setDay} />
}
tabs={
<HStack gap={0.5} wrap="wrap">
<ModeTab
@@ -230,7 +232,7 @@ export function DispatchPage() {
<KpiCard
label="Stops"
value={count(totals.stops)}
note={isToday(day) ? 'today' : 'on this day'}
note={isOneDay ? (isToday(dates.range.fromdate ?? '') ? 'today' : 'on this day') : 'in this range'}
tone="accent"
icon={<Package size={15} />}
/>
@@ -260,7 +262,13 @@ export function DispatchPage() {
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
@@ -301,7 +309,11 @@ export function DispatchPage() {
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
@@ -326,98 +338,6 @@ export function DispatchPage() {
);
}
/* ── The day ─────────────────────────────────────────────────────────────── */
/**
* Which day is on screen, held in the URL.
*
* The same reasoning `BranchScope` gives for the branch: "Dispatch, last
* Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
* not silently throw the operator back to today while they are reading a round
* that belongs to another date.
*
* A malformed or future value falls back to today rather than 400ing the
* delivery read — the param is user-editable, so it is untrusted input.
*/
function useDayParam(): [string, (next: string) => void] {
const [params, setParams] = useSearchParams();
const today = ymd(new Date());
const raw = params.get('day') ?? '';
const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
const set = (next: string) => {
const clamped = next > today ? today : next;
const merged = new URLSearchParams(params);
// Today is the default, so it is left OUT of the URL: the common case gets
// a clean address, and only a deliberate look back carries a param.
if (clamped === today) merged.delete('day');
else merged.set('day', clamped);
setParams(merged, { replace: true });
};
return [day, set];
}
/**
* A day at a time.
*
* Not a range picker: a round belongs to a date, and the arrows are how an
* operator checks yesterday without thinking about a calendar. Forward is
* disabled on today — dispatch has nothing to say about tomorrow.
*/
function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
const today = isToday(day);
const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
});
return (
<div className="day-stepper">
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
<ChevronLeft size={15} />
</button>
{/*
The label doubles as the picker. Arrows answer "yesterday", which is
most of the traffic; a date a month back would otherwise be thirty
clicks. The input sits transparent over the label rather than replacing
it, so the day still reads as "Sat, Sep 5" instead of as a form field,
and clicking it opens the browser's own calendar — a real control,
keyboard-reachable, with no picker to write or to get wrong.
*/}
<span className="day-label">
<span aria-hidden>{label}</span>
{today ? <em aria-hidden>today</em> : null}
<input
type="date"
aria-label="Pick a day"
value={day}
max={ymd(new Date())}
onChange={(event) => {
// Clearing the field yields '' — keep the day rather than blanking
// the board.
if (event.target.value) onChange(event.target.value);
}}
/>
</span>
<button
type="button"
aria-label="Next day"
disabled={today}
onClick={() => onChange(shiftDay(day, 1))}
>
<ChevronRight size={15} />
</button>
{!today ? (
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
Today
</button>
) : null}
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */
function GroupList({

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react';
import { useState, useMemo } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
@@ -17,7 +17,6 @@ import {
usePosSalesByBranch,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, moneyExact } from '../format';
import {
DELIVERY_STATUS,
@@ -31,7 +30,6 @@ import {
syncLagMs,
type StatusKey,
} from '../orderStatus';
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar';
@@ -60,17 +58,15 @@ type Tab = 'orders' | 'deliveries' | 'counter';
* every row would be a column of dashes.
*/
export function SalesPage() {
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const { scoped, selected, branches, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all');
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const dates = useDateScope();
const [keyword, setKeyword] = useState('');
/* What the tables are currently showing. Any change to it sends every pager
back to page 1 — landing on page 4 of a search you just typed reads as an
empty result. */
const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`;
const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`;
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
null,
@@ -86,15 +82,15 @@ export function SalesPage() {
tenantid,
locationid: selected ?? undefined,
keyword: keyword.trim() || undefined,
...range,
...dates.range,
pagesize: 200,
}
: undefined;
const orders = useOrders(query);
const deliveries = useDeliveries(query);
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, range);
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, dates.range);
const allOrders = orders.data ?? [];
const allDeliveries = deliveries.data ?? [];
@@ -218,30 +214,6 @@ export function SalesPage() {
<VStack gap={3}>
<PageHeader
title="Sales"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
isLive
actions={
<HStack gap={0.5} align="center" wrap="wrap">
{/* Counter sales that never touched a till. Sits beside the date
range rather than under the Counter tab because it is how the
figures on every tab get there for a shop with no POS. */}
<BarAction
label="Upload counter sales"
icon={<Upload size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</HStack>
}
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
@@ -482,15 +454,6 @@ export function SalesPage() {
</>
) : null}
{isUploading && tenantid ? (
<CounterSalesDrawer
tenantid={tenantid}
{...(isPinned && current ? { locationid: current.locationid } : {})}
branchIds={branches.map((branch) => branch.locationid)}
onClose={() => setUploading(false)}
/>
) : null}
{/* The row is re-read from the live list on every render rather than
used as captured, because the drawer can now CHANGE the row it is
@@ -1427,48 +1390,3 @@ function Td({
);
}
/**
* A header action drawn like a tab, so the two rows read as one bar. Same shape
* as Inventory's — copied rather than shared because the two pages own their
* chrome, and a shared one would have to take a theme prop to stay identical.
*/
function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext';
@@ -22,15 +23,26 @@ export function UploadsPage() {
const { user } = useAuth();
const { selected } = useBranchScope();
const [isUploading, setUploading] = useState(false);
/*
No date filter by default, unlike the reporting pages.
This is a receipt log, and the reason somebody opens it is to find an upload
from a while ago. Starting at month-to-date hid every receipt older than the
1st — the list read "Nothing matches" while the tab beside it said there were
two. The read already returns only the most recent fifty, so the range is a
narrowing rather than the thing that makes the page tractable.
*/
const dates = useDateScope();
return (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service — what each one added, and whether it reached a shelf."
/>
<UploadsPanel
tenantid={user?.tenantid ?? 0}
range={dates.range}
onClearRange={dates.clear}
onUpload={() => setUploading(true)}
{...(selected === null ? {} : { locationid: selected })}
/>

View File

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