diff --git a/src/features/console/ConsolePage.tsx b/src/features/console/ConsolePage.tsx index d05acba..a76dda3 100644 --- a/src/features/console/ConsolePage.tsx +++ b/src/features/console/ConsolePage.tsx @@ -6,21 +6,20 @@ import { summariseBranch } from '@/features/store-admin/posStatus'; import { useLocationProducts, useLocationSummary, - useOwnTenant, usePosHealthByBranch, usePosSalesByBranch, useStockRequests, } from '@/queries/hooks'; -import { buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel'; +import { buildAlerts, totalsOf, type BranchRow } from './consoleModel'; import { consoleScope } from './consoleScope'; -import { previousRange, useConsoleSeries } from './useConsoleSeries'; +import { useConsoleSeries } from './useConsoleSeries'; import { BranchOverview } from './sections/BranchOverview'; import { KpiStrip } from './sections/KpiStrip'; import { NeedsAttention } from './sections/NeedsAttention'; import { SalesOverview } from './sections/SalesOverview'; import { StoreHealth } from './sections/StoreHealth'; import { TillSync } from './sections/TillSync'; -import { YourShop } from './sections/YourShop'; +import { QuickActions } from './sections/QuickActions'; import './console.css'; /** @@ -53,14 +52,11 @@ export function ConsolePage() { const base = isPinned ? '/store' : '/admin'; const [preset, setPreset] = useState('today'); const [range, setRange] = useState(() => presetRange('today')); - const prev = useMemo(() => previousRange(range), [range]); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const orders = useLocationSummary(tenantid || undefined); const posNow = usePosSalesByBranch(branchIds, range); - const posBefore = usePosSalesByBranch(branchIds, prev); const posHealth = usePosHealthByBranch(branchIds); - const shop = useOwnTenant(tenantid || undefined); const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, { allBranches: true, }); @@ -72,12 +68,7 @@ export function ConsolePage() { () => posNow.flatMap((query) => (query.data ? [query.data] : [])), [posNow], ); - const posBeforeData = useMemo( - () => posBefore.flatMap((query) => (query.data ? [query.data] : [])), - [posBefore], - ); - - const series = useConsoleSeries(tenantid, selected, range, posNowData, posBeforeData); + const series = useConsoleSeries(tenantid, selected, range, posNowData); // One instant for the whole board, so two tills read a second apart are not // judged against two different "nows". @@ -122,7 +113,6 @@ export function ConsolePage() { }; }, [rows, series]); - const rollup = useMemo(() => rollupHealth(rows), [rows]); const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]); const scope = consoleScope({ @@ -162,24 +152,24 @@ export function ConsolePage() { - + -
- + {/* Full width. A trend needs the page: at two thirds it showed six days + before scrolling, which is not a trend, it is a sample. */} + + + {/* Store health takes the slot the shop card had. The shop’s name and + address were the least useful thing on the board — a merchant knows + which shop they are in — and the health panel earns the width. */} +
+
{scope.showBranchOverview ? ( @@ -194,9 +184,7 @@ export function ConsolePage() { - ) : ( - - )} + ) : null} diff --git a/src/features/console/console.css b/src/features/console/console.css index 72086aa..190959c 100644 --- a/src/features/console/console.css +++ b/src/features/console/console.css @@ -63,31 +63,27 @@ .kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; } } +/* Icon beside the figure, nothing beneath it. The card used to carry a trend + row and a sparkline; with those gone it is a label and a number, and the + layout should not still be shaped around what it no longer holds. */ .kpi { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface); padding: 16px; - display: flex; flex-direction: column; gap: 10px; + display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; } -.kpi-top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; } .kpi-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--color-brand-tint); color: var(--color-brand); } -.kpi-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); } /* Two lines rather than an ellipsis. With Nearle Buddy open each card is about 195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that has stopped doing its job. The min-height keeps all five value rows level whether the label took one line or two. */ .kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); } .kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; } -.kpi-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.spark { width: 56px; height: 20px; flex: none; opacity: .9; } -.kpi-trend { display: flex; align-items: center; gap: 5px; font: 500 12.5px/1 var(--font-sans); } -.kpi-trend[data-good="yes"] { color: #1c6b47; } -.kpi-trend[data-good="no"] { color: #b3261e; } -.kpi-trend[data-good="neutral"] { color: var(--color-ink-3); } -.kpi-vs { color: var(--color-ink-3); font-weight: 400; } /* ── Panels ─────────────────────────────────────────────────────────────── */ .panel { @@ -109,10 +105,6 @@ .panel-empty strong { font: 600 14px/1.3 var(--font-sans); color: var(--color-ink-1); } .panel-empty span { font: 400 13px/1.5 var(--font-sans); color: var(--color-ink-3); } -/* The chart needs width to be a trend; health is a list and does not. */ -.console-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); } -@media (min-width: 1080px) { .console-split { grid-template-columns: minmax(0, 1.32fr) minmax(0, 1fr); } } - /* ── Shared: tiles, pills, buttons ──────────────────────────────────────── */ .tile { width: 34px; height: 34px; flex: none; border-radius: 10px; display: grid; place-items: center; @@ -209,39 +201,14 @@ .health-name { font: 600 13.5px/1.2 var(--font-sans); color: var(--color-ink-1); } .health-detail { font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-3); } -.health-rollup { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.rollup-chip { - padding: 12px 14px; border: 1px solid var(--color-line); border-left-width: 3px; - border-radius: 12px; background: var(--color-surface); - display: flex; flex-direction: column; gap: 2px; -} -.rollup-chip strong { font: 600 20px/1 var(--font-sans); font-variant-numeric: tabular-nums; } -.rollup-chip span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); } -.rollup-chip[data-tone="healthy"] { border-left-color: #1c6b47; } -.rollup-chip[data-tone="attention"] { border-left-color: #8a5a00; } -.rollup-chip[data-tone="critical"] { border-left-color: #b3261e; } -/* ── Your shop ──────────────────────────────────────────────────────────── */ -/* Two panels, not three. The shop and its takings are one statement; the - actions are the other. Three columns each doing a third of a job left every - card half empty and stretched to the tallest of them. */ -.shop-row { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); } -@media (min-width: 900px) { .shop-row { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); } } +/* ── Quick actions ──────────────────────────────────────────────────────── */ +/* Store health beside quick actions. Health carries four rows of detail and + earns the larger share; the actions are three doors. */ +.panel-pair { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); } +@media (min-width: 900px) { .panel-pair { grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); } } -.shop-id { display: flex; gap: 14px; align-items: center; } -.shop-idtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.shop-title { font: 600 17px/1.2 var(--font-sans); color: var(--color-ink-1); } -.shop-mark { width: 54px; height: 54px; flex: none; border-radius: 14px; display: grid; place-items: center; background: var(--color-brand-tint); color: var(--color-brand); } -.shop-meta { margin: 0; display: flex; gap: 7px; align-items: flex-start; font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); } -.shop-meta svg { flex: none; margin-top: 2px; } -.perf-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.perf { padding: 12px 14px; border: 1px solid var(--color-line); border-radius: 12px; display: flex; flex-direction: column; gap: 3px; } -.perf[data-strong="yes"] { background: var(--color-brand-tint); border-color: var(--color-brand); } -.perf[data-strong="yes"] strong { color: var(--color-brand); } -.perf-label { display: inline-flex; align-items: center; gap: 6px; font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-3); } -.perf strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; } -.perf-note { font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-4); } .quick-list { display: flex; flex-direction: column; gap: 8px; } .quick { diff --git a/src/features/console/consoleModel.test.ts b/src/features/console/consoleModel.test.ts index b267544..f59c419 100644 --- a/src/features/console/consoleModel.test.ts +++ b/src/features/console/consoleModel.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert'; import { test } from 'node:test'; import type { TenantLocation } from '@/api/types'; import type { BranchSyncSummary, TerminalState } from '@/features/store-admin/posStatus'; -import { branchTone, buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel'; +import { branchTone, buildAlerts, totalsOf, type BranchRow } from './consoleModel'; const health = (over: Partial = {}): BranchSyncSummary => ({ state: 'synced' as TerminalState, @@ -70,15 +70,6 @@ test('a branch with no tills is not reported as healthy', () => { assert.equal(branchTone(health({ online: 0, total: 0 })), 'attention'); }); -test('the rollup counts every branch exactly once', () => { - const rollup = rollupHealth([ - row(1), - row(2, { health: health({ online: 1, total: 3 }) }), - row(3, { health: health({ online: 0, total: 2 }) }), - ]); - assert.deepEqual(rollup, { healthy: 1, attention: 1, offline: 1 }); -}); - /* ── Alerts ───────────────────────────────────────────────────────────────── */ test('a healthy shop raises nothing at all', () => { diff --git a/src/features/console/consoleModel.ts b/src/features/console/consoleModel.ts index c5ee9e9..ac84d07 100644 --- a/src/features/console/consoleModel.ts +++ b/src/features/console/consoleModel.ts @@ -66,12 +66,6 @@ export function totalsOf(rows: readonly BranchRow[]): ConsoleTotals { export type HealthTone = 'healthy' | 'attention' | 'critical'; -export interface HealthRollup { - healthy: number; - attention: number; - offline: number; -} - /** * How one branch is doing, in three words rather than six states. * @@ -88,17 +82,6 @@ export function branchTone(health: BranchSyncSummary): HealthTone { return 'healthy'; } -export function rollupHealth(rows: readonly BranchRow[]): HealthRollup { - const rollup: HealthRollup = { healthy: 0, attention: 0, offline: 0 }; - for (const row of rows) { - const tone = branchTone(row.health); - if (tone === 'critical') rollup.offline += 1; - else if (tone === 'attention') rollup.attention += 1; - else rollup.healthy += 1; - } - return rollup; -} - /* ── Attention ────────────────────────────────────────────────────────────── */ export interface ConsoleAlert { diff --git a/src/features/console/sections/KpiStrip.tsx b/src/features/console/sections/KpiStrip.tsx index 38effde..3aea882 100644 --- a/src/features/console/sections/KpiStrip.tsx +++ b/src/features/console/sections/KpiStrip.tsx @@ -1,36 +1,32 @@ import type { ReactNode } from 'react'; -import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart, TrendingDown, TrendingUp } from 'lucide-react'; +import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react'; import { count, money } from '@/features/store-admin/format'; import type { ConsoleTotals } from '../consoleModel'; -import type { DaySeries, Trend } from '../useConsoleSeries'; /** * The five figures the board opens with. * - * Each card carries the number, the shape of how it got there, and whether that - * is better or worse than the period before. The sparkline is not decoration: - * ₹12,480 taken evenly across a week and ₹12,480 taken in one afternoon are the - * same number and completely different news, and the line is the only part of - * the card that can tell them apart. + * Icon, label, number. Nothing else. * - * Trend arrows are drawn only where a real comparison exists. `Trend.isNew` - * marks the case where the previous period was zero — "↑ ∞%" is not a fact, so - * it reads "new" instead. + * ── What used to be here, and why it went ─────────────────────────────────── + * + * A "↑ 12% vs. yesterday" line and a sparkline on every card. Both removed on + * request, and the request was right: five cards each carrying a percentage, an + * arrow and a chart made the strip the busiest thing on a page whose job is to + * answer "how much did we take" at a glance. A trend belongs in Reports, where + * somebody has gone looking for one. + * + * The line that stayed is a plain statement of fact, not a comparison: a bare 0 + * under "Unsynced Bills" reads as "no data" when it means everything is in the + * books, and "42" under Total Orders says nothing about which channel they came + * through. */ export interface KpiStripProps { totals: ConsoleTotals; - days: readonly DaySeries[]; - trends: { - online: Trend; - counter: Trend; - orders: Trend; - cancelled: Trend; - }; - comparisonLabel: string; isLoading: boolean; } -export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: KpiStripProps) { +export function KpiStrip({ totals, isLoading }: KpiStripProps) { if (isLoading) { return (
@@ -43,129 +39,48 @@ export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: K return (
- } - label="Online Sales" - value={money(totals.onlineRevenue)} - trend={trends.online} - comparisonLabel={comparisonLabel} - series={days.map((d) => d.online)} - /> - } - label="Counter Sales" - value={money(totals.counterRevenue)} - trend={trends.counter} - comparisonLabel={comparisonLabel} - series={days.map((d) => d.counter)} - /> + } label="Online Sales" value={money(totals.onlineRevenue)} /> + } label="Counter Sales" value={money(totals.counterRevenue)} /> } label="Total Orders" value={count(totals.totalOrders)} - trend={trends.orders} - comparisonLabel={comparisonLabel} - series={days.map((d) => d.onlineOrders + d.counterBills)} + note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`} /> } label="Cancelled Orders" value={count(totals.cancelled)} - trend={trends.cancelled} - comparisonLabel={comparisonLabel} - series={days.map(() => 0)} - // The only card where a rise is bad news, so the colours invert. - isInverted + {...(totals.onlineOrders > 0 + ? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` } + : {})} /> } label="Unsynced Bills" value={count(totals.pendingBills)} - // A live queue depth, not a period figure: there is nothing last week - // to compare it against, so it states what it is instead. note={totals.pendingBills === 0 ? 'all bills are in the books' : 'waiting on a till'} - series={days.map(() => 0)} - isInverted />
); } function Kpi({ - icon, label, value, trend, comparisonLabel, series, note, isInverted, + icon, label, value, note, }: { icon: ReactNode; label: string; value: string; - trend?: Trend; - comparisonLabel?: string; - series: readonly number[]; note?: string; - isInverted?: boolean; }) { - // Up is green unless a rise is the bad outcome — cancellations, unsent bills. - const isGood = trend ? (isInverted ? trend.direction === 'down' : trend.direction === 'up') : true; - // A flat line on an empty period is not bad news, so it is not drawn in red. - const hasShape = series.some((value) => value > 0); - return (
- {/* Icon and figure only. The sparkline sits on the row below rather than - beside the label: at the widths this strip actually gets — five cards - sharing the page with Nearle Buddy — a 52px chart alongside truncates - "Cancelled Orders" to "Cance…", and a label that has stopped naming - its number is worse than no chart. */} -
- {icon} -
- {label} - {value} -
-
- -
- {trend && !trend.isNew && trend.percent !== undefined ? ( -
- {trend.direction === 'down' ? : } - {trend.percent}% - vs. {comparisonLabel} -
- ) : ( -
- {note ?? (trend?.isNew ? 'new this period' : `vs. ${comparisonLabel}`)} -
- )} - + {icon} +
+ {label} + {value} + {note ? {note} : null}
); } - -/** - * The shape of the period, in about forty pixels. - * - * A polyline over a normalised series. Flat or empty data draws a flat line - * rather than nothing, because an absent sparkline reads as a broken card while - * a flat one correctly says "nothing moved". - */ -function Sparkline({ values, tone }: { values: readonly number[]; tone: 'good' | 'bad' | 'none' }) { - const points = values.length >= 2 ? values : [0, 0]; - const peak = Math.max(...points); - const floor = Math.min(...points); - const span = peak - floor || 1; - - const path = points - .map((value, i) => { - const x = (i / (points.length - 1)) * 60; - const y = 20 - ((value - floor) / span) * 18; - return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; - }) - .join(' '); - - return ( - - - - ); -} diff --git a/src/features/console/sections/QuickActions.tsx b/src/features/console/sections/QuickActions.tsx new file mode 100644 index 0000000..94b1958 --- /dev/null +++ b/src/features/console/sections/QuickActions.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Boxes, ChevronRight, Eye, RefreshCw } from 'lucide-react'; + +/** + * The three things most often done from this board. + * + * Lifted out of the shop card when that card went. They belong beside Store + * health rather than under it: the health panel is where a merchant learns that + * something needs doing, and these are the doors to go and do it. + * + * Deliberately three. A column of nine shortcuts is a second navigation, and + * this console already has one along the top. + */ +export function QuickActions({ base }: { base: string }) { + return ( +
+
+
+

Quick actions

+

The three things most often done from here

+
+
+
+ } + label="View store" + note="what a shopper sees" + /> + } + label="Manage catalogue" + note="add and price products" + /> + } + label="Update inventory" + note="upload today's counts" + /> +
+
+ ); +} + +function Action({ + to, icon, label, note, +}: { to: string; icon: ReactNode; label: string; note: string }) { + return ( + + {icon} + + {label} + {note} + + + + ); +} diff --git a/src/features/console/sections/StoreHealth.tsx b/src/features/console/sections/StoreHealth.tsx index 9545c57..f4bd2ac 100644 --- a/src/features/console/sections/StoreHealth.tsx +++ b/src/features/console/sections/StoreHealth.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react'; import { count } from '@/features/store-admin/format'; -import type { BranchRow, ConsoleTotals, HealthRollup, HealthTone } from '../consoleModel'; +import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel'; /** * Whether the shop is working, as distinct from how much it sold. @@ -22,14 +22,13 @@ import type { BranchRow, ConsoleTotals, HealthRollup, HealthTone } from '../cons export interface StoreHealthProps { rows: readonly BranchRow[]; totals: ConsoleTotals; - rollup: HealthRollup; isAggregate: boolean; productCount: number; base: string; } export function StoreHealth({ - rows, totals, rollup, isAggregate, productCount, base, + rows, totals, isAggregate, productCount, base, }: StoreHealthProps) { const undecided = rows.reduce((sum, row) => sum + row.pendingRequests, 0); const withoutTill = rows.filter((row) => row.health.total === 0).length; @@ -44,18 +43,14 @@ export function StoreHealth({

Store health

-

Quick status of your key operations

+

+ {isAggregate + ? 'Quick status across every branch' + : 'Quick status of your key operations'} +

- {isAggregate ? ( -
- - - -
- ) : null} -
} @@ -112,15 +107,6 @@ export function StoreHealth({ ); } -function RollupChip({ tone, n, word }: { tone: HealthTone; n: number; word: string }) { - return ( -
- {n} - {n === 1 ? 'branch' : 'branches'} {word} -
- ); -} - function HealthLine({ icon, name, detail, tone, action, href, }: { diff --git a/src/features/console/sections/YourShop.tsx b/src/features/console/sections/YourShop.tsx deleted file mode 100644 index 1977bd1..0000000 --- a/src/features/console/sections/YourShop.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { ReactNode } from 'react'; -import { Link } from 'react-router-dom'; -import { Boxes, ChevronRight, Eye, MapPin, Phone, Receipt, RefreshCw, ShoppingCart, Store } from 'lucide-react'; -import type { TenantInfo } from '@/api/types'; -import { count, money } from '@/features/store-admin/format'; -import type { BranchRow } from '../consoleModel'; - -/** - * The shop: who it is, what it took, and what to do next. - * - * ── Why identity and takings share one card ───────────────────────────────── - * - * They were two panels side by side, and both were half empty: a name and an - * address do not fill a panel, and neither do two figures. Worse, the row - * stretched every panel to match the tallest, so the gap was proportional to - * how much the THIRD panel had to say. Three cards each doing a third of a job - * is not a layout, it is a grid with the content spread thin to fit it. - * - * They also belong together: "R mart" and "R mart took ₹12,480 today" are one - * statement about one shop, and the border between them was inventing a - * distinction the reader does not have. - * - * So two panels, not three — the shop and the things to do to it. The figures - * sit inside the shop card, beneath the address they belong to. - */ -export interface YourShopProps { - row: BranchRow | undefined; - shop: TenantInfo | undefined; - title: string; - base: string; -} - -export function YourShop({ row, shop, title, base }: YourShopProps) { - if (!row) { - return ( -
-
- No branch selected - Pick a branch from the selector above to see its details. -
-
- ); - } - - const record = shop as unknown as Record | undefined; - const name = row.branch.locationname?.trim() || record?.['tenantname']?.trim() || 'Your shop'; - const where = [record?.['address'], record?.['city'], record?.['postcode']] - .map((part) => part?.trim()) - .filter(Boolean) - .join(', '); - const total = row.onlineRevenue + row.counterRevenue; - - return ( -
-
-
-
-

{title}

-

Today’s performance and where customers find you

-
- Active -
- -
- -
- {name} - {where ?

{where}

: null} - {record?.['primarycontact'] ? ( -

{record['primarycontact']}

- ) : null} -
-
- - {/* Three figures rather than two: the total is what a shopkeeper is - actually after, and making them add it up from two channels is the - arithmetic this board exists to remove. */} -
-
} - label="Online" - value={money(row.onlineRevenue)} - note={`${count(row.onlineOrders)} ${row.onlineOrders === 1 ? 'order' : 'orders'}`} - /> -
} - label="Counter" - value={money(row.counterRevenue)} - note={`${count(row.counterBills)} ${row.counterBills === 1 ? 'bill' : 'bills'}`} - /> -
-
-
- -
-
-
-

