Files
daily_console_web/src/features/console/sections/KpiStrip.tsx
2026-09-03 12:31:07 +05:30

172 lines
6.0 KiB
TypeScript

import type { ReactNode } from 'react';
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart, TrendingDown, TrendingUp } 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.
*
* 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.
*/
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) {
if (isLoading) {
return (
<div className="kpi-strip" aria-busy="true">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="sk-card" />
))}
</div>
);
}
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={<ListChecks size={17} />}
label="Total Orders"
value={count(totals.totalOrders)}
trend={trends.orders}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.onlineOrders + d.counterBills)}
/>
<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
/>
<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: 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'} />
</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>
);
}