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 { import {
useLocationProducts, useLocationProducts,
useLocationSummary, useLocationSummary,
useOwnTenant,
usePosHealthByBranch, usePosHealthByBranch,
usePosSalesByBranch, usePosSalesByBranch,
useStockRequests, useStockRequests,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel'; import { buildAlerts, totalsOf, type BranchRow } from './consoleModel';
import { consoleScope } from './consoleScope'; import { consoleScope } from './consoleScope';
import { previousRange, useConsoleSeries } from './useConsoleSeries'; import { useConsoleSeries } from './useConsoleSeries';
import { BranchOverview } from './sections/BranchOverview'; import { BranchOverview } from './sections/BranchOverview';
import { KpiStrip } from './sections/KpiStrip'; import { KpiStrip } from './sections/KpiStrip';
import { NeedsAttention } from './sections/NeedsAttention'; import { NeedsAttention } from './sections/NeedsAttention';
import { SalesOverview } from './sections/SalesOverview'; import { SalesOverview } from './sections/SalesOverview';
import { StoreHealth } from './sections/StoreHealth'; import { StoreHealth } from './sections/StoreHealth';
import { TillSync } from './sections/TillSync'; import { TillSync } from './sections/TillSync';
import { YourShop } from './sections/YourShop'; import { QuickActions } from './sections/QuickActions';
import './console.css'; import './console.css';
/** /**
@@ -53,14 +52,11 @@ export function ConsolePage() {
const base = isPinned ? '/store' : '/admin'; const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today'); const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today')); const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const prev = useMemo(() => previousRange(range), [range]);
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined); const orders = useLocationSummary(tenantid || undefined);
const posNow = usePosSalesByBranch(branchIds, range); const posNow = usePosSalesByBranch(branchIds, range);
const posBefore = usePosSalesByBranch(branchIds, prev);
const posHealth = usePosHealthByBranch(branchIds); const posHealth = usePosHealthByBranch(branchIds);
const shop = useOwnTenant(tenantid || undefined);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, { const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
allBranches: true, allBranches: true,
}); });
@@ -72,12 +68,7 @@ export function ConsolePage() {
() => posNow.flatMap((query) => (query.data ? [query.data] : [])), () => posNow.flatMap((query) => (query.data ? [query.data] : [])),
[posNow], [posNow],
); );
const posBeforeData = useMemo( const series = useConsoleSeries(tenantid, selected, range, posNowData);
() => posBefore.flatMap((query) => (query.data ? [query.data] : [])),
[posBefore],
);
const series = useConsoleSeries(tenantid, selected, range, posNowData, posBeforeData);
// One instant for the whole board, so two tills read a second apart are not // One instant for the whole board, so two tills read a second apart are not
// judged against two different "nows". // judged against two different "nows".
@@ -122,7 +113,6 @@ export function ConsolePage() {
}; };
}, [rows, series]); }, [rows, series]);
const rollup = useMemo(() => rollupHealth(rows), [rows]);
const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]); const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]);
const scope = consoleScope({ const scope = consoleScope({
@@ -162,24 +152,24 @@ export function ConsolePage() {
</div> </div>
</header> </header>
<KpiStrip <KpiStrip totals={totals} isLoading={isBusy} />
totals={totals}
days={series.days}
trends={series.trends}
comparisonLabel={preset === 'today' ? 'yesterday' : 'previous period'}
isLoading={isBusy}
/>
<div className="console-split"> {/* 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} /> <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 <StoreHealth
rows={rows} rows={rows}
totals={totals} totals={totals}
rollup={rollup}
isAggregate={scope.isAggregate} isAggregate={scope.isAggregate}
productCount={(products.data ?? []).length} productCount={(products.data ?? []).length}
base={base} base={base}
/> />
<QuickActions base={base} />
</div> </div>
{scope.showBranchOverview ? ( {scope.showBranchOverview ? (
@@ -194,9 +184,7 @@ export function ConsolePage() {
</header> </header>
<BranchOverview rows={rows} onSelect={select} /> <BranchOverview rows={rows} onSelect={select} />
</section> </section>
) : ( ) : null}
<YourShop row={rows[0]} shop={shop.data} title={scope.shopSectionTitle} base={base} />
)}
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} /> <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; } .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 { .kpi {
border: 1px solid var(--color-line); border-radius: 16px; border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 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 { .kpi-icon {
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
background: var(--color-brand-tint); color: var(--color-brand); 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 /* 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 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 has stopped doing its job. The min-height keeps all five value rows level
whether the label took one line or two. */ 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-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-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 ─────────────────────────────────────────────────────────────── */ /* ── Panels ─────────────────────────────────────────────────────────────── */
.panel { .panel {
@@ -109,10 +105,6 @@
.panel-empty strong { font: 600 14px/1.3 var(--font-sans); color: var(--color-ink-1); } .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); } .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 ──────────────────────────────────────── */ /* ── Shared: tiles, pills, buttons ──────────────────────────────────────── */
.tile { .tile {
width: 34px; height: 34px; flex: none; border-radius: 10px; display: grid; place-items: center; 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-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-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 ──────────────────────────────────────────────────────────── */ /* ── Quick actions ──────────────────────────────────────────────────────── */
/* Two panels, not three. The shop and its takings are one statement; the /* Store health beside quick actions. Health carries four rows of detail and
actions are the other. Three columns each doing a third of a job left every earns the larger share; the actions are three doors. */
card half empty and stretched to the tallest of them. */ .panel-pair { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
.shop-row { 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); } }
@media (min-width: 900px) { .shop-row { grid-template-columns: minmax(0, 1.6fr) 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-list { display: flex; flex-direction: column; gap: 8px; }
.quick { .quick {

View File

@@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert';
import { test } from 'node:test'; import { test } from 'node:test';
import type { TenantLocation } from '@/api/types'; import type { TenantLocation } from '@/api/types';
import type { BranchSyncSummary, TerminalState } from '@/features/store-admin/posStatus'; 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 => ({ const health = (over: Partial<BranchSyncSummary> = {}): BranchSyncSummary => ({
state: 'synced' as TerminalState, 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'); 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 ───────────────────────────────────────────────────────────────── */ /* ── Alerts ───────────────────────────────────────────────────────────────── */
test('a healthy shop raises nothing at all', () => { 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 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. * How one branch is doing, in three words rather than six states.
* *
@@ -88,17 +82,6 @@ export function branchTone(health: BranchSyncSummary): HealthTone {
return 'healthy'; 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 ────────────────────────────────────────────────────────────── */ /* ── Attention ────────────────────────────────────────────────────────────── */
export interface ConsoleAlert { export interface ConsoleAlert {

View File

@@ -1,36 +1,32 @@
import type { ReactNode } from 'react'; 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 { count, money } from '@/features/store-admin/format';
import type { ConsoleTotals } from '../consoleModel'; import type { ConsoleTotals } from '../consoleModel';
import type { DaySeries, Trend } from '../useConsoleSeries';
/** /**
* The five figures the board opens with. * The five figures the board opens with.
* *
* Each card carries the number, the shape of how it got there, and whether that * Icon, label, number. Nothing else.
* 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.
* *
* Trend arrows are drawn only where a real comparison exists. `Trend.isNew` * ── What used to be here, and why it went ───────────────────────────────────
* marks the case where the previous period was zero — "↑ ∞%" is not a fact, so *
* it reads "new" instead. * 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 { export interface KpiStripProps {
totals: ConsoleTotals; totals: ConsoleTotals;
days: readonly DaySeries[];
trends: {
online: Trend;
counter: Trend;
orders: Trend;
cancelled: Trend;
};
comparisonLabel: string;
isLoading: boolean; isLoading: boolean;
} }
export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: KpiStripProps) { export function KpiStrip({ totals, isLoading }: KpiStripProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="kpi-strip" aria-busy="true"> <div className="kpi-strip" aria-busy="true">
@@ -43,129 +39,48 @@ export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: K
return ( return (
<div className="kpi-strip"> <div className="kpi-strip">
<Kpi <Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
icon={<ShoppingCart size={17} />} <Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
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 <Kpi
icon={<ListChecks size={17} />} icon={<ListChecks size={17} />}
label="Total Orders" label="Total Orders"
value={count(totals.totalOrders)} value={count(totals.totalOrders)}
trend={trends.orders} note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.onlineOrders + d.counterBills)}
/> />
<Kpi <Kpi
icon={<Ban size={17} />} icon={<Ban size={17} />}
label="Cancelled Orders" label="Cancelled Orders"
value={count(totals.cancelled)} value={count(totals.cancelled)}
trend={trends.cancelled} {...(totals.onlineOrders > 0
comparisonLabel={comparisonLabel} ? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
series={days.map(() => 0)} : {})}
// The only card where a rise is bad news, so the colours invert.
isInverted
/> />
<Kpi <Kpi
icon={<CloudUpload size={17} />} icon={<CloudUpload size={17} />}
label="Unsynced Bills" label="Unsynced Bills"
value={count(totals.pendingBills)} 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'} note={totals.pendingBills === 0 ? 'all bills are in the books' : 'waiting on a till'}
series={days.map(() => 0)}
isInverted
/> />
</div> </div>
); );
} }
function Kpi({ function Kpi({
icon, label, value, trend, comparisonLabel, series, note, isInverted, icon, label, value, note,
}: { }: {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
value: string; value: string;
trend?: Trend;
comparisonLabel?: string;
series: readonly number[];
note?: string; 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 ( return (
<article className="kpi"> <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> <span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head"> <div className="kpi-head">
<span className="kpi-label">{label}</span> <span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span> <span className="kpi-value">{value}</span>
</div> {note ? <span className="kpi-note">{note}</span> : null}
</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'} />
</div> </div>
</article> </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 { Link } from 'react-router-dom';
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react'; import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
import { count } from '@/features/store-admin/format'; 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. * 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 { export interface StoreHealthProps {
rows: readonly BranchRow[]; rows: readonly BranchRow[];
totals: ConsoleTotals; totals: ConsoleTotals;
rollup: HealthRollup;
isAggregate: boolean; isAggregate: boolean;
productCount: number; productCount: number;
base: string; base: string;
} }
export function StoreHealth({ export function StoreHealth({
rows, totals, rollup, isAggregate, productCount, base, rows, totals, isAggregate, productCount, base,
}: StoreHealthProps) { }: StoreHealthProps) {
const undecided = rows.reduce((sum, row) => sum + row.pendingRequests, 0); const undecided = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
const withoutTill = rows.filter((row) => row.health.total === 0).length; const withoutTill = rows.filter((row) => row.health.total === 0).length;
@@ -44,18 +43,14 @@ export function StoreHealth({
<header className="panel-head"> <header className="panel-head">
<div> <div>
<h3 className="panel-title">Store health</h3> <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> </div>
</header> </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"> <div className="health-list">
<HealthLine <HealthLine
icon={<Monitor size={16} />} 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({ function HealthLine({
icon, name, detail, tone, action, href, 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'; 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 ──────────────────────────── * ── 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 * online series is grouped from the order rows themselves, which carry
* `orderdate` and an amount and can be asked for a date range. * `orderdate` and an amount and can be asked for a date range.
* *
* ── Why the previous period is fetched at all ─────────────────────────────── * 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
* "↑ 12% vs. yesterday" is a claim, and a dashboard that invents it is worse * them — one request per branch, plus an orders query, for a line nobody asked
* than one that omits it. The comparison window is the same length as the one * to see.
* 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.
*/ */
export interface DaySeries { export interface DaySeries {
@@ -31,73 +28,23 @@ export interface DaySeries {
counterBills: number; 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 { export interface ConsoleSeries {
days: DaySeries[]; days: DaySeries[];
onlineRevenue: number; onlineRevenue: number;
onlineOrders: number; onlineOrders: number;
cancelled: number; cancelled: number;
trends: {
online: Trend;
counter: Trend;
orders: Trend;
cancelled: Trend;
};
isLoading: boolean; 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( export function useConsoleSeries(
tenantid: number, tenantid: number,
locationid: number | null, locationid: number | null,
range: DateRange, range: DateRange,
posNow: readonly PosSalesSummary[], posNow: readonly PosSalesSummary[],
posBefore: readonly PosSalesSummary[],
): ConsoleSeries { ): ConsoleSeries {
const prev = useMemo(() => previousRange(range), [range]);
const nowOrders = useOrders( const nowOrders = useOrders(
tenantid ? { tenantid, ...(locationid ? { locationid } : {}), ...range, pagesize: 500 } : undefined, tenantid ? { tenantid, ...(locationid ? { locationid } : {}), ...range, pagesize: 500 } : undefined,
); );
const beforeOrders = useOrders(
tenantid && prev.fromdate
? { tenantid, ...(locationid ? { locationid } : {}), ...prev, pagesize: 500 }
: undefined,
);
return useMemo(() => { return useMemo(() => {
const byDay = new Map<string, DaySeries>(); 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 { return {
days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)), days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)),
onlineRevenue, onlineRevenue,
onlineOrders, onlineOrders,
cancelled, cancelled,
trends: {
online: trendOf(onlineRevenue, beforeOnline),
counter: trendOf(counterNow, counterBefore),
orders: trendOf(onlineOrders + billsNow, beforeOrders_ + billsBefore),
cancelled: trendOf(cancelled, beforeCancelled),
},
isLoading: nowOrders.isLoading, isLoading: nowOrders.isLoading,
}; };
}, [nowOrders.data, nowOrders.isLoading, beforeOrders.data, posNow, posBefore]); }, [nowOrders.data, nowOrders.isLoading, posNow]);
} }
/* ── Reading the wire ─────────────────────────────────────────────────────── */ /* ── Reading the wire ─────────────────────────────────────────────────────── */