cards removed

This commit is contained in:
2026-09-03 15:45:15 +05:30
parent 5345809951
commit bbe313ba48
9 changed files with 128 additions and 448 deletions

View File

@@ -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<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => 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() {
</div>
</header>
<KpiStrip
totals={totals}
days={series.days}
trends={series.trends}
comparisonLabel={preset === 'today' ? 'yesterday' : 'previous period'}
isLoading={isBusy}
/>
<KpiStrip totals={totals} isLoading={isBusy} />
<div className="console-split">
<SalesOverview days={series.days} isLoading={isBusy} />
{/* 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. */}
<SalesOverview days={series.days} isLoading={isBusy} />
{/* 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. */}
<div className="panel-pair">
<StoreHealth
rows={rows}
totals={totals}
rollup={rollup}
isAggregate={scope.isAggregate}
productCount={(products.data ?? []).length}
base={base}
/>
<QuickActions base={base} />
</div>
{scope.showBranchOverview ? (
@@ -194,9 +184,7 @@ export function ConsolePage() {
</header>
<BranchOverview rows={rows} onSelect={select} />
</section>
) : (
<YourShop row={rows[0]} shop={shop.data} title={scope.shopSectionTitle} base={base} />
)}
) : null}
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} />

View File

@@ -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 {

View File

@@ -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> = {}): 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', () => {

View File