Quick actions

-

The three things most often done from here

-
-
-
- } label="View store" note="what a shopper sees" /> - } label="Manage catalogue" note="add and price products" /> - } label="Update inventory" note="upload today's counts" /> -
-
-
- ); -} - -function Figure({ - icon, label, value, note, isStrong, -}: { icon?: ReactNode; label: string; value: string; note: string; isStrong?: boolean }) { - return ( -
- {icon}{label} - {value} - {note} -
- ); -} - -function QuickAction({ - to, icon, label, note, -}: { to: string; icon: ReactNode; label: string; note: string }) { - return ( - - {icon} - - {label} - {note} - - - - ); -} diff --git a/src/features/console/useConsoleSeries.ts b/src/features/console/useConsoleSeries.ts index c895baa..cdda5dc 100644 --- a/src/features/console/useConsoleSeries.ts +++ b/src/features/console/useConsoleSeries.ts @@ -4,7 +4,7 @@ import { useOrders } from '@/queries/hooks'; import type { PosSalesSummary } from '@/api/types'; /** - * The day-by-day series behind the chart, the donut and the trend arrows. + * The day-by-day series behind the chart and the donut. * * ── Why the online side is built from order rows ──────────────────────────── * @@ -14,13 +14,10 @@ import type { PosSalesSummary } from '@/api/types'; * online series is grouped from the order rows themselves, which carry * `orderdate` and an amount and can be asked for a date range. * - * ── Why the previous period is fetched at all ─────────────────────────────── - * - * "↑ 12% vs. yesterday" is a claim, and a dashboard that invents it is worse - * than one that omits it. The comparison window is the same length as the one - * on screen, immediately before it, and every trend shown is a real division of - * two measured numbers. Where a figure has no comparable — an all-time count - * with no date to filter on — no arrow is drawn. + * The hook used to fetch a second, earlier window to compute "vs. yesterday" + * trends for the KPI cards. Those were removed, and the extra fetch went with + * them — one request per branch, plus an orders query, for a line nobody asked + * to see. */ export interface DaySeries { @@ -31,73 +28,23 @@ export interface DaySeries { counterBills: number; } -export interface Trend { - /** Percentage change, rounded. Absent when there is nothing to compare. */ - percent?: number; - /** True when the previous period was zero, so a percentage would be a lie. */ - isNew: boolean; - direction: 'up' | 'down' | 'flat'; -} - export interface ConsoleSeries { days: DaySeries[]; onlineRevenue: number; onlineOrders: number; cancelled: number; - trends: { - online: Trend; - counter: Trend; - orders: Trend; - cancelled: Trend; - }; isLoading: boolean; } -/** The window of the same length immediately before this one. */ -export function previousRange(range: DateRange): DateRange { - const from = range.fromdate ? new Date(range.fromdate) : undefined; - const to = range.todate ? new Date(range.todate) : undefined; - if (!from || !to || Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return {}; - - const days = Math.max(1, Math.round((to.getTime() - from.getTime()) / 86_400_000) + 1); - const prevTo = new Date(from); - prevTo.setDate(prevTo.getDate() - 1); - const prevFrom = new Date(prevTo); - prevFrom.setDate(prevFrom.getDate() - (days - 1)); - - return { fromdate: iso(prevFrom), todate: iso(prevTo) }; -} - -export function trendOf(now: number, before: number): Trend { - if (before <= 0) { - // Everything is up from nothing, and "↑ ∞%" is not a fact. Say it is new. - return { isNew: now > 0, direction: now > 0 ? 'up' : 'flat' }; - } - const percent = Math.round(((now - before) / before) * 100); - return { - percent: Math.abs(percent), - isNew: false, - direction: percent > 0 ? 'up' : percent < 0 ? 'down' : 'flat', - }; -} - export function useConsoleSeries( tenantid: number, locationid: number | null, range: DateRange, posNow: readonly PosSalesSummary[], - posBefore: readonly PosSalesSummary[], ): ConsoleSeries { - const prev = useMemo(() => previousRange(range), [range]); - const nowOrders = useOrders( tenantid ? { tenantid, ...(locationid ? { locationid } : {}), ...range, pagesize: 500 } : undefined, ); - const beforeOrders = useOrders( - tenantid && prev.fromdate - ? { tenantid, ...(locationid ? { locationid } : {}), ...prev, pagesize: 500 } - : undefined, - ); return useMemo(() => { const byDay = new Map(); @@ -137,35 +84,14 @@ export function useConsoleSeries( } } - let beforeOnline = 0; - let beforeOrders_ = 0; - let beforeCancelled = 0; - for (const order of beforeOrders.data ?? []) { - const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled'; - beforeOrders_ += 1; - if (isCancelled) beforeCancelled += 1; - else beforeOnline += amountOf(order); - } - - const counterNow = posNow.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0); - const counterBefore = posBefore.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0); - const billsNow = posNow.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0); - const billsBefore = posBefore.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0); - return { days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)), onlineRevenue, onlineOrders, cancelled, - trends: { - online: trendOf(onlineRevenue, beforeOnline), - counter: trendOf(counterNow, counterBefore), - orders: trendOf(onlineOrders + billsNow, beforeOrders_ + billsBefore), - cancelled: trendOf(cancelled, beforeCancelled), - }, isLoading: nowOrders.isLoading, }; - }, [nowOrders.data, nowOrders.isLoading, beforeOrders.data, posNow, posBefore]); + }, [nowOrders.data, nowOrders.isLoading, posNow]); } /* ── Reading the wire ─────────────────────────────────────────────────────── */