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:
@@ -0,0 +1,319 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
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",
|
||||
};
|
||||
|
||||
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-[12px] 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,
|
||||
}));
|
||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI cards. */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<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.subscribers")}
|
||||
value={String(data.subscriptions.currentlyValid)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Revenue per bucket. */}
|
||||
<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))} />
|
||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
||||
</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>
|
||||
|
||||
{/* Peak hours (entries by hour-of-day). */}
|
||||
<Panel title={t("reports.chart.peakHours")}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
||||
</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-[13px]">
|
||||
<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>
|
||||
|
||||
<p className="text-[11px] text-term-muted">
|
||||
{t("reports.footnote", { tz: data.tz })}
|
||||
</p>
|
||||
</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-[11px] 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-[11px] 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-[12px] text-term-muted">{t("reports.noData")}</p>;
|
||||
}
|
||||
@@ -308,6 +308,66 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin reports -------------------------------------------------------
|
||||
export type ReportBucket = "hour" | "day" | "month";
|
||||
|
||||
export interface ReportSeriesPoint {
|
||||
bucket: string;
|
||||
entries: number;
|
||||
exits: number;
|
||||
revenueMinor: number;
|
||||
payments: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
entries: number;
|
||||
exits: number;
|
||||
payments: number;
|
||||
revenueMinor: number;
|
||||
cashMinor: number;
|
||||
cardMinor: number;
|
||||
ticketMinor: number;
|
||||
subscriptionSalesMinor: number;
|
||||
subscriptionWindowMinor: number;
|
||||
closedSessions: number;
|
||||
totalParkedMinutes: number;
|
||||
avgParkedMinutes: number;
|
||||
medianParkedMinutes: number;
|
||||
}
|
||||
|
||||
export interface ReportSubscriptionStats {
|
||||
active: number;
|
||||
suspended: number;
|
||||
revoked: number;
|
||||
currentlyValid: number;
|
||||
coveredCars: number;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
from: string;
|
||||
to: string;
|
||||
bucket: ReportBucket;
|
||||
tz: string;
|
||||
currency: string | null;
|
||||
totals: ReportTotals;
|
||||
series: ReportSeriesPoint[];
|
||||
entriesByHour: number[];
|
||||
subscriptions: ReportSubscriptionStats;
|
||||
}
|
||||
|
||||
/** The whole admin dashboard (totals + series + peak-hours + subscriptions) for a range. */
|
||||
export function fetchReport(from: string, to: string, bucket: ReportBucket): Promise<ReportSummary> {
|
||||
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||||
return apiFetch<ReportSummary>(`/api/reports/summary?${qs}`);
|
||||
}
|
||||
|
||||
/** URL for the CSV export of the per-bucket series (opened/downloaded directly; the
|
||||
* auth cookie rides along same-origin). */
|
||||
export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string {
|
||||
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||||
return apiUrl(`/api/reports/summary.csv?${qs}`);
|
||||
}
|
||||
|
||||
export interface BackendIpCandidate {
|
||||
ip: string;
|
||||
iface: string;
|
||||
|
||||
@@ -22,6 +22,14 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||
export function formatMinutes(mins: number): string {
|
||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||
const m = Math.round(mins);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,4 +27,6 @@ export const qk = {
|
||||
siteConfig: ["site-config"] as const,
|
||||
shift: ["shift"] as const,
|
||||
deviceStatus: ["device-status"] as const,
|
||||
report: (from: string, to: string, bucket: string) =>
|
||||
["report", from, to, bucket] as const,
|
||||
} as const;
|
||||
|
||||
+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