7ef332999e
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
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
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>;
|
||
}
|