@@ -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 {

View File

@@ -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 (
<div className="kpi-strip" aria-busy="true">
@@ -43,129 +39,48 @@ export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: K
return (
<div className="kpi-strip">
<Kpi
icon={<ShoppingCart size={17} />}
label="Online Sales"
value={money(totals.onlineRevenue)}
trend={trends.online}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.online)}
/>
<Kpi
icon={<Receipt size={17} />}
label="Counter Sales"
value={money(totals.counterRevenue)}
trend={trends.counter}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.counter)}
/>
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<Kpi
icon={<ListChecks size={17} />}
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`}
/>
<Kpi
icon={<Ban size={17} />}
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` }
: {})}
/>
<Kpi
icon={<CloudUpload size={17} />}
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
/>
</div>
);
}
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 (
<article className="kpi">
{/* 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. */}
<div className="kpi-top">
<span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
</div>
</div>
<div className="kpi-foot">
{trend && !trend.isNew && trend.percent !== undefined ? (
<div className="kpi-trend" data-good={isGood ? 'yes' : 'no'}>
{trend.direction === 'down' ? <TrendingDown size={13} /> : <TrendingUp size={13} />}
<strong>{trend.percent}%</strong>
<span className="kpi-vs">vs. {comparisonLabel}</span>
</div>
) : (
<div className="kpi-trend" data-good="neutral">
<span className="kpi-vs">{note ?? (trend?.isNew ? 'new this period' : `vs. ${comparisonLabel}`)}</span>
</div>
)}
<Sparkline values={series} tone={!hasShape ? 'none' : isGood ? 'good' : 'bad'} />
<span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
{note ? <span className="kpi-note">{note}</span> : null}
</div>
</article>
);
}
/**
* 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 (
<svg className="spark" viewBox="0 0 60 22" aria-hidden focusable="false">
<path d={path} fill="none" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
stroke={tone === 'none' ? 'var(--color-line)' : tone === 'bad' ? '#b3261e' : 'var(--color-brand)'} />
</svg>
);
}

View File

@@ -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 (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Quick actions</h3>
<p className="panel-sub">The three things most often done from here</p>
</div>
</header>
<div className="quick-list">
<Action
to={`${base}/inventory?tab=products`}
icon={<Eye size={15} />}
label="View store"
note="what a shopper sees"
/>
<Action
to={`${base}/inventory?tab=catalogue`}
icon={<Boxes size={15} />}
label="Manage catalogue"
note="add and price products"
/>
<Action
to={`${base}/inventory?tab=stock`}
icon={<RefreshCw size={15} />}
label="Update inventory"
note="upload today's counts"
/>
</div>
</section>
);
}
function Action({
to, icon, label, note,
}: { to: string; icon: ReactNode; label: string; note: string }) {
return (
<Link to={to} className="quick">
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
<span className="quick-text">
<span className="quick-label">{label}</span>
<span className="quick-note">{note}</span>
</span>
<ChevronRight size={15} className="quick-chev" />
</Link>
);
}

View File

@@ -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({
<header className="panel-head">
<div>
<h3 className="panel-title">Store health</h3>
<p className="panel-sub">Quick status of your key operations</p>
<p className="panel-sub">
{isAggregate
? 'Quick status across every branch'
: 'Quick status of your key operations'}
</p>
</div>
</header>
{isAggregate ? (
<div className="health-rollup">
<RollupChip tone="healthy" n={rollup.healthy} word="healthy" />
<RollupChip tone="attention" n={rollup.attention} word="need attention" />
<RollupChip tone="critical" n={rollup.offline} word="offline" />
</div>
) : null}
<div className="health-list">
<HealthLine
icon={<Monitor size={16} />}
@@ -112,15 +107,6 @@ export function StoreHealth({
);
}
function RollupChip({ tone, n, word }: { tone: HealthTone; n: number; word: string }) {
return (
<div className="rollup-chip" data-tone={tone}>
<strong>{n}</strong>
<span>{n === 1 ? 'branch' : 'branches'} {word}</span>
</div>
);
}
function HealthLine({
icon, name, detail, tone, action, href,
}: {

View File

@@ -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 (
<section className="panel">
<div className="panel-empty">
<strong>No branch selected</strong>
<span>Pick a branch from the selector above to see its details.</span>
</div>
</section>
);
}
const record = shop as unknown as Record<string, string | undefined> | 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 (
<div className="shop-row">
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">{title}</h3>
<p className="panel-sub">Today&rsquo;s performance and where customers find you</p>
</div>
<span className="pill" data-tone="healthy"><i />Active</span>
</header>
<div className="shop-id">
<span className="shop-mark" aria-hidden><Store size={24} /></span>
<div className="shop-idtext">
<strong className="shop-title">{name}</strong>
{where ? <p className="shop-meta"><MapPin size={13} />{where}</p> : null}
{record?.['primarycontact'] ? (
<p className="shop-meta"><Phone size={13} />{record['primarycontact']}</p>
) : null}
</div>
</div>
{/* 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. */}
<div className="perf-row">
<Figure
icon={<ShoppingCart size={13} />}
label="Online"
value={money(row.onlineRevenue)}
note={`${count(row.onlineOrders)} ${row.onlineOrders === 1 ? 'order' : 'orders'}`}
/>
<Figure
icon={<Receipt size={13} />}
label="Counter"
value={money(row.counterRevenue)}
note={`${count(row.counterBills)} ${row.counterBills === 1 ? 'bill' : 'bills'}`}
/>
<Figure label="Total" value={money(total)} note="both channels" isStrong />
</div>
</section>
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Quick actions</h3>
<p className="panel-sub">The three things most often done from here</p>
</div>
</header>
<div className="quick-list">
<QuickAction to={`${base}/inventory?tab=products`} icon={<Eye size={15} />} label="View store" note="what a shopper sees" />
<QuickAction to={`${base}/inventory?tab=catalogue`} icon={<Boxes size={15} />} label="Manage catalogue" note="add and price products" />
<QuickAction to={`${base}/inventory?tab=stock`} icon={<RefreshCw size={15} />} label="Update inventory" note="upload today's counts" />
</div>
</section>
</div>
);
}
function Figure({
icon, label, value, note, isStrong,
}: { icon?: ReactNode; label: string; value: string; note: string; isStrong?: boolean }) {
return (
<div className="perf" data-strong={isStrong ? 'yes' : 'no'}>
<span className="perf-label">{icon}{label}</span>
<strong>{value}</strong>
<span className="perf-note">{note}</span>
</div>
);
}
function QuickAction({
to, icon, label, note,
}: { to: string; icon: ReactNode; label: string; note: string }) {
return (
<Link to={to} className="quick">
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
<span className="quick-text">
<span className="quick-label">{label}</span>
<span className="quick-note">{note}</span>
</span>
<ChevronRight size={15} className="quick-chev" />
</Link>
);
}

View File

@@ -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<string, DaySeries>();
@@ -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 ─────────────────────────────────────────────────────── */