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:
+22
-1
@@ -6,7 +6,7 @@ import {
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||
@@ -30,6 +30,9 @@ import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||
// initial bundle and only downloads when an admin opens /setup/reports.
|
||||
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
||||
|
||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||
@@ -85,6 +88,7 @@ function SetupLayout() {
|
||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
{show("report:read") && <SetupTab to="/setup/reports" label={t("nav.reports")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
@@ -382,6 +386,7 @@ function RootLayout() {
|
||||
show("site:read") ||
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("report:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
@@ -491,6 +496,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||
{ to: "/setup/site", perm: "site:read" },
|
||||
{ to: "/setup/users", perm: "user:read" },
|
||||
{ to: "/setup/roles", perm: "role:read" },
|
||||
{ to: "/setup/reports", perm: "report:read" },
|
||||
{ to: "/shifts", perm: "shift:read" },
|
||||
{ to: "/setup/logs", perm: "log:read" },
|
||||
];
|
||||
@@ -588,6 +594,20 @@ const rolesRoute = createRoute({
|
||||
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
||||
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
||||
|
||||
// Admin reports/charts. Gated by report:read. Lazy component (Recharts) in a Suspense.
|
||||
const reportsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "reports",
|
||||
beforeLoad: ({ context }) => requirePerm("report:read")(context),
|
||||
component: function ReportsRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-3 text-term-muted">…</div>}>
|
||||
<Reports />
|
||||
</Suspense>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
@@ -612,6 +632,7 @@ const routeTree = rootRoute.addChildren([
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
reportsRoute,
|
||||
logsRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user