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("30d"); const [bucketOverride, setBucketOverride] = useState(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 (

{t("reports.title")}

{presets.map((p) => ( ))}
{t("reports.groupBy")}
{t("reports.exportCsv")}
{isLoading &&

{t("common.loading")}

} {isError && (

{t("reports.loadFailed", { error: (error as Error)?.message ?? "?" })}

)} {data && }
); } 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 (
{/* KPI cards. */}
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */} 0 ? "amber" : undefined} /> 0 ? "red" : undefined} />
{/* Entry / exit over time. */} {/* 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. */} {data.capacity != null && ( )}
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */} money(Math.round(Number(v) * 100))} /> {/* Revenue mix (ticket vs subscription vs window). */} {mix.length === 0 ? ( ) : ( {mix.map((s) => ( ))} money(Number(v))} /> )} {/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */} {/* Cash / card + duration + subscription breakdown (numbers). */}
{/* 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). */}

{t("reports.footnote", { tz: data.tz })}

); } /** 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 (
{Array.from({ length: 24 }, (_, h) => ( {h % 3 === 0 ? h : ""} ))} {matrix.map((row, d) => ( {dows[d]} {row.map((v, h) => ( ))} ))}
); } 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 (
{label}
{value}
); } function Panel({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Row({ label, value }: { label: string; value: string }) { return ( <>
{label}
{value}
); } function Empty({ t }: { t: TFunction }) { return

{t("reports.noData")}

; }