enhancement
This commit is contained in:
@@ -3,7 +3,7 @@ import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
|
||||
import { nutritionApi } from '@/api/nutrition';
|
||||
import type { Product } from '@/api/types';
|
||||
import { DrawerCard, Note, Row, Section } from './drawerKit';
|
||||
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
|
||||
import { BAND_COLOR, BAND_LABEL, facts, isEdible, present } from './healthScore';
|
||||
import './pages/deliveries.css';
|
||||
|
||||
/**
|
||||
@@ -26,10 +26,40 @@ import './pages/deliveries.css';
|
||||
* confident one — see the caveat, which is shown whenever the service's own
|
||||
* match confidence is below 70%.
|
||||
*/
|
||||
export function HealthScorePanel({ product }: { product: Product }) {
|
||||
export function HealthScorePanel({
|
||||
product,
|
||||
category,
|
||||
}: {
|
||||
product: Product;
|
||||
/**
|
||||
* The product's category, used to decide whether a nutrition score means
|
||||
* anything for it at all. Passed in because the two callers hold it in
|
||||
* different fields — a tenant product has `categoryname`, a catalogue row has
|
||||
* `category` — and neither is on the narrow shape this panel receives.
|
||||
*/
|
||||
category?: string | null;
|
||||
}) {
|
||||
const brand = (product.productbrand ?? '').trim();
|
||||
const imageId = (product.imageid ?? '').trim();
|
||||
|
||||
/*
|
||||
Soap does not have a health score, and the service will give you one anyway.
|
||||
|
||||
Measured again 7 Sep 2026: `GET /nutrition/Godrej/godrej_hit_spray_1101d017`
|
||||
returns `health_score: 80`, `data_status: "verified"` — an "excellent" score
|
||||
for insecticide. Palmolive and Pantene score 37.5 through the same route.
|
||||
|
||||
The catalogue team's own guidance is to hide the field entirely for non-food
|
||||
rather than show an empty slot, and their per-product endpoint still does not
|
||||
publish the edibility label that gates their LIST endpoint — `data_status` is
|
||||
"unavailable" for both a soap and an unscored food, and `category` is
|
||||
populated for both, so nothing in the response separates them. Our own
|
||||
category does, so the decision is made here until they publish theirs.
|
||||
|
||||
Before the network call, deliberately: there is nothing to ask about.
|
||||
*/
|
||||
if (!isEdible(category)) return null;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['nutrition', brand, imageId],
|
||||
queryFn: () => nutritionApi.forProduct(brand, imageId),
|
||||
@@ -89,8 +119,9 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
||||
return (
|
||||
<Section title="Health score">
|
||||
<Note>
|
||||
This product is in the catalogue but has not been scored yet. It will appear here, and in
|
||||
the app, once it has.
|
||||
Nutrition information not available for this product. The catalogue service has already
|
||||
searched its nutrition databases and found nothing usable, so this gap closes only by
|
||||
entering the per-100g figures by hand — not by waiting.
|
||||
</Note>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -172,7 +172,10 @@ export function ProductDrawer({
|
||||
{/* ── Health score ──────────────────────────────────────────────────
|
||||
Under Sizes and above the shop detail: it is what a SHOPPER reads,
|
||||
and it belongs with the other shopper-facing facts. */}
|
||||
<HealthScorePanel product={product} />
|
||||
<HealthScorePanel
|
||||
product={product}
|
||||
category={product.categoryname || product.Subcategoryname}
|
||||
/>
|
||||
|
||||
{product.productdesc ? (
|
||||
<Section title="Description">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
|
||||
import { AssistantScope } from '@/features/console/AssistantScope';
|
||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||
|
||||
/**
|
||||
* Store Admin — the merchant workspace.
|
||||
@@ -80,6 +81,7 @@ const MANAGE: readonly MenuEntry[] = [
|
||||
|
||||
export function StoreAdminShell() {
|
||||
return (
|
||||
<DateScopeProvider>
|
||||
<BranchScopeProvider>
|
||||
<LiveWatch />
|
||||
<OnboardingGate />
|
||||
@@ -93,6 +95,7 @@ export function StoreAdminShell() {
|
||||
/>
|
||||
</AssistantScope>
|
||||
</BranchScopeProvider>
|
||||
</DateScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
groupByStore,
|
||||
isToday,
|
||||
lastSeen,
|
||||
shiftDay,
|
||||
stopKey,
|
||||
stopValue,
|
||||
toStops,
|
||||
@@ -377,14 +376,6 @@ test('the day is worth the work not yet sent out, too', () => {
|
||||
|
||||
/* ── The day ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('stepping a day stays in local time', () => {
|
||||
// Parsing the string would shift the whole board by a day in some zones.
|
||||
assert.equal(shiftDay('2026-09-05', -1), '2026-09-04');
|
||||
assert.equal(shiftDay('2026-09-05', 1), '2026-09-06');
|
||||
assert.equal(shiftDay('2026-01-01', -1), '2025-12-31', 'across a year boundary');
|
||||
assert.equal(shiftDay('2026-03-01', -1), '2026-02-28', 'across a month boundary');
|
||||
});
|
||||
|
||||
test('today is today', () => {
|
||||
assert.equal(isToday(ymd(new Date())), true);
|
||||
assert.equal(isToday('2020-01-01'), false);
|
||||
|
||||
@@ -421,21 +421,12 @@ export function dayTotals(stops: readonly Stop[]): DayTotals {
|
||||
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
|
||||
}
|
||||
|
||||
/** One day as Fiesta wants it, and the stepper's arithmetic. */
|
||||
/** One day as Fiesta wants it. */
|
||||
export function ymd(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function shiftDay(day: string, by: number): string {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
// Local midnight, then step. Constructing from the parts rather than parsing
|
||||
// the string avoids the UTC shift that would move the whole board a day.
|
||||
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
|
||||
date.setDate(date.getDate() + by);
|
||||
return ymd(date);
|
||||
}
|
||||
|
||||
export function isToday(day: string): boolean {
|
||||
return day === ymd(new Date());
|
||||
}
|
||||
|
||||
@@ -254,3 +254,52 @@ test('a declared allergen is shown regardless of confidence', () => {
|
||||
assert.deepEqual(shown.allergens, ['Gluten']);
|
||||
assert.equal(shown.allergensUnconfirmed, false);
|
||||
});
|
||||
|
||||
/* ── Edibility, wired in 7 Sep 2026 ──────────────────────────────────────── */
|
||||
|
||||
test('a health score is never shown for something you do not eat', () => {
|
||||
/*
|
||||
The service will happily score insecticide. Measured 7 Sep 2026:
|
||||
GET /nutrition/Godrej/godrej_hit_spray_1101d017 returns health_score 80,
|
||||
data_status "verified" — an "excellent" score for HIT spray. Palmolive soap
|
||||
and Pantene shampoo both score 37.5 by the same route.
|
||||
|
||||
Their per-product endpoint does not publish the edibility label that gates
|
||||
their list endpoint, so the decision has to be made from our own category.
|
||||
*/
|
||||
assert.equal(isEdible('Home Care'), false);
|
||||
assert.equal(isEdible('Personal Care'), false);
|
||||
assert.equal(isEdible('Detergents'), false);
|
||||
assert.equal(isEdible('Oral Care'), false);
|
||||
});
|
||||
|
||||
test('"General" is not good enough to call something food', () => {
|
||||
// It carries Palmolive soap and P&G alongside anything the scraper could not
|
||||
// place. Ambiguous is not a basis for showing a nutrition score.
|
||||
assert.equal(isEdible('General'), false);
|
||||
assert.equal(isEdible('general'), false);
|
||||
});
|
||||
|
||||
test('the real catalogue food categories are recognised', () => {
|
||||
// Taken from live rows, not invented — these are what the drawer receives.
|
||||
for (const category of [
|
||||
'Spices & Masalas',
|
||||
'Pulses, Grains & Spices',
|
||||
'Health Drinks',
|
||||
'Dairy',
|
||||
'Snacks & Namkeen',
|
||||
'Edible Oils',
|
||||
'Biscuits & Cookies',
|
||||
'Beverages',
|
||||
]) {
|
||||
assert.equal(isEdible(category), true, category);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing category is treated as not food', () => {
|
||||
// Hiding a panel on a food product costs one line of "not available".
|
||||
// Showing one on a bottle of insecticide costs rather more.
|
||||
assert.equal(isEdible(undefined), false);
|
||||
assert.equal(isEdible(null), false);
|
||||
assert.equal(isEdible(' '), false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
ShoppingCart,
|
||||
TrendingDown,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
useStockRequests,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
@@ -52,7 +51,7 @@ import {
|
||||
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
||||
*/
|
||||
export function ConsolePage() {
|
||||
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
|
||||
const { scoped, selected, tenantid, isLoading, isPinned } = useBranchScope();
|
||||
/**
|
||||
* Where this page's links point.
|
||||
*
|
||||
@@ -62,13 +61,12 @@ export function ConsolePage() {
|
||||
* does nothing.
|
||||
*/
|
||||
const base = isPinned ? '/store' : '/admin';
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const dates = useDateScope();
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
const posSales = usePosSalesByBranch(branchIds, dates.range);
|
||||
const posHealth = usePosHealthByBranch(branchIds);
|
||||
|
||||
const requests = useStockRequests(
|
||||
@@ -134,23 +132,6 @@ export function ConsolePage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Console"
|
||||
count={
|
||||
selected === null
|
||||
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
|
||||
: (current?.locationname ?? undefined)
|
||||
}
|
||||
description="Everything trading right now — by channel, by branch, and what is waiting on you."
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { useCounterLabels } from '../counterLabels';
|
||||
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
@@ -49,9 +48,8 @@ const STATE_TONE: Record<CounterState, string> = {
|
||||
};
|
||||
|
||||
export function CountersPage() {
|
||||
const { branches, scoped, selected, current, isPinned } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const { branches, scoped, isPinned } = useBranchScope();
|
||||
const dates = useDateScope();
|
||||
const [chip, setChip] = useState<CounterState | 'all'>('all');
|
||||
const [branchId, setBranchId] = useState<number | null>(null);
|
||||
const [term, setTerm] = useState('');
|
||||
@@ -59,7 +57,7 @@ export function CountersPage() {
|
||||
const [open, setOpen] = useState<CounterRow | null>(null);
|
||||
|
||||
const labels = useCounterLabels();
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
|
||||
|
||||
const rows = useMemo(() => rowsOf(board), [board]);
|
||||
const counts = useMemo(() => countsOf(rows), [rows]);
|
||||
@@ -81,31 +79,7 @@ export function CountersPage() {
|
||||
|
||||
return (
|
||||
<div className="console">
|
||||
<header className="console-head">
|
||||
<div className="console-title">
|
||||
<div className="console-title-row">
|
||||
<h1>Counters</h1>
|
||||
<span className="console-scope">
|
||||
{selected === null
|
||||
? `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`
|
||||
: branchLabel(current?.locationname) || 'This branch'}
|
||||
</span>
|
||||
<span className="console-live"><i />Live</span>
|
||||
</div>
|
||||
<p className="console-blurb">Monitor your counters, activity and connection status.</p>
|
||||
</div>
|
||||
<div className="console-actions">
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 className="sr-only">Counters</h1>
|
||||
<div className="kpi-strip">
|
||||
<Kpi
|
||||
icon={<Monitor size={17} />}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
Bike,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
IndianRupee,
|
||||
MapPin,
|
||||
Package,
|
||||
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
|
||||
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { count, money, moneyExact } from '../format';
|
||||
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
|
||||
@@ -37,7 +35,6 @@ import {
|
||||
groupByStore,
|
||||
isToday,
|
||||
lastSeen,
|
||||
shiftDay,
|
||||
stopAddress,
|
||||
stopCustomer,
|
||||
stopKey,
|
||||
@@ -48,7 +45,6 @@ import {
|
||||
toStops,
|
||||
UNASSIGNED,
|
||||
WAITING,
|
||||
ymd,
|
||||
type Group,
|
||||
type Stop,
|
||||
type ViewMode,
|
||||
@@ -56,10 +52,12 @@ import {
|
||||
import './dispatch.css';
|
||||
|
||||
/**
|
||||
* Dispatch — one day's deliveries, read three ways.
|
||||
* Dispatch — the day's deliveries, read three ways.
|
||||
*
|
||||
* A day at a time, because that is how a round is worked: everything here is
|
||||
* scoped to a single date, not a range. No batch windows — order placement in
|
||||
* Scoped by the console's shared date filter in the top bar, like every other
|
||||
* page. A single day is the shape this was built for — that is how a round is
|
||||
* worked — and the labels adapt when a wider range is chosen rather than
|
||||
* calling a week of a rider's stops a "round". No batch windows — order placement in
|
||||
* this tenant clusters so hard into one hour that a morning/afternoon/evening
|
||||
* split would put ninety per cent in one bucket and call it navigation.
|
||||
*
|
||||
@@ -92,14 +90,14 @@ import './dispatch.css';
|
||||
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
||||
|
||||
export function DispatchPage() {
|
||||
const { branches, selected, current, tenantid } = useBranchScope();
|
||||
const [day, setDay] = useDayParam();
|
||||
const { branches, selected, tenantid } = useBranchScope();
|
||||
const dates = useDateScope();
|
||||
const [mode, setMode] = useState<ViewMode>('riders');
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Stop | null>(null);
|
||||
|
||||
const query = tenantid
|
||||
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
|
||||
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
|
||||
: undefined;
|
||||
|
||||
const deliveries = useDeliveries(query);
|
||||
@@ -121,6 +119,16 @@ export function DispatchPage() {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
/*
|
||||
A one-day range is still the shape this board was built for, so it keeps the
|
||||
wording it had: "Rounds", "on this day". A wider range is a legitimate thing
|
||||
to ask for and the groupings all still hold — but a rider's group is then
|
||||
several rounds run together, and the labels say so rather than pretending.
|
||||
*/
|
||||
const isOneDay = Boolean(
|
||||
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
|
||||
);
|
||||
|
||||
const stops = useMemo(
|
||||
() => toStops(deliveries.data ?? [], orders.data ?? []),
|
||||
[deliveries.data, orders.data],
|
||||
@@ -194,12 +202,6 @@ export function DispatchPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Dispatch"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="One day of deliveries — by rider, by shop, or by customer."
|
||||
isLive
|
||||
actions={
|
||||
<DayStepper day={day} onChange={setDay} />
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<ModeTab
|
||||
@@ -230,7 +232,7 @@ export function DispatchPage() {
|
||||
<KpiCard
|
||||
label="Stops"
|
||||
value={count(totals.stops)}
|
||||
note={isToday(day) ? 'today' : 'on this day'}
|
||||
note={isOneDay ? (isToday(dates.range.fromdate ?? '') ? 'today' : 'on this day') : 'in this range'}
|
||||
tone="accent"
|
||||
icon={<Package size={15} />}
|
||||
/>
|
||||
@@ -260,7 +262,13 @@ export function DispatchPage() {
|
||||
<div className="dispatch-body">
|
||||
<div className="dispatch-rail">
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
|
||||
{mode === 'riders'
|
||||
? isOneDay
|
||||
? 'Rounds'
|
||||
: 'Riders'
|
||||
: mode === 'stores'
|
||||
? 'Shops'
|
||||
: 'Customers'}
|
||||
</Text>
|
||||
<GroupList
|
||||
groups={groups}
|
||||
@@ -301,7 +309,11 @@ export function DispatchPage() {
|
||||
<MapPin size={22} />
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
|
||||
{groups.length > 0
|
||||
? 'Pick one to see its stops'
|
||||
: isOneDay
|
||||
? 'Nothing out on this day'
|
||||
: 'Nothing out in this range'}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
@@ -326,98 +338,6 @@ export function DispatchPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The day ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Which day is on screen, held in the URL.
|
||||
*
|
||||
* The same reasoning `BranchScope` gives for the branch: "Dispatch, last
|
||||
* Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
|
||||
* not silently throw the operator back to today while they are reading a round
|
||||
* that belongs to another date.
|
||||
*
|
||||
* A malformed or future value falls back to today rather than 400ing the
|
||||
* delivery read — the param is user-editable, so it is untrusted input.
|
||||
*/
|
||||
function useDayParam(): [string, (next: string) => void] {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const today = ymd(new Date());
|
||||
const raw = params.get('day') ?? '';
|
||||
const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
|
||||
|
||||
const set = (next: string) => {
|
||||
const clamped = next > today ? today : next;
|
||||
const merged = new URLSearchParams(params);
|
||||
// Today is the default, so it is left OUT of the URL: the common case gets
|
||||
// a clean address, and only a deliberate look back carries a param.
|
||||
if (clamped === today) merged.delete('day');
|
||||
else merged.set('day', clamped);
|
||||
setParams(merged, { replace: true });
|
||||
};
|
||||
|
||||
return [day, set];
|
||||
}
|
||||
|
||||
/**
|
||||
* A day at a time.
|
||||
*
|
||||
* Not a range picker: a round belongs to a date, and the arrows are how an
|
||||
* operator checks yesterday without thinking about a calendar. Forward is
|
||||
* disabled on today — dispatch has nothing to say about tomorrow.
|
||||
*/
|
||||
function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
|
||||
const today = isToday(day);
|
||||
const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="day-stepper">
|
||||
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
{/*
|
||||
The label doubles as the picker. Arrows answer "yesterday", which is
|
||||
most of the traffic; a date a month back would otherwise be thirty
|
||||
clicks. The input sits transparent over the label rather than replacing
|
||||
it, so the day still reads as "Sat, Sep 5" instead of as a form field,
|
||||
and clicking it opens the browser's own calendar — a real control,
|
||||
keyboard-reachable, with no picker to write or to get wrong.
|
||||
*/}
|
||||
<span className="day-label">
|
||||
<span aria-hidden>{label}</span>
|
||||
{today ? <em aria-hidden>today</em> : null}
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Pick a day"
|
||||
value={day}
|
||||
max={ymd(new Date())}
|
||||
onChange={(event) => {
|
||||
// Clearing the field yields '' — keep the day rather than blanking
|
||||
// the board.
|
||||
if (event.target.value) onChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next day"
|
||||
disabled={today}
|
||||
onClick={() => onChange(shiftDay(day, 1))}
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
{!today ? (
|
||||
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
|
||||
Today
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function GroupList({
|
||||
|
||||
@@ -59,7 +59,7 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
||||
* backend catch up. Nothing on screen claims the selection is in force.
|
||||
*/
|
||||
export function InventoryPage() {
|
||||
const { branches, selected, current, tenantid } = useBranchScope();
|
||||
const { selected, tenantid } = useBranchScope();
|
||||
|
||||
/**
|
||||
* The tab comes from the URL, so a link can name one.
|
||||
@@ -112,9 +112,8 @@ export function InventoryPage() {
|
||||
) : null}
|
||||
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Inventory"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
||||
/* Upload sheet lives here, not in the tab row, so its position is
|
||||
fixed — it no longer appears and disappears as the tab changes, and
|
||||
nothing below shifts sideways when it does.
|
||||
@@ -129,7 +128,7 @@ export function InventoryPage() {
|
||||
stated twice on one screen invites the reader to check whether the
|
||||
two agree. */
|
||||
actions={
|
||||
<HStack gap={0.5} align="center" wrap="wrap">
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<BarAction
|
||||
label="Upload sheet"
|
||||
icon={<UploadCloud size={14} />}
|
||||
@@ -181,7 +180,10 @@ export function InventoryPage() {
|
||||
) : tab === 'catalogue' ? (
|
||||
<CataloguePanel />
|
||||
) : tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
<RequestsPanel
|
||||
requests={requests.data ?? []}
|
||||
isLoading={requests.isLoading}
|
||||
/>
|
||||
) : (
|
||||
<StockPanel />
|
||||
)}
|
||||
|
||||
@@ -185,7 +185,7 @@ export function OnboardBranchPage() {
|
||||
if (mutation.isSuccess) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
|
||||
<PageHeader title="Branch commissioned" />
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
@@ -220,7 +220,6 @@ export function OnboardBranchPage() {
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard branch"
|
||||
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
@@ -19,13 +20,11 @@ import {
|
||||
TrendingDown,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
@@ -42,14 +41,13 @@ type Tab = 'revenue' | 'sales' | 'inventory';
|
||||
* `posorders` would not be.
|
||||
*/
|
||||
export function ReportsPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('revenue');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
||||
const dates = useDateScope();
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
const posSales = usePosSalesByBranch(branchIds, dates.range);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -101,18 +99,6 @@ export function ReportsPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Revenue and stock over a period you choose, compared where a comparison is sound."
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
usePosSalesByBranch,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, moneyExact } from '../format';
|
||||
import {
|
||||
DELIVERY_STATUS,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
syncLagMs,
|
||||
type StatusKey,
|
||||
} from '../orderStatus';
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
@@ -60,17 +58,15 @@ type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
* every row would be a column of dashes.
|
||||
*/
|
||||
export function SalesPage() {
|
||||
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
const { scoped, selected, branches, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('orders');
|
||||
const [status, setStatus] = useState<StatusKey>('all');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
||||
const dates = useDateScope();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
/* What the tables are currently showing. Any change to it sends every pager
|
||||
back to page 1 — landing on page 4 of a search you just typed reads as an
|
||||
empty result. */
|
||||
const queryKey = `${tab}|${status}|${keyword}|${range.fromdate}|${range.todate}|${selected ?? 'all'}`;
|
||||
const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`;
|
||||
|
||||
const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>(
|
||||
null,
|
||||
@@ -86,15 +82,15 @@ export function SalesPage() {
|
||||
tenantid,
|
||||
locationid: selected ?? undefined,
|
||||
keyword: keyword.trim() || undefined,
|
||||
...range,
|
||||
...dates.range,
|
||||
pagesize: 200,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const orders = useOrders(query);
|
||||
const deliveries = useDeliveries(query);
|
||||
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 });
|
||||
const posSummary = usePosSalesByBranch(branchIds, range);
|
||||
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
|
||||
const posSummary = usePosSalesByBranch(branchIds, dates.range);
|
||||
|
||||
const allOrders = orders.data ?? [];
|
||||
const allDeliveries = deliveries.data ?? [];
|
||||
@@ -218,30 +214,6 @@ export function SalesPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Sales"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
|
||||
isLive
|
||||
actions={
|
||||
<HStack gap={0.5} align="center" wrap="wrap">
|
||||
{/* Counter sales that never touched a till. Sits beside the date
|
||||
range rather than under the Counter tab because it is how the
|
||||
figures on every tab get there for a shop with no POS. */}
|
||||
<BarAction
|
||||
label="Upload counter sales"
|
||||
icon={<Upload size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
@@ -482,15 +454,6 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isUploading && tenantid ? (
|
||||
<CounterSalesDrawer
|
||||
tenantid={tenantid}
|
||||
{...(isPinned && current ? { locationid: current.locationid } : {})}
|
||||
branchIds={branches.map((branch) => branch.locationid)}
|
||||
onClose={() => setUploading(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
{/* The row is re-read from the live list on every render rather than
|
||||
used as captured, because the drawer can now CHANGE the row it is
|
||||
@@ -1427,48 +1390,3 @@ function Td({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A header action drawn like a tab, so the two rows read as one bar. Same shape
|
||||
* as Inventory's — copied rather than shared because the two pages own their
|
||||
* chrome, and a shared one would have to take a theme prop to stay identical.
|
||||
*/
|
||||
function BarAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ export function ShopProfilePage() {
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Shop profile"
|
||||
description="What shoppers see about your business, and how to reach you."
|
||||
{...(gaps.length > 0
|
||||
? { count: `${gaps.length} still to fill in` }
|
||||
: {})}
|
||||
@@ -175,7 +174,6 @@ export function ShopProfilePage() {
|
||||
value={form['tenantimage'] ?? ''}
|
||||
onChange={set('tenantimage')}
|
||||
placeholder="https://…"
|
||||
description="The picture shoppers see beside your shop in the app."
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
@@ -190,7 +188,6 @@ export function ShopProfilePage() {
|
||||
label="FSSAI / trade licence"
|
||||
value={form['licenseno'] ?? ''}
|
||||
onChange={set('licenseno')}
|
||||
description="Shown to shoppers. Required to display for a food business."
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
@@ -16,12 +17,10 @@ import {
|
||||
Pencil,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
@@ -48,14 +47,13 @@ import { usePaged } from '@/components/usePaged';
|
||||
* correct output, and it is the one the old design could not produce.
|
||||
*/
|
||||
export function TerminalsPage() {
|
||||
const { branches, scoped, selected, current } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const { scoped } = useBranchScope();
|
||||
const dates = useDateScope();
|
||||
const [showFine, setShowFine] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const labels = useCounterLabels();
|
||||
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
const board = useTerminalBoard({ branches: scoped, range: dates.range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
/**
|
||||
@@ -79,19 +77,7 @@ export function TerminalsPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Counters"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
{...(summary ? { description: summary } : {})}
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The console's opening move on every operations page — Console, Sales,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
@@ -22,15 +23,26 @@ export function UploadsPage() {
|
||||
const { user } = useAuth();
|
||||
const { selected } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
/*
|
||||
No date filter by default, unlike the reporting pages.
|
||||
|
||||
This is a receipt log, and the reason somebody opens it is to find an upload
|
||||
from a while ago. Starting at month-to-date hid every receipt older than the
|
||||
1st — the list read "Nothing matches" while the tab beside it said there were
|
||||
two. The read already returns only the most recent fifty, so the range is a
|
||||
narrowing rather than the thing that makes the page tractable.
|
||||
*/
|
||||
const dates = useDateScope();
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
description="Spreadsheets sent to the catalogue service — what each one added, and whether it reached a shelf."
|
||||
/>
|
||||
<UploadsPanel
|
||||
tenantid={user?.tenantid ?? 0}
|
||||
range={dates.range}
|
||||
onClearRange={dates.clear}
|
||||
onUpload={() => setUploading(true)}
|
||||
{...(selected === null ? {} : { locationid: selected })}
|
||||
/>
|
||||
|
||||
@@ -140,9 +140,8 @@ export function UsersPage() {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Users & access"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Who can sign in to the console, and who can open a till."
|
||||
actions={
|
||||
<Button
|
||||
label={
|
||||
|
||||
Reference in New Issue
Block a user