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,183 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { makeLog } from "./test-helpers.js";
|
||||||
|
import { reportSummary } from "./reports.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||||
|
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||||
|
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||||
|
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||||
|
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||||
|
function at(iso: string): string {
|
||||||
|
return new Date(iso).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entry(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function exit(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function payment(
|
||||||
|
occurredAt: string,
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await log.append({
|
||||||
|
type: "payment",
|
||||||
|
occurredAt,
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
currency: "ALL",
|
||||||
|
tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||||
|
|
||||||
|
describe("reportSummary — ledger-first totals", () => {
|
||||||
|
it("counts entries and exits from the signed ledger", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-10T18:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(2);
|
||||||
|
expect(r.totals.exits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes events outside [from, to)", async () => {
|
||||||
|
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||||
|
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums payment money and splits cash vs card", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.payments).toBe(2);
|
||||||
|
expect(r.totals.revenueMinor).toBe(25000);
|
||||||
|
expect(r.totals.cashMinor).toBe(20000);
|
||||||
|
expect(r.totals.cardMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||||
|
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||||
|
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.ticketMinor).toBe(10000);
|
||||||
|
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||||
|
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||||
|
// The three add up to the gross revenue.
|
||||||
|
expect(r.totals.revenueMinor).toBe(41500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up the currency from a payment in range", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.currency).toBe("ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||||
|
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||||
|
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||||
|
await entry(at("2026-06-15T23:30:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const point = r.series.find((p) => p.entries > 0);
|
||||||
|
expect(point?.bucket).toBe("2026-06-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||||
|
await entry(at("2026-06-12T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const labels = r.series.map((p) => p.bucket);
|
||||||
|
expect(labels).toEqual([...labels].sort());
|
||||||
|
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||||
|
expect(d10?.entries).toBe(1);
|
||||||
|
expect(d10?.revenueMinor).toBe(7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||||
|
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||||
|
await entry(at("2026-06-10T06:00:00Z"));
|
||||||
|
await entry(at("2026-06-11T06:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByHour).toHaveLength(24);
|
||||||
|
expect(r.entriesByHour[8]).toBe(2);
|
||||||
|
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||||
|
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||||
|
// 60-min and 120-min stays → avg 90, median 90.
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s1",
|
||||||
|
identity: "t1",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s2",
|
||||||
|
identity: "t2",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
// An OPEN session (no exit) must not count.
|
||||||
|
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.closedSessions).toBe(2);
|
||||||
|
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||||
|
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||||
|
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||||
|
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||||
|
// active + valid window covering `to`, quantity 2.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "a", status: "active", quantity: 2,
|
||||||
|
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// active but EXPIRED before `to` → not currently valid.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "b", status: "active", quantity: 1,
|
||||||
|
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// suspended.
|
||||||
|
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.subscriptions.active).toBe(2);
|
||||||
|
expect(r.subscriptions.suspended).toBe(1);
|
||||||
|
expect(r.subscriptions.revoked).toBe(0);
|
||||||
|
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||||
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
ledgerEvents,
|
||||||
|
sessions,
|
||||||
|
subscriptions,
|
||||||
|
tariffVersions,
|
||||||
|
tariffs,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
|
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
|
||||||
|
// admin sees on the Reports page are summed from the SIGNED, hash-chained
|
||||||
|
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
|
||||||
|
// reconciles against — so a chart total always ties out to the drawer. Only the
|
||||||
|
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
|
||||||
|
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
|
||||||
|
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
|
||||||
|
//
|
||||||
|
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
|
||||||
|
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
|
||||||
|
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
|
||||||
|
// floats (money is integer minor units throughout).
|
||||||
|
|
||||||
|
export type Bucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
export interface ReportQuery {
|
||||||
|
/** Inclusive lower bound (ISO instant). */
|
||||||
|
readonly from: string;
|
||||||
|
/** Exclusive upper bound (ISO instant). */
|
||||||
|
readonly to: string;
|
||||||
|
/** Time grain for the series. Default "day". */
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
|
||||||
|
* for a day, "2026-06-22 14" for an hour). */
|
||||||
|
export interface SeriesPoint {
|
||||||
|
readonly bucket: string;
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
|
readonly payments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTotals {
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
readonly payments: number;
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
|
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
|
||||||
|
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
|
||||||
|
readonly ticketMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
|
||||||
|
readonly closedSessions: number;
|
||||||
|
readonly totalParkedMinutes: number;
|
||||||
|
readonly avgParkedMinutes: number;
|
||||||
|
readonly medianParkedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStats {
|
||||||
|
readonly active: number;
|
||||||
|
readonly suspended: number;
|
||||||
|
readonly revoked: number;
|
||||||
|
/** Active subscriptions whose window covers `to` (the report's "now"). */
|
||||||
|
readonly currentlyValid: number;
|
||||||
|
/** Cars covered by currently-valid subscriptions (Σ quantity). */
|
||||||
|
readonly coveredCars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: string;
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
readonly tz: string;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly totals: ReportTotals;
|
||||||
|
readonly series: SeriesPoint[];
|
||||||
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
|
readonly entriesByHour: number[];
|
||||||
|
readonly subscriptions: SubscriptionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
|
return {
|
||||||
|
y: Number(parts.year),
|
||||||
|
mo: Number(parts.month),
|
||||||
|
d: Number(parts.day),
|
||||||
|
h: Number(parts.hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
|
||||||
|
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
|
||||||
|
const p = localParts(iso, tz);
|
||||||
|
const mo = String(p.mo).padStart(2, "0");
|
||||||
|
const d = String(p.d).padStart(2, "0");
|
||||||
|
const h = String(p.h).padStart(2, "0");
|
||||||
|
if (bucket === "month") return `${p.y}-${mo}`;
|
||||||
|
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
|
||||||
|
return `${p.y}-${mo}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentPayload {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
tender?: "cash" | "card";
|
||||||
|
subscriptionSale?: boolean;
|
||||||
|
subscriptionWindowCharge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(sorted: number[]): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
const hi = sorted[mid] ?? 0;
|
||||||
|
if (sorted.length % 2) return hi;
|
||||||
|
const lo = sorted[mid - 1] ?? 0;
|
||||||
|
return Math.round((lo + hi) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
|
||||||
|
* and money are summed from the signed ledger; duration stats from the closed sessions
|
||||||
|
* in range; subscription counts from the subscriptions table as of `to`.
|
||||||
|
*/
|
||||||
|
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||||
|
const tz = siteTz(db);
|
||||||
|
|
||||||
|
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
|
||||||
|
.orderBy(asc(ledgerEvents.index))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
|
||||||
|
// the currency they were taken in, so take it from a payment in range (then fall back
|
||||||
|
// to the active tariff version). Reports never mix currencies (single-currency site).
|
||||||
|
let currency: string | null = null;
|
||||||
|
|
||||||
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const totals = {
|
||||||
|
entries: 0,
|
||||||
|
exits: 0,
|
||||||
|
payments: 0,
|
||||||
|
revenueMinor: 0,
|
||||||
|
cashMinor: 0,
|
||||||
|
cardMinor: 0,
|
||||||
|
ticketMinor: 0,
|
||||||
|
subscriptionSalesMinor: 0,
|
||||||
|
subscriptionWindowMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function point(label: string): SeriesPoint {
|
||||||
|
let p = seriesMap.get(label);
|
||||||
|
if (!p) {
|
||||||
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||||
|
seriesMap.set(label, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
|
if (row.type === "vehicle_entry") {
|
||||||
|
totals.entries++;
|
||||||
|
p.entries++;
|
||||||
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||||
|
} else if (row.type === "vehicle_exit") {
|
||||||
|
totals.exits++;
|
||||||
|
p.exits++;
|
||||||
|
} else if (row.type === "payment") {
|
||||||
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (!currency && typeof pl.currency === "string") currency = pl.currency;
|
||||||
|
totals.payments++;
|
||||||
|
totals.revenueMinor += amt;
|
||||||
|
p.payments++;
|
||||||
|
p.revenueMinor += amt;
|
||||||
|
if (pl.tender === "card") totals.cardMinor += amt;
|
||||||
|
else totals.cashMinor += amt;
|
||||||
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
|
// (the rest is) transient ticket revenue.
|
||||||
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
|
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||||||
|
else totals.ticketMinor += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
|
// zero-revenue range still labels its money column.
|
||||||
|
if (!currency) {
|
||||||
|
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (tariff) {
|
||||||
|
const tv = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.get();
|
||||||
|
currency = tv?.currency ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
|
||||||
|
const closed = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
|
||||||
|
.all();
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const s of closed) {
|
||||||
|
if (!s.enteredAt || !s.exitedAt) continue;
|
||||||
|
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
|
||||||
|
durations.push(mins);
|
||||||
|
}
|
||||||
|
durations.sort((a, b) => a - b);
|
||||||
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
|
for (const s of subs) {
|
||||||
|
if (s.status === "active") subStats.active++;
|
||||||
|
else if (s.status === "suspended") subStats.suspended++;
|
||||||
|
else if (s.status === "revoked") subStats.revoked++;
|
||||||
|
const validNow =
|
||||||
|
s.status === "active" &&
|
||||||
|
(!s.validFrom || s.validFrom <= q.to) &&
|
||||||
|
(!s.validTo || s.validTo >= q.to);
|
||||||
|
if (validNow) {
|
||||||
|
subStats.currentlyValid++;
|
||||||
|
subStats.coveredCars += s.quantity ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: q.from,
|
||||||
|
to: q.to,
|
||||||
|
bucket: q.bucket,
|
||||||
|
tz,
|
||||||
|
currency,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
closedSessions: durations.length,
|
||||||
|
totalParkedMinutes,
|
||||||
|
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
|
||||||
|
medianParkedMinutes: median(durations),
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
entriesByHour,
|
||||||
|
subscriptions: subStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { reportSummary, type Bucket } from "../reports.js";
|
||||||
|
|
||||||
|
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
|
||||||
|
// cache for durations); no writes, no new event types. Gated on `report:read` — the
|
||||||
|
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
|
||||||
|
|
||||||
|
const BUCKETS: Bucket[] = ["hour", "day", "month"];
|
||||||
|
|
||||||
|
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
|
||||||
|
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bucket: Bucket;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
|
||||||
|
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
|
||||||
|
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
|
||||||
|
// Guard the inversion (from after to) — swap rather than return an empty report.
|
||||||
|
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteIso(s: string | undefined): boolean {
|
||||||
|
return !!s && Number.isFinite(Date.parse(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requirePermission("report:read");
|
||||||
|
|
||||||
|
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
|
||||||
|
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
|
||||||
|
// site timezone. See reports.ts.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => reportSummary(db, parseQuery(req.query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
|
||||||
|
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
|
||||||
|
// the JSON which stays in minor units. text/csv with a download filename.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary.csv",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
|
const lines = [
|
||||||
|
"bucket,entries,exits,payments,revenue",
|
||||||
|
...summary.series.map((p) =>
|
||||||
|
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
reply
|
||||||
|
.header("content-type", "text/csv; charset=utf-8")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
|
||||||
|
)
|
||||||
|
.send(lines.join("\n") + "\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js";
|
|||||||
import { roleRoutes } from "./routes/roles.js";
|
import { roleRoutes } from "./routes/roles.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
@@ -141,6 +142,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
);
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||||
|
await reportRoutes(app, db);
|
||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor);
|
await wsRoutes(app, db, deviceMonitor);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
|
"recharts": "^3.2.1",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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 {
|
export interface BackendIpCandidate {
|
||||||
ip: string;
|
ip: string;
|
||||||
iface: string;
|
iface: string;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
|||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
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. */
|
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||||
export function formatTime(iso: string | null): string {
|
export function formatTime(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const en: Catalog = {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
shifts: "Shifts",
|
shifts: "Shifts",
|
||||||
|
reports: "Reports",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -677,6 +678,40 @@ export const en: Catalog = {
|
|||||||
cashRemoved: "Cash removed",
|
cashRemoved: "Cash removed",
|
||||||
loadFailed: "Failed to load shifts.",
|
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: {
|
logs: {
|
||||||
title: "System logs",
|
title: "System logs",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const sq = {
|
|||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
|
reports: "Raportet",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -691,6 +692,40 @@ export const sq = {
|
|||||||
cashRemoved: "Para të hequra",
|
cashRemoved: "Para të hequra",
|
||||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
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: {
|
logs: {
|
||||||
title: "Loget e sistemit",
|
title: "Loget e sistemit",
|
||||||
refresh: "Rifresko",
|
refresh: "Rifresko",
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ export const qk = {
|
|||||||
siteConfig: ["site-config"] as const,
|
siteConfig: ["site-config"] as const,
|
||||||
shift: ["shift"] as const,
|
shift: ["shift"] as const,
|
||||||
deviceStatus: ["device-status"] as const,
|
deviceStatus: ["device-status"] as const,
|
||||||
|
report: (from: string, to: string, bucket: string) =>
|
||||||
|
["report", from, to, bucket] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+22
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
@@ -30,6 +30,9 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.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
|
// 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
|
// 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("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{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")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -382,6 +386,7 @@ function RootLayout() {
|
|||||||
show("site:read") ||
|
show("site:read") ||
|
||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
|
show("report:read") ||
|
||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<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/site", perm: "site:read" },
|
||||||
{ to: "/setup/users", perm: "user:read" },
|
{ to: "/setup/users", perm: "user:read" },
|
||||||
{ to: "/setup/roles", perm: "role:read" },
|
{ to: "/setup/roles", perm: "role:read" },
|
||||||
|
{ to: "/setup/reports", perm: "report:read" },
|
||||||
{ to: "/shifts", perm: "shift:read" },
|
{ to: "/shifts", perm: "shift:read" },
|
||||||
{ to: "/setup/logs", perm: "log: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
|
// (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.)
|
// 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).
|
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||||
const logsRoute = createRoute({
|
const logsRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
@@ -612,6 +632,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
reportsRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, desc, gte, lte, sql } from "drizzle-orm";
|
export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
Generated
+296
-2
@@ -123,9 +123,12 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.8
|
specifier: ^17.0.8
|
||||||
version: 17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
version: 17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.14
|
specifier: ^5.0.14
|
||||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
version: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
@@ -1095,6 +1098,17 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.2':
|
'@radix-ui/rect@1.1.2':
|
||||||
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0':
|
||||||
|
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||||
|
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1190,6 +1204,9 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.1':
|
'@tailwindcss/node@4.3.1':
|
||||||
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
||||||
|
|
||||||
@@ -1478,6 +1495,33 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -1495,6 +1539,9 @@ packages:
|
|||||||
'@types/react@19.2.17':
|
'@types/react@19.2.17':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.2':
|
'@vitejs/plugin-react@6.0.2':
|
||||||
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1661,6 +1708,50 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.2:
|
||||||
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1674,6 +1765,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -1843,6 +1937,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.48.1:
|
||||||
|
resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==}
|
||||||
|
|
||||||
esbuild@0.18.20:
|
esbuild@0.18.20:
|
||||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1864,6 +1961,9 @@ packages:
|
|||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
expand-template@2.0.3:
|
expand-template@2.0.3:
|
||||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2024,12 +2124,22 @@ packages:
|
|||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
|
immer@10.2.0:
|
||||||
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
|
immer@11.1.8:
|
||||||
|
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
|
||||||
|
|
||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
ini@1.3.8:
|
ini@1.3.8:
|
||||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ipaddr.js@2.4.0:
|
ipaddr.js@2.4.0:
|
||||||
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
|
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -2316,6 +2426,18 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-redux@9.3.0:
|
||||||
|
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8:
|
react-remove-scroll-bar@2.3.8:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2361,10 +2483,29 @@ packages:
|
|||||||
real-require@1.0.0:
|
real-require@1.0.0:
|
||||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
|
recharts@3.8.1:
|
||||||
|
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -2506,6 +2647,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2595,6 +2739,9 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite@8.0.16:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -3438,6 +3585,18 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.2': {}
|
'@radix-ui/rect@1.1.2': {}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.1.8
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3491,6 +3650,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.1':
|
'@tailwindcss/node@4.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -3728,6 +3889,30 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@@ -3744,6 +3929,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))':
|
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
@@ -3902,6 +4089,44 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.2: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.2
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 4.0.0
|
whatwg-mimetype: 4.0.0
|
||||||
@@ -3911,6 +4136,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
@@ -3988,6 +4215,8 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.4
|
hasown: 2.0.4
|
||||||
|
|
||||||
|
es-toolkit@1.48.1: {}
|
||||||
|
|
||||||
esbuild@0.18.20:
|
esbuild@0.18.20:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/android-arm': 0.18.20
|
'@esbuild/android-arm': 0.18.20
|
||||||
@@ -4077,6 +4306,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
expand-template@2.0.3: {}
|
expand-template@2.0.3: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
@@ -4265,10 +4496,16 @@ snapshots:
|
|||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.1.8: {}
|
||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
ini@1.3.8: {}
|
ini@1.3.8: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
ipaddr.js@2.4.0: {}
|
ipaddr.js@2.4.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
@@ -4532,6 +4769,15 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.7
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -4571,8 +4817,36 @@ snapshots:
|
|||||||
|
|
||||||
real-require@1.0.0: {}
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
|
recharts@3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.48.1
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
immer: 10.2.0
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
react-is: 17.0.2
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
ret@0.5.0: {}
|
ret@0.5.0: {}
|
||||||
@@ -4708,6 +4982,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
real-require: 1.0.0
|
real-require: 1.0.0
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@1.2.4: {}
|
tinyexec@1.2.4: {}
|
||||||
@@ -4783,6 +5059,23 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.8
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4):
|
vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -4859,8 +5152,9 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)):
|
zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
immer: 11.1.8
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, reporting]
|
tags: [parking, domain, business, reporting]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-22
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +12,36 @@ Turning the signed event log into the numbers an owner runs the business on. All
|
|||||||
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
||||||
derived and rebuildable, never a separate ledger.
|
derived and rebuildable, never a separate ledger.
|
||||||
|
|
||||||
|
## Built — admin Reports dashboard v1 (2026-06-22)
|
||||||
|
|
||||||
|
A first cut shipped: an admin **Reports** screen (`/setup/reports`, gated on `report:read`), an
|
||||||
|
on-demand **dashboard** (not a live feed). Server aggregates everything in **one call**
|
||||||
|
(`GET /api/reports/summary?from&to&bucket`) so the SPA only renders; `…/summary.csv` exports the
|
||||||
|
per-bucket series. Code: `apps/server/src/reports.ts` (+ `routes/reports.ts`), `apps/web/src/Reports.tsx`.
|
||||||
|
|
||||||
|
- **Ledger-first** (decision 2026-06-22). Entry/exit **counts** and all **money** are summed
|
||||||
|
straight from the signed `ledger_events` — the SAME source the `shift_z_report` reconciles, so a
|
||||||
|
chart total always ties out to the drawer. The revenue **split** (transient ticket /
|
||||||
|
subscription sale / out-of-window window-charge) mirrors the Z-report's split exactly
|
||||||
|
(`subscriptionSale` / `subscriptionWindowCharge` payload flags). Duration/occupancy stats are the
|
||||||
|
one exception: read from the derived `sessions` cache (pairing each entry with its exit on the
|
||||||
|
chain by hand is awkward) — flagged as a cache, not the financial truth.
|
||||||
|
- **Site-timezone bucketing.** A "day"/"hour" bucket is **local wall-clock** in `siteConfig.timezone`
|
||||||
|
(reuses `siteTz()`), so a 23:30Z entry lands on the right local date and the peak-hour histogram
|
||||||
|
reads in wall-clock. Bucket grain: hour / day / month, with date-range presets (today / 7d / 30d / 90d).
|
||||||
|
- **Views:** KPI cards (entries, exits, revenue, payments, avg stay, current subscribers); entry/exit
|
||||||
|
line; revenue bar (per bucket) + cash/card split; revenue-mix pie; **peak-hours** histogram
|
||||||
|
(entries by local hour-of-day); a numeric breakdown (cash/card, the 3-way revenue split, closed
|
||||||
|
sessions, avg/median stay, active subs + cars covered); subscription status counts + currently-valid
|
||||||
|
coverage as of the range end. Charts via **Recharts** (MIT), **lazy-loaded** into its own bundle
|
||||||
|
chunk so the booth never downloads it. Tested: `reports.test.ts` (10) pin the sums, the tz bucketing,
|
||||||
|
the money split, duration stats, and subscription counts.
|
||||||
|
|
||||||
|
**Not yet** (deferred from the list below): anomalies/voids reporting, per-operator takings, the
|
||||||
|
plate/entry search (next section), PDF export, and a live dashboard. The `report:read` permission
|
||||||
|
already existed for "events feed, occupancy, future reports" — this is its first real consumer
|
||||||
|
beyond the feed.
|
||||||
|
|
||||||
## Reports (driven by the events already designed)
|
## Reports (driven by the events already designed)
|
||||||
|
|
||||||
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
||||||
|
|||||||
+14
@@ -1340,3 +1340,17 @@ deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" sec
|
|||||||
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
|
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
|
||||||
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
|
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
|
||||||
matrix; server 80/80.
|
matrix; server 80/80.
|
||||||
|
|
||||||
|
## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR"
|
||||||
|
Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call
|
||||||
|
(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all
|
||||||
|
money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the
|
||||||
|
`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket /
|
||||||
|
subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the
|
||||||
|
`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: 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. reports.test.ts (10) pins the sums/tz/split/duration/subs; server
|
||||||
|
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
|
||||||
|
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
|
||||||
|
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
|
||||||
|
|||||||
Reference in New Issue
Block a user