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
+35
View File
@@ -55,6 +55,7 @@ export const en: Catalog = {
users: "Users",
roles: "Roles",
shifts: "Shifts",
reports: "Reports",
logs: "Logs",
},
status: {
@@ -677,6 +678,40 @@ export const en: Catalog = {
cashRemoved: "Cash removed",
loadFailed: "Failed to load shifts.",
},
reports: {
title: "Reports",
groupBy: "Group by",
exportCsv: "Export CSV",
loadFailed: "Couldn't load the report: {{error}}",
noData: "No data in this range.",
footnote: "Counts and money are summed from the signed event log. Times shown in {{tz}}.",
preset: { today: "Today", "7d": "7 days", "30d": "30 days", "90d": "90 days" },
bucket: { hour: "Hour", day: "Day", month: "Month" },
kpi: {
entries: "Entries",
exits: "Exits",
revenue: "Revenue",
payments: "Payments",
avgStay: "Avg stay",
subscribers: "Subscribers",
},
chart: {
flow: "Entries & exits over time",
revenue: "Revenue ({{currency}})",
mix: "Revenue mix",
peakHours: "Entries by hour of day",
breakdown: "Breakdown",
},
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: {
cash: "Cash",
card: "Card",
closed: "Closed sessions",
medianStay: "Median stay",
subActive: "Active subscriptions",
subCars: "Cars covered",
},
},
logs: {
title: "System logs",
refresh: "Refresh",
+35
View File
@@ -57,6 +57,7 @@ export const sq = {
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
reports: "Raportet",
logs: "Loget",
},
status: {
@@ -691,6 +692,40 @@ export const sq = {
cashRemoved: "Para të hequra",
loadFailed: "Ngarkimi i turneve dështoi.",
},
reports: {
title: "Raportet",
groupBy: "Grupo sipas",
exportCsv: "Eksporto CSV",
loadFailed: "Raporti nuk u ngarkua dot: {{error}}",
noData: "Nuk ka të dhëna në këtë interval.",
footnote: "Numërimet dhe paratë mblidhen nga regjistri i nënshkruar. Oraret në {{tz}}.",
preset: { today: "Sot", "7d": "7 ditë", "30d": "30 ditë", "90d": "90 ditë" },
bucket: { hour: "Orë", day: "Ditë", month: "Muaj" },
kpi: {
entries: "Hyrje",
exits: "Dalje",
revenue: "Të ardhura",
payments: "Pagesa",
avgStay: "Qëndrim mes.",
subscribers: "Abonentë",
},
chart: {
flow: "Hyrjet & daljet me kalimin e kohës",
revenue: "Të ardhurat ({{currency}})",
mix: "Përbërja e të ardhurave",
peakHours: "Hyrjet sipas orës së ditës",
breakdown: "Ndarja",
},
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
row: {
cash: "Para në dorë",
card: "Kartë",
closed: "Sesione të mbyllura",
medianStay: "Qëndrim mesatar (median)",
subActive: "Abonime aktive",
subCars: "Makina të mbuluara",
},
},
logs: {
title: "Loget e sistemit",
refresh: "Rifresko",