feat(reports): admin Reports dashboard — ledger-first charts

Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 00:16:07 +02:00
parent 742653aefb
commit 5a5f5c554b
16 changed files with 1358 additions and 5 deletions
+319
View File
@@ -0,0 +1,319 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { fetchReport, reportCsvUrl, type ReportBucket, type ReportSummary } from "./api.js";
import { qk } from "./lib/query.js";
import { formatMinutes, formatMoney } from "./lib/format.js";
// Admin Reports — the at-a-glance dashboard over the signed ledger. All numbers come
// from the server already aggregated (ledger-first; see apps/server/src/reports.ts), so
// this file is pure presentation: date-range presets, KPI cards, and a handful of
// Recharts views (entry/exit, revenue cash/card, peak hours, revenue mix, subscriptions).
// Themed to the terminal palette. Gated by report:read at the route + server.
// Terminal palette (mirrors index.css --color-term-*). Recharts wants literal colors.
const C = {
green: "#2e8c4a", // entry / ok
red: "#e8412b", // exit / fault
amber: "#f2a516", // accent / cash
cyan: "#2563c8", // payment / card
muted: "#8a8a82",
border: "#2a2f38",
text: "#f2f2ee",
panel: "#14171c",
};
type PresetKey = "today" | "7d" | "30d" | "90d";
/** [from, to) ISO bounds + a sensible default bucket for a preset, computed in the
* browser's local time (the appliance IS the site, so local == site time). */
function presetRange(key: PresetKey): { from: string; to: string; bucket: ReportBucket } {
const now = new Date();
const to = now.toISOString();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (key === "today") return { from: startOfToday.toISOString(), to, bucket: "hour" };
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
const from = new Date(now.getTime() - days * 86_400_000).toISOString();
return { from, to, bucket: days <= 30 ? "day" : "month" };
}
export function Reports() {
const { t } = useTranslation();
const [preset, setPreset] = useState<PresetKey>("30d");
const [bucketOverride, setBucketOverride] = useState<ReportBucket | null>(null);
const range = useMemo(() => presetRange(preset), [preset]);
const bucket = bucketOverride ?? range.bucket;
const { data, isLoading, isError, error } = useQuery({
queryKey: qk.report(range.from, range.to, bucket),
queryFn: () => fetchReport(range.from, range.to, bucket),
});
const presets: { key: PresetKey; label: string }[] = [
{ key: "today", label: t("reports.preset.today") },
{ key: "7d", label: t("reports.preset.7d") },
{ key: "30d", label: t("reports.preset.30d") },
{ key: "90d", label: t("reports.preset.90d") },
];
const buckets: ReportBucket[] = ["hour", "day", "month"];
return (
<div className="mx-auto max-w-6xl">
<div className="mb-3 flex flex-wrap items-center gap-2">
<h1 className="mr-2 text-base font-bold uppercase tracking-widest text-term-amber">
{t("reports.title")}
</h1>
<div className="flex gap-1">
{presets.map((p) => (
<button
key={p.key}
type="button"
className={`btn btn-sm ${preset === p.key ? "btn-primary" : "btn-ghost"}`}
onClick={() => {
setPreset(p.key);
setBucketOverride(null);
}}
>
{p.label}
</button>
))}
</div>
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
<span>{t("reports.groupBy")}</span>
<select
className="select input-sm w-auto"
value={bucket}
onChange={(e) => setBucketOverride(e.target.value as ReportBucket)}
>
{buckets.map((b) => (
<option key={b} value={b}>
{t(`reports.bucket.${b}`)}
</option>
))}
</select>
</div>
<a
className="btn btn-sm btn-ghost ml-auto"
href={reportCsvUrl(range.from, range.to, bucket)}
download
>
{t("reports.exportCsv")}
</a>
</div>
{isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{isError && (
<p className="text-term-red">
{t("reports.loadFailed", { error: (error as Error)?.message ?? "?" })}
</p>
)}
{data && <ReportBody data={data} t={t} />}
</div>
);
}
function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
const cur = data.currency ?? "ALL";
const money = (m: number) => formatMoney(m, cur);
const tot = data.totals;
// Recharts series: label + the metrics. Keep the server's lexically-sortable bucket
// labels; trim the date prefix off hour labels for a tighter axis.
const series = data.series.map((p) => ({
...p,
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
revenue: p.revenueMinor / 100,
}));
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
const mix = [
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
].filter((s) => s.value > 0);
return (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi
label={t("reports.kpi.subscribers")}
value={String(data.subscriptions.currentlyValid)}
/>
</div>
{/* Entry / exit over time. */}
<Panel title={t("reports.chart.flow")}>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line
type="monotone"
dataKey="entries"
name={t("reports.kpi.entries")}
stroke={C.green}
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="exits"
name={t("reports.kpi.exits")}
stroke={C.red}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
</BarChart>
</ResponsiveContainer>
</Panel>
{/* Revenue mix (ticket vs subscription vs window). */}
<Panel title={t("reports.chart.mix")}>
{mix.length === 0 ? (
<Empty t={t} />
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={mix}
dataKey="value"
nameKey="name"
innerRadius={48}
outerRadius={80}
paddingAngle={2}
>
{mix.map((s) => (
<Cell key={s.name} fill={s.color} stroke={C.panel} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Number(v))} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
)}
</Panel>
{/* Peak hours (entries by hour-of-day). */}
<Panel title={t("reports.chart.peakHours")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
</BarChart>
</ResponsiveContainer>
</Panel>
{/* Cash / card + duration + subscription breakdown (numbers). */}
<Panel title={t("reports.chart.breakdown")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
<Row label={t("reports.mix.subSales")} value={money(tot.subscriptionSalesMinor)} />
<Row label={t("reports.mix.subWindow")} value={money(tot.subscriptionWindowMinor)} />
<Row label={t("reports.row.closed")} value={String(tot.closedSessions)} />
<Row label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Row label={t("reports.row.medianStay")} value={formatMinutes(tot.medianParkedMinutes)} />
<Row label={t("reports.row.subActive")} value={String(data.subscriptions.active)} />
<Row label={t("reports.row.subCars")} value={String(data.subscriptions.coveredCars)} />
</dl>
</Panel>
</div>
<p className="text-[11px] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
</div>
);
}
const tooltipStyle = {
background: C.panel,
border: `1px solid ${C.border}`,
borderRadius: 6,
color: C.text,
fontSize: 12,
};
function Kpi({ label, value, accent }: { label: string; value: string; accent?: "green" | "red" | "amber" | "cyan" }) {
const color =
accent === "green"
? "text-term-green"
: accent === "red"
? "text-term-red"
: accent === "amber"
? "text-term-amber"
: accent === "cyan"
? "text-term-cyan"
: "text-term-text";
return (
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-term border border-term-border bg-term-panel p-3">
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
{children}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<>
<dt className="text-term-muted">{label}</dt>
<dd className="text-right tabular-nums text-term-text">{value}</dd>
</>
);
}
function Empty({ t }: { t: TFunction }) {
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
}