ui/ux improvement
This commit is contained in:
@@ -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. */
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user