ui/ux improvement

This commit is contained in:
2026-09-18 16:57:24 +05:30
parent 115a06a02c
commit 041dc37861
43 changed files with 1697 additions and 746 deletions

View File

@@ -184,59 +184,258 @@
.link-quiet { font: 500 12.5px/1 var(--font-sans); color: var(--color-brand); text-decoration: none; }
.link-quiet:hover { text-decoration: underline; }
/* ── Sales overview ─────────────────────────────────────────────────────── */
.sales-split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 720px) { .sales-split { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
/* ── Sales overview ─────────────────────────────────────────────────────────
.chart-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; }
.chart-axis {
display: flex; flex-direction: column; justify-content: space-between; height: 190px;
text-align: right; font: 400 10.5px/1 var(--font-sans); color: var(--color-ink-4);
The chart's two colours are here, and they are measured rather than chosen.
The pair before them was the brand purple and the brand at 32% on white, and
the palette checks failed three ways: #662582 sits at OKLCH L 0.406, below
the 0.43 floor for a chart mark; the 32% mix came out at chroma 0.047, which
reads as grey rather than as a colour; and it cleared 1.82:1 against white,
under the 3:1 minimum. The second channel was a mark you could not reliably
see.
These two clear every check on white with no warnings — lightness band,
chroma floor, CVD separation (ΔE 27.4 worst adjacent under protanopia),
normal-vision floor (29.4) and contrast. Purple + BLUE was the obvious
alternative and it fails: ΔE 5.8 under deuteranopia, well under the 8 target.
--chart-online is a lighter step of the Nearle purple rather than the brand
itself, because the brand is out of the band for a mark. It is used as this
series and nowhere else.
Dark mode is not reachable today (main.tsx pins mode="light"), so only the
light pair is validated. A dark theme needs its own steps and its own run of
the checks, not an automatic flip. */
:root {
--chart-online: #8b3fb5;
--chart-counter: #e0bdf0; /* Lighter tint of primary purple */
}
.chart-block { display: flex; flex-direction: column; gap: 16px; }
/* ── The plot ───────────────────────────────────────────────────────────────
Two stacked rows — plot and day labels — so the container's height INCLUDES
the x-axis band. A fixed height around the plot alone is what gives a card
its own little nested scrollbar. */
.plot {
position: relative;
display: grid;
grid-template-rows: 200px auto;
padding-left: 46px; /* the axis gutter the tick labels sit in */
}
/* The grid is drawn under the marks: hairline, solid, one step off the
surface. Never dashed — dashing reads as "threshold" when it is just a
grid. */
.plot-grid {
grid-row: 1;
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.plot-gridline {
position: relative;
border-top: 0;
}
.plot-tick {
position: absolute;
right: calc(100% + 8px);
top: -0.55em;
font: 400 10.5px/1 var(--font-sans);
color: var(--color-ink-4);
font-variant-numeric: tabular-nums; /* a column of numbers: tabular */
white-space: nowrap;
}
.plot-cols {
grid-row: 1;
grid-column: 1;
display: flex;
align-items: flex-end;
gap: 4px;
min-width: 0;
}
.plot-col {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
border: 0;
background: none;
}
.plot-col:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* The stack. Capped at 24px — a column that fills its slot leaves no air, and
the leftover band is what makes the hover target bigger than the mark. */
.plot-stack {
display: flex;
flex-direction: column-reverse; /* counter at the base, online on top */
width: 100%;
max-width: 28px;
min-height: 2px;
gap: 0; /* removed surface gap between segments */
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
border-radius: 6px 6px 0 0;
}
.plot-col[data-hover='true'] .plot-stack { opacity: 0.78; }
.plot-seg { min-height: 0; }
/* 4px rounded data-end, square at the baseline: only the top of the stack is
rounded, and only because it is the top. */
.plot-stack > .plot-seg:last-child { border-radius: 4px 4px 0 0; }
.plot-seg[data-kind='online'] { background: var(--chart-online); }
.plot-seg[data-kind='counter'] { background: var(--chart-counter); }
/* One direct label, on the tallest column. Selective by design — a number on
every column is chaos and goes unread. */
.plot-peak {
position: absolute;
left: 50%;
transform: translate(-50%, -4px);
font: 600 10.5px/1 var(--font-sans);
color: var(--color-ink-2); /* a text token, never the series colour */
white-space: nowrap;
pointer-events: none;
}
/* ── Tooltip ────────────────────────────────────────────────────────────── */
.plot-tip {
position: absolute;
/* Pinned to the top of the PLOT, not floated above the bar.
Anchored to the bar it was clipped: the panel carries `overflow: hidden`
so its header band cannot square off the card corners, and the tallest
column's tooltip ran straight through the top edge and lost its first
row. Hanging from the plot ceiling it is always inside the card, whatever
the column height, and the hovered column is tinted so the pairing is
still obvious. */
top: 0;
left: 50%;
transform: translate(-50%, 0);
z-index: 5;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 152px;
padding: 9px 11px;
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 0.18);
pointer-events: none;
}
.plot-tip-day {
font: 600 11.5px/1.2 var(--font-sans);
color: var(--color-ink-1);
padding-bottom: 3px;
border-bottom: 1px solid var(--color-line);
}
.plot-tip-row {
display: flex;
align-items: center;
gap: 7px;
font: 400 11.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
}
.plot-tip-row b {
margin-left: auto;
font-weight: 600;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.chart2 {
display: flex; align-items: flex-end; gap: 10px; height: 190px; overflow-x: auto;
border-left: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); padding: 0 6px;
}
.chart2-col { flex: 1 0 34px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; }
.chart2-bars { display: flex; align-items: flex-end; gap: 3px; height: 100%; width: 100%; justify-content: center; }
.chart2-bar { width: 11px; border-radius: 4px 4px 0 0; min-height: 2px; transition: opacity .15s ease; }
.chart2-bar[data-kind="online"] { background: var(--color-brand); }
/* The second channel is the same hue at lower weight, so the pair reads as one
family rather than two unrelated categories. */
.chart2-bar[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.chart2-col:hover .chart2-bar { opacity: .78; }
.chart2-label { font: 500 10.5px/1 var(--font-sans); color: var(--color-ink-4); white-space: nowrap; }
.chart-legend { grid-column: 2; display: flex; gap: 16px; padding-top: 6px; }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; font: 400 12px/1 var(--font-sans); color: var(--color-ink-3); }
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind="online"] { background: var(--color-brand); }
.chart-legend i[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.donut-wrap { display: flex; align-items: center; justify-content: center; }
.donut { position: relative; width: 160px; height: 160px; }
.donut svg { width: 100%; height: 100%; }
.donut-track { fill: none; stroke: var(--color-surface-subtle); stroke-width: 18; }
.donut-fill { fill: none; stroke: var(--color-brand); stroke-width: 18; transition: stroke-dasharray .4s ease; }
.donut-centre { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; }
.donut-centre span { font: 400 11px/1 var(--font-sans); color: var(--color-ink-3); }
.donut-centre strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.donut-key { width: 10px; height: 10px; border-radius: 999px; flex: none; }
.donut-key[data-kind="online"] { background: var(--color-brand); }
.donut-key[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
/* The channel split, on one line beneath the card. Two columns so the pair
sits square against each other; stacked only when there is genuinely no room
for both. */
.split-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-top: 14px;
.plot-tip-row[data-total='true'] {
padding-top: 3px;
border-top: 1px solid var(--color-line);
}
.plot-tip-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.plot-tip-row i[data-kind='online'] { background: var(--chart-online); }
.plot-tip-row i[data-kind='counter'] { background: var(--chart-counter); }
/* Flip at the edges so the first and last day's tooltip stays on the card. */
.plot-col:first-child .plot-tip { left: 0; transform: translate(0, 0); }
.plot-col:last-child .plot-tip { left: auto; right: 0; transform: translate(0, 0); }
/* ── Day labels ─────────────────────────────────────────────────────────── */
.plot-days {
grid-row: 2;
grid-column: 1;
display: flex;
gap: 4px;
padding-top: 8px;
}
.plot-day {
flex: 1 1 0;
min-width: 0;
text-align: center;
font: 400 10.5px/1.2 var(--font-sans);
color: var(--color-ink-4);
white-space: nowrap;
}
/* ── Legend and the table toggle ────────────────────────────────────────── */
.chart-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.chart-legend { display: flex; gap: 16px; }
.chart-legend span {
display: inline-flex; align-items: center; gap: 6px;
font: 400 12px/1 var(--font-sans); color: var(--color-ink-3);
}
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind='online'] { background: var(--chart-online); }
.chart-legend i[data-kind='counter'] { background: var(--chart-counter); }
.chart-toggle {
border: 0; background: none; padding: 0; cursor: pointer;
font: 600 12px/1 var(--font-sans); color: var(--color-brand);
}
.chart-toggle:hover { text-decoration: underline; }
.chart-toggle:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
/* ── Table view ─────────────────────────────────────────────────────────── */
.chart-table { max-height: 232px; overflow-y: auto; }
.chart-table table { width: 100%; border-collapse: collapse; }
.chart-table th {
position: sticky; top: 0;
text-align: left; padding: 7px 10px;
background: var(--color-surface-subtle);
font: 600 10.5px/1 var(--font-sans);
letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-ink-3);
border-bottom: 1px solid var(--color-line);
}
.chart-table td {
padding: 8px 10px;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-1);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
}
.chart-table tr:last-child td { border-bottom: 0; }
.chart-table .num { text-align: right; font-variant-numeric: tabular-nums; }
/* ── The channel split ──────────────────────────────────────────────────────
One bar, then the two figures. This is what a 160px two-slice donut was
doing, in the form that can be read rather than estimated. */
.split {
display: flex; flex-direction: column; gap: 10px;
padding-top: 14px; border-top: 1px solid var(--color-line);
}
.split-bar { display: flex; height: 10px; gap: 2px; }
.split-bar span:first-child { border-radius: 999px 0 0 999px; }
.split-bar span:last-child { border-radius: 0 999px 999px 0; }
.split-bar span[data-kind='online'] { background: var(--chart-online); }
.split-bar span[data-kind='counter'] { background: var(--chart-counter); }
.split-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@media (max-width: 520px) { .split-row { grid-template-columns: minmax(0, 1fr); } }
.share {
@@ -245,15 +444,22 @@
border: var(--card-border); border-radius: var(--card-radius-sm);
background: var(--card-bg-subtle);
}
.share .donut-key { align-self: center; }
.share-key { width: 10px; height: 10px; border-radius: 999px; flex: none; align-self: center; }
.share-key[data-kind='online'] { background: var(--chart-online); }
.share-key[data-kind='counter'] { background: var(--chart-counter); }
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
/* The percentage leads: the question the split answers is "how much of my
trade comes through each", and the rupee figure is already in the KPI card
above. */
above. Proportional figures, not tabular — this is a standalone value, not a
column of them. */
.share-pct { font: 600 18px/1 var(--font-sans); color: var(--color-ink-1); margin-left: auto; }
.share-amt {
font: 500 13px/1 var(--font-sans); color: var(--color-brand);
font-variant-numeric: tabular-nums;
.share-amt { font: 500 13px/1 var(--font-sans); color: var(--color-ink-2); font-variant-numeric: tabular-nums; }
@media (max-width: 600px) {
.plot { padding-left: 40px; grid-template-rows: 170px auto; }
}
@media (prefers-reduced-motion: reduce) {
.plot-stack { transition: none; }
}
/* ── Store health ───────────────────────────────────────────────────────── */
@@ -309,26 +515,22 @@
.heartbeat[data-tone="critical"] i { background: #b3261e; }
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.chip-row { display: inline-flex; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: 999px; cursor: pointer; border: 1px solid var(--color-line);
background: var(--color-surface); font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2);
}
.chip b { font-weight: 600; opacity: .7; }
.chip[data-active="true"] { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
.chip[data-tone="attention"][data-active="true"] { background: #8a5a00; border-color: #8a5a00; }
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* `.chip` and `.chip-row` were here — the filter strip on the Console’s Till
sync panel and on Counters. Both render `components/TabBar` at `size="sm"`
now, the same control as the Sales status ladder.
The tones went with them: green on Healthy, amber on Attention, red on
Offline. They only ever applied to the SELECTED chip, so a strip at rest
was plain pills anyway, and this console’s position is that it does not
colour-code severity — see the note where `BUCKET_COLOR` used to be in
`terminalProblems.ts`. The label and the count say which rung is bad. */
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
input version of the search box. Both use `components/SearchInput` now. */
/* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
.seg-btn { border: 0; background: none; padding: 6px 14px; border-radius: 8px; font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2); cursor: pointer; }
.seg-btn[data-active="yes"] { background: var(--color-brand); color: #fff; }
.seg-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* The `.seg` segmented control was here — an eighth treatment of "tab", used by
the Sales overview's Revenue / Orders switch. That switch is
`components/TabBar` at `size="sm"` now, like every other one in the console. */
/* ── Needs your attention ───────────────────────────────────────────────── */
.attention-head { display: flex; gap: 12px; align-items: flex-start; }
@@ -371,5 +573,7 @@
@media (prefers-reduced-motion: reduce) {
.sk-card, .sk-chart, .console-live i { animation: none; }
.chart2-bar, .donut-fill { transition: none; }
/* `.chart2-bar` and `.donut-fill` were named here. Both are gone — the chart
is stacked columns and the split is a bar, not a ring — and the column's
own transition is handled beside it, in the Sales overview block. */
}

View File

@@ -1,20 +1,37 @@
import { useMemo, useState } from 'react';
import { Tab, TabBar } from '@/components/TabBar';
import { money, count } from '@/features/store-admin/format';
import type { DaySeries } from '../useConsoleSeries';
/**
* Revenue and orders over the period: the shape on the left, the split on the
* right.
* Revenue and orders over the period.
*
* Two views of one dataset, deliberately. The columns answer "when did it
* happen"; the ring answers "where did it come from". Either alone leaves a
* shopkeeper doing arithmetic in their head — a good week of counter sales and
* a collapse in app orders look identical in a single total.
* ── What this replaced, and why each piece went ─────────────────────────────
*
* Drawn by hand rather than with a charting library. It is a few dozen columns
* and one ring; a library would be more code shipped than the chart it draws,
* and this way the bars use the same brand token as everything else on the page
* instead of a palette of its own.
* **The donut is gone.** It was a ring showing one share — online against
* counter — with the same two figures spelled out in a row directly beneath
* it. A two-slice ring is the textbook case for "the number is the chart": it
* spends 160×160px to say what "68% / 32%" says in nine characters, and it says
* it less precisely. The share is now a single slim bar above the two figures,
* which is the form for part-to-whole and doubles as the legend key.
*
* **The columns are stacked, not grouped.** Two thin bars per day meant 60
* marks across a 30-day range at ~11px each. Stacked, a column's height is the
* day's takings — the thing a shopkeeper actually reads off this chart — and
* the split is still visible inside it.
*
* **The colours are measured, not chosen.** The old pair was the brand purple
* and the brand at 32% on white, and running them through the palette checks
* failed three ways: the brand sits at OKLCH L 0.406, below the 0.43 floor for
* a chart mark; the 32% mix landed at chroma 0.047, which reads as grey rather
* than as a colour; and it cleared only 1.82:1 against white, under the 3:1
* minimum. So the second channel was a mark you could not reliably see. The
* pair below is validated — see `--chart-online` in `console.css`.
*
* **There is a real tooltip.** Values used to live in `title` attributes: a
* browser tooltip after a second's delay, unstyled, invisible on touch. And a
* table view, because with a stacked column the per-channel figure for one day
* exists nowhere else on the page — a tooltip must enhance, never gate.
*/
export interface SalesOverviewProps {
days: readonly DaySeries[];
@@ -25,6 +42,8 @@ type Measure = 'revenue' | 'orders';
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
const [measure, setMeasure] = useState<Measure>('revenue');
const [isTable, setIsTable] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const series = useMemo(
() =>
@@ -36,16 +55,41 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
[days, measure],
);
/* The axis tops out at a round number at or above the tallest column, so the
ticks read 0 / 5,000 / 10,000 rather than 0 / 4,317 / 8,634. */
const peak = useMemo(
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
() => Math.max(1, ...series.map((d) => d.online + d.counter)),
[series],
);
const axisTop = useMemo(() => niceCeiling(peak), [peak]);
const ticks = useMemo(
() => [1, 0.75, 0.5, 0.25, 0].map((step) => Math.round(axisTop * step)),
[axisTop],
);
/* Labelled directly, because it is the one column worth naming. Everything
else is carried by the axis, the tooltip and the table. */
const peakIndex = useMemo(() => {
let best = -1;
let bestTotal = 0;
series.forEach((d, i) => {
const total = d.online + d.counter;
if (total > bestTotal) {
bestTotal = total;
best = i;
}
});
return best;
}, [series]);
const online = series.reduce((sum, d) => sum + d.online, 0);
const counter = series.reduce((sum, d) => sum + d.counter, 0);
const total = online + counter;
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
const axisFormat = (value: number) =>
measure === 'revenue' ? compactMoney(value) : count(value);
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
: `Revenue and orders for the last ${days.length} days`}
</p>
</div>
<div className="seg" role="tablist" aria-label="Measure">
{(['revenue', 'orders'] as const).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={measure === m}
className="seg-btn"
data-active={measure === m ? 'yes' : 'no'}
onClick={() => setMeasure(m)}
>
{m === 'revenue' ? 'Revenue' : 'Orders'}
</button>
))}
</div>
<TabBar size="sm" aria-label="Measure">
<Tab
label="Revenue"
isActive={measure === 'revenue'}
onClick={() => setMeasure('revenue')}
/>
<Tab label="Orders" isActive={measure === 'orders'} onClick={() => setMeasure('orders')} />
</TabBar>
</header>
{total === 0 ? (
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
</div>
) : (
<div className="sales-split">
<div className="chart-wrap">
{/* The axis is drawn from the data rather than fixed, so a shop
taking ₹800 a day gets a readable scale instead of a flat line
along the bottom of a ₹20K grid. */}
<div className="chart-axis" aria-hidden>
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
<span key={step}>{format(Math.round(peak * step))}</span>
))}
</div>
<div className="chart2">
{series.map((d) => (
<div key={d.day} className="chart2-col">
<div className="chart2-bars">
<span
className="chart2-bar"
data-kind="online"
style={{ height: `${(d.online / peak) * 100}%` }}
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
/>
<span
className="chart2-bar"
data-kind="counter"
style={{ height: `${(d.counter / peak) * 100}%` }}
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
/>
<div className="chart-block">
{isTable ? (
<SeriesTable rows={series} format={format} />
) : (
<div
className="plot"
onMouseLeave={() => setHovered(null)}
role="img"
aria-label={`${measure === 'revenue' ? 'Revenue' : 'Orders'} by day, online and counter`}
>
{/* The grid is drawn first and sits under the marks: hairline,
solid, one step off the surface. Its rows and the axis labels
come from the same tick list, so they cannot drift apart. */}
<div className="plot-grid" aria-hidden>
{ticks.map((tick) => (
<div key={tick} className="plot-gridline">
<span className="plot-tick">{axisFormat(tick)}</span>
</div>
<span className="chart2-label">{shortDay(d.day)}</span>
</div>
))}
</div>
))}
</div>
<div className="chart-legend">
<span><i data-kind="online" />Online</span>
<span><i data-kind="counter" />Counter</span>
<div className="plot-cols">
{series.map((d, index) => {
const dayTotal = d.online + d.counter;
return (
<div
key={d.day}
className="plot-col"
data-hover={hovered === index}
onMouseEnter={() => setHovered(index)}
onFocus={() => setHovered(index)}
onBlur={() => setHovered(null)}
tabIndex={0}
aria-label={`${longDay(d.day)}: online ${format(d.online)}, counter ${format(d.counter)}`}
>
{/* The stack. Counter sits under online so the brand
colour caps the column, and the two are separated by a
gap in the surface rather than by a stroke. */}
<div className="plot-stack" style={{ height: `${(dayTotal / axisTop) * 100}%` }}>
<span
className="plot-seg"
data-kind="online"
style={{ flexBasis: `${pct(d.online, dayTotal)}%` }}
/>
<span
className="plot-seg"
data-kind="counter"
style={{ flexBasis: `${pct(d.counter, dayTotal)}%` }}
/>
</div>
{index === peakIndex ? (
<span className="plot-peak" style={{ bottom: `${(dayTotal / axisTop) * 100}%` }}>
{format(dayTotal)}
</span>
) : null}
{hovered === index ? (
<div className="plot-tip" role="status">
<span className="plot-tip-day">{longDay(d.day)}</span>
<span className="plot-tip-row">
<i data-kind="online" />
Online
<b>{format(d.online)}</b>
</span>
<span className="plot-tip-row">
<i data-kind="counter" />
Counter
<b>{format(d.counter)}</b>
</span>
<span className="plot-tip-row" data-total="true">
Total<b>{format(dayTotal)}</b>
</span>
</div>
) : null}
</div>
);
})}
</div>
{/* Every date is drawn — see `showLabel`. */}
<div className="plot-days" aria-hidden>
{series.map((d) => (
<span key={d.day} className="plot-day">
{showLabel() ? shortDay(d.day) : ''}
</span>
))}
</div>
</div>
)}
<div className="chart-foot">
<div className="chart-legend">
<span>
<i data-kind="online" />
Online
</span>
<span>
<i data-kind="counter" />
Counter
</span>
</div>
<button type="button" className="chart-toggle" onClick={() => setIsTable((v) => !v)}>
{isTable ? 'Show chart' : 'Show table'}
</button>
</div>
<div className="donut-wrap">
<Donut share={onlineShare} total={format(total)} />
{/* The channel split: one bar, then the figures. This is what the
donut was for, in a form that can be read rather than estimated. */}
<div className="split">
<div className="split-row">
<Share kind="online" label="Online" percent={onlineShare} amount={format(online)} />
<Share
kind="counter"
label="Counter"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
</div>
</div>
)}
{/* The channel split, on one line under the whole card.
It was a stacked column beside the ring, which put the two figures a
shopkeeper compares — online against counter — one above the other in
a narrow gutter. Side by side they can be read against each other at a
glance, and the row runs the full width rather than being squeezed
into whatever the ring left over. */}
{total > 0 ? (
<div className="split-row">
<Share
kind="online"
label="Online Sales"
percent={onlineShare}
amount={format(online)}
/>
<Share
kind="counter"
label="Counter Sales"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
) : null}
</section>
);
}
function Share({
kind, label, percent, amount,
}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) {
kind,
label,
percent,
amount,
}: {
kind: 'online' | 'counter';
label: string;
percent: number;
amount: string;
}) {
return (
<div className="share">
<span className="donut-key" data-kind={kind} />
<span className="share-key" data-kind={kind} />
<span className="share-label">{label}</span>
<strong className="share-pct">{percent}%</strong>
<span className="share-amt">{amount}</span>
@@ -170,42 +270,101 @@ function Share({
}
/**
* The channel split as a ring.
* The same numbers as a table.
*
* One circle with a dash offset rather than two arcs: a single stroke cannot
* develop a seam at the join, and the whole thing animates from one number.
* Not a nicety: in a stacked column the per-channel figure for a single day is
* otherwise only in the hover tooltip, which is no use on a touch screen, to a
* screen reader, or to anyone printing the page.
*/
function Donut({ share, total }: { share: number; total: string }) {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const filled = (share / 100) * circumference;
function SeriesTable({
rows,
format,
}: {
rows: readonly { day: string; online: number; counter: number }[];
format: (value: number) => string;
}) {
return (
<div className="donut">
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
<circle cx="70" cy="70" r={radius} className="donut-track" />
<circle
cx="70"
cy="70"
r={radius}
className="donut-fill"
strokeDasharray={`${filled} ${circumference - filled}`}
// Start at twelve o'clock; the default is three, which reads as a
// slice that has already been eaten.
transform="rotate(-90 70 70)"
/>
</svg>
<div className="donut-centre">
<span>Total Sales</span>
<strong>{total}</strong>
</div>
<div className="chart-table">
<table>
<thead>
<tr>
<th>Day</th>
<th className="num">Online</th>
<th className="num">Counter</th>
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.day}>
<td>{longDay(d.day)}</td>
<td className="num">{format(d.online)}</td>
<td className="num">{format(d.counter)}</td>
<td className="num">
<strong>{format(d.online + d.counter)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0);
/**
* The next round number at or above `value` — 1, 2 or 5 times a power of ten.
*
* An axis whose top is the tallest column produces ticks like 4,317 and 8,634,
* which nobody can read a bar against. This is what makes them 5,000 and
* 10,000.
*/
function niceCeiling(value: number): number {
if (value <= 0) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
/** "₹12.4K" for an axis tick, where the full figure would not fit the gutter. */
function compactMoney(value: number): string {
if (value >= 10_000_000) return `₹${(value / 10_000_000).toFixed(1).replace(/\.0$/, '')}Cr`;
if (value >= 100_000) return `₹${(value / 100_000).toFixed(1).replace(/\.0$/, '')}L`;
if (value >= 1_000) return `₹${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return money(value);
}
/**
* Which x labels to draw.
*
* Currently: all of them. The thinning this used to do — keep about six,
* always including the first and the last — is gone by choice, so every date
* is drawn.
*
* It keeps its own function rather than being inlined at the call site, so the
* decision has somewhere to live and thinning is one edit away if a 30-day
* range turns out to overlap. It takes NO arguments: it was passed the index
* and the length, and with the body returning a constant those became unused
* parameters, which `noUnusedParameters` fails the build on.
*/
function showLabel(): boolean {
return true;
}
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
function shortDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
}
/** "2026-09-03" → "Wed, 3 Sep" — the tooltip and table have room for the day. */
function longDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' });
}

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
@@ -99,17 +100,26 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
</div>
<div className="till-controls">
<div className="chip-row">
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
All <b>{tills.length}</b>
</button>
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
Attention <b>{needing}</b>
</button>
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
Healthy <b>{healthy}</b>
</button>
</div>
<TabBar size="sm" aria-label="Filter tills">
<Tab
label="All"
count={tills.length}
isActive={filter === 'all'}
onClick={() => setFilter('all')}
/>
<Tab
label="Attention"
count={needing}
isActive={filter === 'attention'}
onClick={() => setFilter('attention')}
/>
<Tab
label="Healthy"
count={healthy}
isActive={filter === 'healthy'}
onClick={() => setFilter('healthy')}
/>
</TabBar>
<SearchInput
label="Search tills"