redesign console page
This commit is contained in:
171
src/features/console/sections/KpiStrip.tsx
Normal file
171
src/features/console/sections/KpiStrip.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user