Files
parking_solution/apps/web/src/Reports.tsx
T
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00

403 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ReferenceLine,
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",
panel2: "#1e222a",
};
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-[0.75rem] 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,
cash: p.cashMinor / 100,
card: p.cardMinor / 100,
}));
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);
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
const stay = data.stayHistogram.map((b) => ({
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
count: b.count,
}));
return (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
<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.peakOcc")}
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
/>
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
</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>
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
(occupancy only moves at entries/exits); the red line is the configured cap. */}
<Panel title={t("reports.chart.occupancy")}>
<ResponsiveContainer width="100%" height={220}>
<AreaChart 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} />
{data.capacity != null && (
<ReferenceLine
y={data.capacity}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
/>
)}
<Area
type="stepAfter"
dataKey="occupancyEnd"
name={t("reports.chart.occupancySeries")}
stroke={C.cyan}
fill={C.cyan}
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
<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))} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
</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>
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
<Panel title={t("reports.chart.stay")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={stay} 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} />
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
</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-[0.8125rem]">
<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>
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
the direct input for tariff windows (night rates, weekend cards, early bird). */}
<Panel title={t("reports.chart.heatmap")}>
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
</Panel>
<p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
</div>
);
}
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
const max = Math.max(1, ...matrix.flat());
return (
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="pb-0.5 text-center text-term-muted">
{h % 3 === 0 ? h : ""}
</span>
))}
{matrix.map((row, d) => (
<Fragment key={d}>
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
className="h-4 rounded-[1px]"
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
/>
))}
</Fragment>
))}
</div>
</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-[0.6875rem] 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-[0.6875rem] 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-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
}