172 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|