cards removed
This commit is contained in:
@@ -6,21 +6,20 @@ import { summariseBranch } from '@/features/store-admin/posStatus';
|
||||
import {
|
||||
useLocationProducts,
|
||||
useLocationSummary,
|
||||
useOwnTenant,
|
||||
usePosHealthByBranch,
|
||||
usePosSalesByBranch,
|
||||
useStockRequests,
|
||||
} from '@/queries/hooks';
|
||||
import { buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel';
|
||||
import { buildAlerts, totalsOf, type BranchRow } from './consoleModel';
|
||||
import { consoleScope } from './consoleScope';
|
||||
import { previousRange, useConsoleSeries } from './useConsoleSeries';
|
||||
import { useConsoleSeries } from './useConsoleSeries';
|
||||
import { BranchOverview } from './sections/BranchOverview';
|
||||
import { KpiStrip } from './sections/KpiStrip';
|
||||
import { NeedsAttention } from './sections/NeedsAttention';
|
||||
import { SalesOverview } from './sections/SalesOverview';
|
||||
import { StoreHealth } from './sections/StoreHealth';
|
||||
import { TillSync } from './sections/TillSync';
|
||||
import { YourShop } from './sections/YourShop';
|
||||
import { QuickActions } from './sections/QuickActions';
|
||||
import './console.css';
|
||||
|
||||
/**
|
||||
@@ -53,14 +52,11 @@ export function ConsolePage() {
|
||||
const base = isPinned ? '/store' : '/admin';
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const prev = useMemo(() => previousRange(range), [range]);
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posNow = usePosSalesByBranch(branchIds, range);
|
||||
const posBefore = usePosSalesByBranch(branchIds, prev);
|
||||
const posHealth = usePosHealthByBranch(branchIds);
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
|
||||
allBranches: true,
|
||||
});
|
||||
@@ -72,12 +68,7 @@ export function ConsolePage() {
|
||||
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
|
||||
[posNow],
|
||||
);
|
||||
const posBeforeData = useMemo(
|
||||
() => posBefore.flatMap((query) => (query.data ? [query.data] : [])),
|
||||
[posBefore],
|
||||
);
|
||||
|
||||
const series = useConsoleSeries(tenantid, selected, range, posNowData, posBeforeData);
|
||||
const series = useConsoleSeries(tenantid, selected, range, posNowData);
|
||||
|
||||
// One instant for the whole board, so two tills read a second apart are not
|
||||
// judged against two different "nows".
|
||||
@@ -122,7 +113,6 @@ export function ConsolePage() {
|
||||
};
|
||||
}, [rows, series]);
|
||||
|
||||
const rollup = useMemo(() => rollupHealth(rows), [rows]);
|
||||
const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]);
|
||||
|
||||
const scope = consoleScope({
|
||||
@@ -162,24 +152,24 @@ export function ConsolePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<KpiStrip
|
||||
totals={totals}
|
||||
days={series.days}
|
||||
trends={series.trends}
|
||||
comparisonLabel={preset === 'today' ? 'yesterday' : 'previous period'}
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
<KpiStrip totals={totals} isLoading={isBusy} />
|
||||
|
||||
<div className="console-split">
|
||||
<SalesOverview days={series.days} isLoading={isBusy} />
|
||||
{/* Full width. A trend needs the page: at two thirds it showed six days
|
||||
before scrolling, which is not a trend, it is a sample. */}
|
||||
<SalesOverview days={series.days} isLoading={isBusy} />
|
||||
|
||||
{/* Store health takes the slot the shop card had. The shop’s name and
|
||||
address were the least useful thing on the board — a merchant knows
|
||||
which shop they are in — and the health panel earns the width. */}
|
||||
<div className="panel-pair">
|
||||
<StoreHealth
|
||||
rows={rows}
|
||||
totals={totals}
|
||||
rollup={rollup}
|
||||
isAggregate={scope.isAggregate}
|
||||
productCount={(products.data ?? []).length}
|
||||
base={base}
|
||||
/>
|
||||
<QuickActions base={base} />
|
||||
</div>
|
||||
|
||||
{scope.showBranchOverview ? (
|
||||
@@ -194,9 +184,7 @@ export function ConsolePage() {
|
||||
</header>
|
||||
<BranchOverview rows={rows} onSelect={select} />
|
||||
</section>
|
||||
) : (
|
||||
<YourShop row={rows[0]} shop={shop.data} title={scope.shopSectionTitle} base={base} />
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} />
|
||||
|
||||
|
||||
@@ -63,31 +63,27 @@
|
||||
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
||||
}
|
||||
|
||||
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
|
||||
row and a sparkline; with those gone it is a label and a number, and the
|
||||
layout should not still be shaped around what it no longer holds. */
|
||||
.kpi {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface); padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
|
||||
}
|
||||
.kpi-top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; }
|
||||
.kpi-icon {
|
||||
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
|
||||
background: var(--color-brand-tint); color: var(--color-brand);
|
||||
}
|
||||
.kpi-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
|
||||
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
|
||||
has stopped doing its job. The min-height keeps all five value rows level
|
||||
whether the label took one line or two. */
|
||||
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
|
||||
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
||||
.kpi-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.spark { width: 56px; height: 20px; flex: none; opacity: .9; }
|
||||
|
||||
.kpi-trend { display: flex; align-items: center; gap: 5px; font: 500 12.5px/1 var(--font-sans); }
|
||||
.kpi-trend[data-good="yes"] { color: #1c6b47; }
|
||||
.kpi-trend[data-good="no"] { color: #b3261e; }
|
||||
.kpi-trend[data-good="neutral"] { color: var(--color-ink-3); }
|
||||
.kpi-vs { color: var(--color-ink-3); font-weight: 400; }
|
||||
|
||||
/* ── Panels ─────────────────────────────────────────────────────────────── */
|
||||
.panel {
|
||||
@@ -109,10 +105,6 @@
|
||||
.panel-empty strong { font: 600 14px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||
.panel-empty span { font: 400 13px/1.5 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
/* The chart needs width to be a trend; health is a list and does not. */
|
||||
.console-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 1080px) { .console-split { grid-template-columns: minmax(0, 1.32fr) minmax(0, 1fr); } }
|
||||
|
||||
/* ── Shared: tiles, pills, buttons ──────────────────────────────────────── */
|
||||
.tile {
|
||||
width: 34px; height: 34px; flex: none; border-radius: 10px; display: grid; place-items: center;
|
||||
@@ -209,39 +201,14 @@
|
||||
.health-name { font: 600 13.5px/1.2 var(--font-sans); color: var(--color-ink-1); }
|
||||
.health-detail { font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
.health-rollup { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.rollup-chip {
|
||||
padding: 12px 14px; border: 1px solid var(--color-line); border-left-width: 3px;
|
||||
border-radius: 12px; background: var(--color-surface);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.rollup-chip strong { font: 600 20px/1 var(--font-sans); font-variant-numeric: tabular-nums; }
|
||||
.rollup-chip span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||
.rollup-chip[data-tone="healthy"] { border-left-color: #1c6b47; }
|
||||
.rollup-chip[data-tone="attention"] { border-left-color: #8a5a00; }
|
||||
.rollup-chip[data-tone="critical"] { border-left-color: #b3261e; }
|
||||
|
||||
/* ── Your shop ──────────────────────────────────────────────────────────── */
|
||||
/* Two panels, not three. The shop and its takings are one statement; the
|
||||
actions are the other. Three columns each doing a third of a job left every
|
||||
card half empty and stretched to the tallest of them. */
|
||||
.shop-row { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 900px) { .shop-row { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); } }
|
||||
/* ── Quick actions ──────────────────────────────────────────────────────── */
|
||||
/* Store health beside quick actions. Health carries four rows of detail and
|
||||
earns the larger share; the actions are three doors. */
|
||||
.panel-pair { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 900px) { .panel-pair { grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); } }
|
||||
|
||||
.shop-id { display: flex; gap: 14px; align-items: center; }
|
||||
.shop-idtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.shop-title { font: 600 17px/1.2 var(--font-sans); color: var(--color-ink-1); }
|
||||
.shop-mark { width: 54px; height: 54px; flex: none; border-radius: 14px; display: grid; place-items: center; background: var(--color-brand-tint); color: var(--color-brand); }
|
||||
.shop-meta { margin: 0; display: flex; gap: 7px; align-items: flex-start; font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); }
|
||||
.shop-meta svg { flex: none; margin-top: 2px; }
|
||||
|
||||
.perf-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.perf { padding: 12px 14px; border: 1px solid var(--color-line); border-radius: 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.perf[data-strong="yes"] { background: var(--color-brand-tint); border-color: var(--color-brand); }
|
||||
.perf[data-strong="yes"] strong { color: var(--color-brand); }
|
||||
.perf-label { display: inline-flex; align-items: center; gap: 6px; font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||
.perf strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
||||
.perf-note { font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-4); }
|
||||
|
||||
.quick-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.quick {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import type { BranchSyncSummary, TerminalState } from '@/features/store-admin/posStatus';
|
||||
import { branchTone, buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel';
|
||||
import { branchTone, buildAlerts, totalsOf, type BranchRow } from './consoleModel';
|
||||
|
||||
const health = (over: Partial<BranchSyncSummary> = {}): BranchSyncSummary => ({
|
||||
state: 'synced' as TerminalState,
|
||||
@@ -70,15 +70,6 @@ test('a branch with no tills is not reported as healthy', () => {
|
||||
assert.equal(branchTone(health({ online: 0, total: 0 })), 'attention');
|
||||
});
|
||||
|
||||
test('the rollup counts every branch exactly once', () => {
|
||||
const rollup = rollupHealth([
|
||||
row(1),
|
||||
row(2, { health: health({ online: 1, total: 3 }) }),
|
||||
row(3, { health: health({ online: 0, total: 2 }) }),
|
||||
]);
|
||||
assert.deepEqual(rollup, { healthy: 1, attention: 1, offline: 1 });
|
||||
});
|
||||
|
||||
/* ── Alerts ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a healthy shop raises nothing at all', () => {
|
||||
|
||||
@@ -66,12 +66,6 @@ export function totalsOf(rows: readonly BranchRow[]): ConsoleTotals {
|
||||
|
||||
export type HealthTone = 'healthy' | 'attention' | 'critical';
|
||||
|
||||
export interface HealthRollup {
|
||||
healthy: number;
|
||||
attention: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How one branch is doing, in three words rather than six states.
|
||||
*
|
||||
@@ -88,17 +82,6 @@ export function branchTone(health: BranchSyncSummary): HealthTone {
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function rollupHealth(rows: readonly BranchRow[]): HealthRollup {
|
||||
const rollup: HealthRollup = { healthy: 0, attention: 0, offline: 0 };
|
||||
for (const row of rows) {
|
||||
const tone = branchTone(row.health);
|
||||
if (tone === 'critical') rollup.offline += 1;
|
||||
else if (tone === 'attention') rollup.attention += 1;
|
||||
else rollup.healthy += 1;
|
||||
}
|
||||
return rollup;
|
||||
}
|
||||
|
||||
/* ── Attention ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ConsoleAlert {
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } 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.
|
||||
* Icon, label, number. Nothing else.
|
||||
*
|
||||
* 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.
|
||||
* ── What used to be here, and why it went ───────────────────────────────────
|
||||
*
|
||||
* A "↑ 12% vs. yesterday" line and a sparkline on every card. Both removed on
|
||||
* request, and the request was right: five cards each carrying a percentage, an
|
||||
* arrow and a chart made the strip the busiest thing on a page whose job is to
|
||||
* answer "how much did we take" at a glance. A trend belongs in Reports, where
|
||||
* somebody has gone looking for one.
|
||||
*
|
||||
* The line that stayed is a plain statement of fact, not a comparison: a bare 0
|
||||
* under "Unsynced Bills" reads as "no data" when it means everything is in the
|
||||
* books, and "42" under Total Orders says nothing about which channel they came
|
||||
* through.
|
||||
*/
|
||||
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) {
|
||||
export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="kpi-strip" aria-busy="true">
|
||||
@@ -43,129 +39,48 @@ export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: K
|
||||
|
||||
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={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||
<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)}
|
||||
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
|
||||
/>
|
||||
<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
|
||||
{...(totals.onlineOrders > 0
|
||||
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
||||
: {})}
|
||||
/>
|
||||
<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, label, value, note,
|
||||
}: {
|
||||
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'} />
|
||||
<span className="kpi-icon" aria-hidden>{icon}</span>
|
||||
<div className="kpi-head">
|
||||
<span className="kpi-label">{label}</span>
|
||||
<span className="kpi-value">{value}</span>
|
||||
{note ? <span className="kpi-note">{note}</span> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/features/console/sections/QuickActions.tsx
Normal file
61
src/features/console/sections/QuickActions.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Boxes, ChevronRight, Eye, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The three things most often done from this board.
|
||||
*
|
||||
* Lifted out of the shop card when that card went. They belong beside Store
|
||||
* health rather than under it: the health panel is where a merchant learns that
|
||||
* something needs doing, and these are the doors to go and do it.
|
||||
*
|
||||
* Deliberately three. A column of nine shortcuts is a second navigation, and
|
||||
* this console already has one along the top.
|
||||
*/
|
||||
export function QuickActions({ base }: { base: string }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<div>
|
||||
<h3 className="panel-title">Quick actions</h3>
|
||||
<p className="panel-sub">The three things most often done from here</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="quick-list">
|
||||
<Action
|
||||
to={`${base}/inventory?tab=products`}
|
||||
icon={<Eye size={15} />}
|
||||
label="View store"
|
||||
note="what a shopper sees"
|
||||
/>
|
||||
<Action
|
||||
to={`${base}/inventory?tab=catalogue`}
|
||||
icon={<Boxes size={15} />}
|
||||
label="Manage catalogue"
|
||||
note="add and price products"
|
||||
/>
|
||||
<Action
|
||||
to={`${base}/inventory?tab=stock`}
|
||||
icon={<RefreshCw size={15} />}
|
||||
label="Update inventory"
|
||||
note="upload today's counts"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
to, icon, label, note,
|
||||
}: { to: string; icon: ReactNode; label: string; note: string }) {
|
||||
return (
|
||||
<Link to={to} className="quick">
|
||||
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
|
||||
<span className="quick-text">
|
||||
<span className="quick-label">{label}</span>
|
||||
<span className="quick-note">{note}</span>
|
||||
</span>
|
||||
<ChevronRight size={15} className="quick-chev" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
|
||||
import { count } from '@/features/store-admin/format';
|
||||
import type { BranchRow, ConsoleTotals, HealthRollup, HealthTone } from '../consoleModel';
|
||||
import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel';
|
||||
|
||||
/**
|
||||
* Whether the shop is working, as distinct from how much it sold.
|
||||
@@ -22,14 +22,13 @@ import type { BranchRow, ConsoleTotals, HealthRollup, HealthTone } from '../cons
|
||||
export interface StoreHealthProps {
|
||||
rows: readonly BranchRow[];
|
||||
totals: ConsoleTotals;
|
||||
rollup: HealthRollup;
|
||||
isAggregate: boolean;
|
||||
productCount: number;
|
||||
base: string;
|
||||
}
|
||||
|
||||
export function StoreHealth({
|
||||
rows, totals, rollup, isAggregate, productCount, base,
|
||||
rows, totals, isAggregate, productCount, base,
|
||||
}: StoreHealthProps) {
|
||||
const undecided = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
|
||||
const withoutTill = rows.filter((row) => row.health.total === 0).length;
|
||||
@@ -44,18 +43,14 @@ export function StoreHealth({
|
||||
<header className="panel-head">
|
||||
<div>
|
||||
<h3 className="panel-title">Store health</h3>
|
||||
<p className="panel-sub">Quick status of your key operations</p>
|
||||
<p className="panel-sub">
|
||||
{isAggregate
|
||||
? 'Quick status across every branch'
|
||||
: 'Quick status of your key operations'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isAggregate ? (
|
||||
<div className="health-rollup">
|
||||
<RollupChip tone="healthy" n={rollup.healthy} word="healthy" />
|
||||
<RollupChip tone="attention" n={rollup.attention} word="need attention" />
|
||||
<RollupChip tone="critical" n={rollup.offline} word="offline" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="health-list">
|
||||
<HealthLine
|
||||
icon={<Monitor size={16} />}
|
||||
@@ -112,15 +107,6 @@ export function StoreHealth({
|
||||
);
|
||||
}
|
||||
|
||||
function RollupChip({ tone, n, word }: { tone: HealthTone; n: number; word: string }) {
|
||||
return (
|
||||
<div className="rollup-chip" data-tone={tone}>
|
||||
<strong>{n}</strong>
|
||||
<span>{n === 1 ? 'branch' : 'branches'} {word}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthLine({
|
||||
icon, name, detail, tone, action, href,
|
||||
}: {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Boxes, ChevronRight, Eye, MapPin, Phone, Receipt, RefreshCw, ShoppingCart, Store } from 'lucide-react';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { count, money } from '@/features/store-admin/format';
|
||||
import type { BranchRow } from '../consoleModel';
|
||||
|
||||
/**
|
||||
* The shop: who it is, what it took, and what to do next.
|
||||
*
|
||||
* ── Why identity and takings share one card ─────────────────────────────────
|
||||
*
|
||||
* They were two panels side by side, and both were half empty: a name and an
|
||||
* address do not fill a panel, and neither do two figures. Worse, the row
|
||||
* stretched every panel to match the tallest, so the gap was proportional to
|
||||
* how much the THIRD panel had to say. Three cards each doing a third of a job
|
||||
* is not a layout, it is a grid with the content spread thin to fit it.
|
||||
*
|
||||
* They also belong together: "R mart" and "R mart took ₹12,480 today" are one
|
||||
* statement about one shop, and the border between them was inventing a
|
||||
* distinction the reader does not have.
|
||||
*
|
||||
* So two panels, not three — the shop and the things to do to it. The figures
|
||||
* sit inside the shop card, beneath the address they belong to.
|
||||
*/
|
||||
export interface YourShopProps {
|
||||
row: BranchRow | undefined;
|
||||
shop: TenantInfo | undefined;
|
||||
title: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
export function YourShop({ row, shop, title, base }: YourShopProps) {
|
||||
if (!row) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-empty">
|
||||
<strong>No branch selected</strong>
|
||||
<span>Pick a branch from the selector above to see its details.</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const record = shop as unknown as Record<string, string | undefined> | undefined;
|
||||
const name = row.branch.locationname?.trim() || record?.['tenantname']?.trim() || 'Your shop';
|
||||
const where = [record?.['address'], record?.['city'], record?.['postcode']]
|
||||
.map((part) => part?.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const total = row.onlineRevenue + row.counterRevenue;
|
||||
|
||||
return (
|
||||
<div className="shop-row">
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<div>
|
||||
<h3 className="panel-title">{title}</h3>
|
||||
<p className="panel-sub">Today’s performance and where customers find you</p>
|
||||
</div>
|
||||
<span className="pill" data-tone="healthy"><i />Active</span>
|
||||
</header>
|
||||
|
||||
<div className="shop-id">
|
||||
<span className="shop-mark" aria-hidden><Store size={24} /></span>
|
||||
<div className="shop-idtext">
|
||||
<strong className="shop-title">{name}</strong>
|
||||
{where ? <p className="shop-meta"><MapPin size={13} />{where}</p> : null}
|
||||
{record?.['primarycontact'] ? (
|
||||
<p className="shop-meta"><Phone size={13} />{record['primarycontact']}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three figures rather than two: the total is what a shopkeeper is
|
||||
actually after, and making them add it up from two channels is the
|
||||
arithmetic this board exists to remove. */}
|
||||
<div className="perf-row">
|
||||
<Figure
|
||||
icon={<ShoppingCart size={13} />}
|
||||
label="Online"
|
||||
value={money(row.onlineRevenue)}
|
||||
note={`${count(row.onlineOrders)} ${row.onlineOrders === 1 ? 'order' : 'orders'}`}
|
||||
/>
|
||||
<Figure
|
||||
icon={<Receipt size={13} />}
|
||||
label="Counter"
|
||||
value={money(row.counterRevenue)}
|
||||
note={`${count(row.counterBills)} ${row.counterBills === 1 ? 'bill' : 'bills'}`}
|
||||
/>
|
||||
<Figure label="Total" value={money(total)} note="both channels" isStrong />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<div>
|
||||
<h3 className="panel-title">Quick actions</h3>
|
||||
<p className="panel-sub">The three things most often done from here</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="quick-list">
|
||||
<QuickAction to={`${base}/inventory?tab=products`} icon={<Eye size={15} />} label="View store" note="what a shopper sees" />
|
||||
<QuickAction to={`${base}/inventory?tab=catalogue`} icon={<Boxes size={15} />} label="Manage catalogue" note="add and price products" />
|
||||
<QuickAction to={`${base}/inventory?tab=stock`} icon={<RefreshCw size={15} />} label="Update inventory" note="upload today's counts" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
icon, label, value, note, isStrong,
|
||||
}: { icon?: ReactNode; label: string; value: string; note: string; isStrong?: boolean }) {
|
||||
return (
|
||||
<div className="perf" data-strong={isStrong ? 'yes' : 'no'}>
|
||||
<span className="perf-label">{icon}{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<span className="perf-note">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
to, icon, label, note,
|
||||
}: { to: string; icon: ReactNode; label: string; note: string }) {
|
||||
return (
|
||||
<Link to={to} className="quick">
|
||||
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
|
||||
<span className="quick-text">
|
||||
<span className="quick-label">{label}</span>
|
||||
<span className="quick-note">{note}</span>
|
||||
</span>
|
||||
<ChevronRight size={15} className="quick-chev" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useOrders } from '@/queries/hooks';
|
||||
import type { PosSalesSummary } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The day-by-day series behind the chart, the donut and the trend arrows.
|
||||
* The day-by-day series behind the chart and the donut.
|
||||
*
|
||||
* ── Why the online side is built from order rows ────────────────────────────
|
||||
*
|
||||
@@ -14,13 +14,10 @@ import type { PosSalesSummary } from '@/api/types';
|
||||
* online series is grouped from the order rows themselves, which carry
|
||||
* `orderdate` and an amount and can be asked for a date range.
|
||||
*
|
||||
* ── Why the previous period is fetched at all ───────────────────────────────
|
||||
*
|
||||
* "↑ 12% vs. yesterday" is a claim, and a dashboard that invents it is worse
|
||||
* than one that omits it. The comparison window is the same length as the one
|
||||
* on screen, immediately before it, and every trend shown is a real division of
|
||||
* two measured numbers. Where a figure has no comparable — an all-time count
|
||||
* with no date to filter on — no arrow is drawn.
|
||||
* The hook used to fetch a second, earlier window to compute "vs. yesterday"
|
||||
* trends for the KPI cards. Those were removed, and the extra fetch went with
|
||||
* them — one request per branch, plus an orders query, for a line nobody asked
|
||||
* to see.
|
||||
*/
|
||||
|
||||
export interface DaySeries {
|
||||
@@ -31,73 +28,23 @@ export interface DaySeries {
|
||||
counterBills: number;
|
||||
}
|
||||
|
||||
export interface Trend {
|
||||
/** Percentage change, rounded. Absent when there is nothing to compare. */
|
||||
percent?: number;
|
||||
/** True when the previous period was zero, so a percentage would be a lie. */
|
||||
isNew: boolean;
|
||||
direction: 'up' | 'down' | 'flat';
|
||||
}
|
||||
|
||||
export interface ConsoleSeries {
|
||||
days: DaySeries[];
|
||||
onlineRevenue: number;
|
||||
onlineOrders: number;
|
||||
cancelled: number;
|
||||
trends: {
|
||||
online: Trend;
|
||||
counter: Trend;
|
||||
orders: Trend;
|
||||
cancelled: Trend;
|
||||
};
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** The window of the same length immediately before this one. */
|
||||
export function previousRange(range: DateRange): DateRange {
|
||||
const from = range.fromdate ? new Date(range.fromdate) : undefined;
|
||||
const to = range.todate ? new Date(range.todate) : undefined;
|
||||
if (!from || !to || Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return {};
|
||||
|
||||
const days = Math.max(1, Math.round((to.getTime() - from.getTime()) / 86_400_000) + 1);
|
||||
const prevTo = new Date(from);
|
||||
prevTo.setDate(prevTo.getDate() - 1);
|
||||
const prevFrom = new Date(prevTo);
|
||||
prevFrom.setDate(prevFrom.getDate() - (days - 1));
|
||||
|
||||
return { fromdate: iso(prevFrom), todate: iso(prevTo) };
|
||||
}
|
||||
|
||||
export function trendOf(now: number, before: number): Trend {
|
||||
if (before <= 0) {
|
||||
// Everything is up from nothing, and "↑ ∞%" is not a fact. Say it is new.
|
||||
return { isNew: now > 0, direction: now > 0 ? 'up' : 'flat' };
|
||||
}
|
||||
const percent = Math.round(((now - before) / before) * 100);
|
||||
return {
|
||||
percent: Math.abs(percent),
|
||||
isNew: false,
|
||||
direction: percent > 0 ? 'up' : percent < 0 ? 'down' : 'flat',
|
||||
};
|
||||
}
|
||||
|
||||
export function useConsoleSeries(
|
||||
tenantid: number,
|
||||
locationid: number | null,
|
||||
range: DateRange,
|
||||
posNow: readonly PosSalesSummary[],
|
||||
posBefore: readonly PosSalesSummary[],
|
||||
): ConsoleSeries {
|
||||
const prev = useMemo(() => previousRange(range), [range]);
|
||||
|
||||
const nowOrders = useOrders(
|
||||
tenantid ? { tenantid, ...(locationid ? { locationid } : {}), ...range, pagesize: 500 } : undefined,
|
||||
);
|
||||
const beforeOrders = useOrders(
|
||||
tenantid && prev.fromdate
|
||||
? { tenantid, ...(locationid ? { locationid } : {}), ...prev, pagesize: 500 }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const byDay = new Map<string, DaySeries>();
|
||||
@@ -137,35 +84,14 @@ export function useConsoleSeries(
|
||||
}
|
||||
}
|
||||
|
||||
let beforeOnline = 0;
|
||||
let beforeOrders_ = 0;
|
||||
let beforeCancelled = 0;
|
||||
for (const order of beforeOrders.data ?? []) {
|
||||
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
|
||||
beforeOrders_ += 1;
|
||||
if (isCancelled) beforeCancelled += 1;
|
||||
else beforeOnline += amountOf(order);
|
||||
}
|
||||
|
||||
const counterNow = posNow.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0);
|
||||
const counterBefore = posBefore.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0);
|
||||
const billsNow = posNow.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0);
|
||||
const billsBefore = posBefore.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0);
|
||||
|
||||
return {
|
||||
days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
||||
onlineRevenue,
|
||||
onlineOrders,
|
||||
cancelled,
|
||||
trends: {
|
||||
online: trendOf(onlineRevenue, beforeOnline),
|
||||
counter: trendOf(counterNow, counterBefore),
|
||||
orders: trendOf(onlineOrders + billsNow, beforeOrders_ + billsBefore),
|
||||
cancelled: trendOf(cancelled, beforeCancelled),
|
||||
},
|
||||
isLoading: nowOrders.isLoading,
|
||||
};
|
||||
}, [nowOrders.data, nowOrders.isLoading, beforeOrders.data, posNow, posBefore]);
|
||||
}, [nowOrders.data, nowOrders.isLoading, posNow]);
|
||||
}
|
||||
|
||||
/* ── Reading the wire ─────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user