From 15b9323590935a2c357c6da0bbcbb4fb9700aa6e Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Tue, 17 Mar 2026 21:23:16 +0000 Subject: [PATCH 1/2] feat: reporting dashboard (closes groombook/groombook#6) (GRO-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/reports/summary — KPI cards (revenue, appointments, clients) - Add GET /api/reports/revenue — revenue by day/week/month and by groomer - Add GET /api/reports/appointments — appointment trends with status breakdown - Add GET /api/reports/services — service popularity and revenue by service - Add GET /api/reports/clients — new clients, active count, churn risk list - Add GET /api/reports/export.csv — CSV export for revenue, appointments, services - Add Reports page at /reports with date range picker and group-by control - Wire Reports into nav and routing in App.tsx Co-Authored-By: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/reports.ts | 426 +++++++++++++++++++++++++++++++++ apps/web/src/App.tsx | 3 + apps/web/src/pages/Reports.tsx | 395 ++++++++++++++++++++++++++++++ 4 files changed, 826 insertions(+) create mode 100644 apps/api/src/routes/reports.ts create mode 100644 apps/web/src/pages/Reports.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0640bd0..8e7488f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { appointmentsRouter } from "./routes/appointments.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; import { bookRouter } from "./routes/book.js"; +import { reportsRouter } from "./routes/reports.js"; import { authMiddleware } from "./middleware/auth.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -40,6 +41,7 @@ api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); +api.route("/reports", reportsRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts new file mode 100644 index 0000000..644cd8a --- /dev/null +++ b/apps/api/src/routes/reports.ts @@ -0,0 +1,426 @@ +import { Hono } from "hono"; +import { + and, + eq, + gte, + lt, + sql, + getDb, + appointments, + clients, + invoices, + services, + staff, +} from "@groombook/db"; + +export const reportsRouter = new Hono(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function parseDate(value: string | undefined, fallback: Date): Date { + if (!value) return fallback; + const d = new Date(value); + return isNaN(d.getTime()) ? fallback : d; +} + +function defaultFrom(): Date { + const d = new Date(); + d.setDate(d.getDate() - 30); + d.setHours(0, 0, 0, 0); + return d; +} + +function defaultTo(): Date { + const d = new Date(); + d.setHours(23, 59, 59, 999); + return d; +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +// GET /api/reports/summary?from=&to= +// High-level KPIs for a date range + +reportsRouter.get("/summary", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const [revenueRow] = await db + .select({ + totalRevenueCents: sql`COALESCE(SUM(${invoices.totalCents}), 0)::int`, + paidCount: sql`COUNT(*)::int`, + }) + .from(invoices) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ); + + const [apptRow] = await db + .select({ + total: sql`COUNT(*)::int`, + completed: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + cancelled: sql`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`, + noShow: sql`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ); + + const [clientRow] = await db + .select({ + totalClients: sql`COUNT(*)::int`, + }) + .from(clients); + + // New clients in the period + const [newClientRow] = await db + .select({ + newClients: sql`COUNT(*)::int`, + }) + .from(clients) + .where( + and( + gte(clients.createdAt, from), + lt(clients.createdAt, to) + ) + ); + + return c.json({ + from: from.toISOString(), + to: to.toISOString(), + revenue: { + totalCents: revenueRow?.totalRevenueCents ?? 0, + paidInvoices: revenueRow?.paidCount ?? 0, + }, + appointments: { + total: apptRow?.total ?? 0, + completed: apptRow?.completed ?? 0, + cancelled: apptRow?.cancelled ?? 0, + noShow: apptRow?.noShow ?? 0, + }, + clients: { + total: clientRow?.totalClients ?? 0, + new: newClientRow?.newClients ?? 0, + }, + }); +}); + +// ─── Revenue by period ──────────────────────────────────────────────────────── +// GET /api/reports/revenue?from=&to=&groupBy=day|week|month + +reportsRouter.get("/revenue", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + const groupBy = c.req.query("groupBy") ?? "day"; + + const truncUnit = + groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day"; + + const byPeriod = await db + .select({ + period: sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})::text`, + totalCents: sql`SUM(${invoices.totalCents})::int`, + invoiceCount: sql`COUNT(*)::int`, + }) + .from(invoices) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})` + ) + .orderBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})` + ); + + // Revenue by groomer (via appointment -> staff join) + const byGroomer = await db + .select({ + staffId: staff.id, + staffName: staff.name, + totalCents: sql`SUM(${invoices.totalCents})::int`, + invoiceCount: sql`COUNT(${invoices.id})::int`, + }) + .from(invoices) + .innerJoin(appointments, eq(invoices.appointmentId, appointments.id)) + .innerJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy(staff.id, staff.name) + .orderBy(sql`SUM(${invoices.totalCents}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod, byGroomer }); +}); + +// ─── Appointment analytics ──────────────────────────────────────────────────── +// GET /api/reports/appointments?from=&to=&groupBy=day|week|month + +reportsRouter.get("/appointments", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + const groupBy = c.req.query("groupBy") ?? "day"; + + const truncUnit = + groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day"; + + const byPeriod = await db + .select({ + period: sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})::text`, + total: sql`COUNT(*)::int`, + completed: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + cancelled: sql`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`, + noShow: sql`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .groupBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})` + ) + .orderBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})` + ); + + return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod }); +}); + +// ─── Service popularity ─────────────────────────────────────────────────────── +// GET /api/reports/services?from=&to= + +reportsRouter.get("/services", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const rows = await db + .select({ + serviceId: services.id, + serviceName: services.name, + appointmentCount: sql`COUNT(${appointments.id})::int`, + completedCount: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + revenueCents: sql`COALESCE(SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.totalCents} ELSE 0 END), 0)::int`, + }) + .from(services) + .leftJoin( + appointments, + and( + eq(appointments.serviceId, services.id), + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .leftJoin(invoices, eq(invoices.appointmentId, appointments.id)) + .groupBy(services.id, services.name) + .orderBy(sql`COUNT(${appointments.id}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), rows }); +}); + +// ─── Client retention ───────────────────────────────────────────────────────── +// GET /api/reports/clients?from=&to= +// Returns: new clients, returning clients, clients with no recent activity (churn risk) + +reportsRouter.get("/clients", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + // New clients in period + const newClients = await db + .select({ + clientId: clients.id, + clientName: clients.name, + createdAt: clients.createdAt, + }) + .from(clients) + .where(and(gte(clients.createdAt, from), lt(clients.createdAt, to))) + .orderBy(clients.createdAt); + + // Active clients in period (had at least 1 appointment) + const activeInPeriod = await db + .select({ + clientId: appointments.clientId, + appointmentCount: sql`COUNT(*)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to), + eq(appointments.status, "completed") + ) + ) + .groupBy(appointments.clientId); + + // Clients with no appointment in last 90 days (churn risk) + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + const churnRisk = await db + .select({ + clientId: clients.id, + clientName: clients.name, + lastAppointmentAt: sql`MAX(${appointments.startTime})::text`, + }) + .from(clients) + .leftJoin(appointments, eq(appointments.clientId, clients.id)) + .groupBy(clients.id, clients.name) + .having( + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgo} OR MAX(${appointments.startTime}) IS NULL` + ) + .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`); + + return c.json({ + from: from.toISOString(), + to: to.toISOString(), + newClients, + activeInPeriodCount: activeInPeriod.length, + churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients + churnRiskTotal: churnRisk.length, + }); +}); + +// ─── CSV export ─────────────────────────────────────────────────────────────── +// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to= + +reportsRouter.get("/export.csv", async (c) => { + const db = getDb(); + const type = c.req.query("type") ?? "revenue"; + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + let csv = ""; + + if (type === "revenue") { + const rows = await db + .select({ + paidAt: invoices.paidAt, + clientId: invoices.clientId, + totalCents: invoices.totalCents, + subtotalCents: invoices.subtotalCents, + taxCents: invoices.taxCents, + tipCents: invoices.tipCents, + paymentMethod: invoices.paymentMethod, + staffName: staff.name, + }) + .from(invoices) + .leftJoin(appointments, eq(invoices.appointmentId, appointments.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .orderBy(invoices.paidAt); + + csv = "Date,Groomer,Total,Subtotal,Tax,Tip,Payment Method\n"; + csv += rows + .map((r) => + [ + r.paidAt ? new Date(r.paidAt).toLocaleDateString() : "", + r.staffName ?? "", + (r.totalCents / 100).toFixed(2), + (r.subtotalCents / 100).toFixed(2), + (r.taxCents / 100).toFixed(2), + (r.tipCents / 100).toFixed(2), + r.paymentMethod ?? "", + ].join(",") + ) + .join("\n"); + } else if (type === "appointments") { + const rows = await db + .select({ + startTime: appointments.startTime, + status: appointments.status, + clientId: appointments.clientId, + clientName: clients.name, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .leftJoin(clients, eq(appointments.clientId, clients.id)) + .leftJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .orderBy(appointments.startTime); + + csv = "Date,Client,Service,Groomer,Status\n"; + csv += rows + .map((r) => + [ + new Date(r.startTime).toLocaleDateString(), + `"${(r.clientName ?? "").replace(/"/g, '""')}"`, + `"${(r.serviceName ?? "").replace(/"/g, '""')}"`, + r.staffName ?? "", + r.status, + ].join(",") + ) + .join("\n"); + } else if (type === "services") { + const rows = await db + .select({ + serviceName: services.name, + appointmentCount: sql`COUNT(${appointments.id})::int`, + completedCount: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + }) + .from(services) + .leftJoin( + appointments, + and( + eq(appointments.serviceId, services.id), + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .groupBy(services.id, services.name) + .orderBy(sql`COUNT(${appointments.id}) DESC`); + + csv = "Service,Total Appointments,Completed\n"; + csv += rows + .map((r) => + [ + `"${r.serviceName.replace(/"/g, '""')}"`, + r.appointmentCount, + r.completedCount, + ].join(",") + ) + .join("\n"); + } else { + return c.json({ error: "Invalid type. Use revenue, appointments, or services." }, 400); + } + + const filename = `groombook-${type}-report.csv`; + c.header("Content-Type", "text/csv"); + c.header("Content-Disposition", `attachment; filename="${filename}"`); + return c.text(csv); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 81f7562..a76def2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; +import { ReportsPage } from "./pages/Reports.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, @@ -12,6 +13,7 @@ const NAV_LINKS = [ { to: "/services", label: "Services" }, { to: "/staff", label: "Staff" }, { to: "/invoices", label: "Invoices" }, + { to: "/reports", label: "Reports" }, ]; export function App() { @@ -74,6 +76,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx new file mode 100644 index 0000000..6383757 --- /dev/null +++ b/apps/web/src/pages/Reports.tsx @@ -0,0 +1,395 @@ +import { useEffect, useState } from "react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Summary { + from: string; + to: string; + revenue: { totalCents: number; paidInvoices: number }; + appointments: { total: number; completed: number; cancelled: number; noShow: number }; + clients: { total: number; new: number }; +} + +interface RevenuePeriod { + period: string; + totalCents: number; + invoiceCount: number; +} + +interface RevenueByGroomer { + staffId: string; + staffName: string; + totalCents: number; + invoiceCount: number; +} + +interface RevenueReport { + byPeriod: RevenuePeriod[]; + byGroomer: RevenueByGroomer[]; +} + +interface ApptPeriod { + period: string; + total: number; + completed: number; + cancelled: number; + noShow: number; +} + +interface ServiceRow { + serviceId: string; + serviceName: string; + appointmentCount: number; + completedCount: number; + revenueCents: number; +} + +interface ChurnClient { + clientId: string; + clientName: string; + lastAppointmentAt: string | null; +} + +interface ClientReport { + newClients: { clientId: string; clientName: string; createdAt: string }[]; + activeInPeriodCount: number; + churnRisk: ChurnClient[]; + churnRiskTotal: number; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmtMoney(cents: number) { + return `$${(cents / 100).toFixed(2)}`; +} + +function fmtDate(iso: string | null) { + if (!iso) return "Never"; + return new Date(iso).toLocaleDateString(); +} + +function toInputDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function buildQuery(from: string, to: string, extra: Record = {}) { + const params = new URLSearchParams({ from: `${from}T00:00:00Z`, to: `${to}T23:59:59Z`, ...extra }); + return params.toString(); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
+ {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) { + return ( + + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + {rows.length === 0 && ( + + + + )} + +
+ {h} +
+ {cell} +
+ No data for this period. +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export function ReportsPage() { + const today = new Date(); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const [fromDate, setFromDate] = useState(toInputDate(thirtyDaysAgo)); + const [toDate, setToDate] = useState(toInputDate(today)); + const [groupBy, setGroupBy] = useState<"day" | "week" | "month">("day"); + + const [summary, setSummary] = useState(null); + const [revenue, setRevenue] = useState(null); + const [apptTrends, setApptTrends] = useState([]); + const [services, setServices] = useState([]); + const [clientReport, setClientReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadAll() { + setLoading(true); + setError(null); + try { + const qs = buildQuery(fromDate, toDate); + const qsGroup = buildQuery(fromDate, toDate, { groupBy }); + + const [summRes, revRes, apptRes, svcRes, clientRes] = await Promise.all([ + fetch(`/api/reports/summary?${qs}`), + fetch(`/api/reports/revenue?${qsGroup}`), + fetch(`/api/reports/appointments?${qsGroup}`), + fetch(`/api/reports/services?${qs}`), + fetch(`/api/reports/clients?${qs}`), + ]); + + if (!summRes.ok || !revRes.ok || !apptRes.ok || !svcRes.ok || !clientRes.ok) { + throw new Error("Failed to load report data"); + } + + const [summData, revData, apptData, svcData, clientData] = await Promise.all([ + summRes.json() as Promise, + revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>, + apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>, + svcRes.json() as Promise<{ rows: ServiceRow[] }>, + clientRes.json() as Promise, + ]); + + setSummary(summData); + setRevenue(revData); + setApptTrends(apptData.byPeriod); + setServices(svcData.rows); + setClientReport(clientData); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadAll(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + function exportCsv(type: "revenue" | "appointments" | "services") { + const qs = buildQuery(fromDate, toDate, { type }); + window.open(`/api/reports/export.csv?${qs}`, "_blank"); + } + + const completionRate = + summary && summary.appointments.total > 0 + ? Math.round((summary.appointments.completed / summary.appointments.total) * 100) + : 0; + + const noShowRate = + summary && summary.appointments.total > 0 + ? Math.round((summary.appointments.noShow / summary.appointments.total) * 100) + : 0; + + return ( +
+ {/* ── Controls ── */} +
+

Reports

+ + + + +
+ + + +
+
+ + {error &&

{error}

} + + {/* ── KPI Cards ── */} + {summary && ( +
+ + + + + +
+ )} + + {/* ── Revenue by Period ── */} + Revenue by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)} + [ + fmtDate(r.period), + r.invoiceCount, + fmtMoney(r.totalCents), + ])} + /> + + {/* ── Revenue by Groomer ── */} + Revenue by Groomer +
[ + r.staffName, + r.invoiceCount, + fmtMoney(r.totalCents), + ])} + /> + + {/* ── Appointment Trends ── */} + Appointment Trends by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)} +
[ + fmtDate(r.period), + r.total, + r.completed, + r.cancelled, + r.noShow, + ])} + /> + + {/* ── Service Popularity ── */} + Service Popularity +
[ + r.serviceName, + r.appointmentCount, + r.completedCount, + fmtMoney(r.revenueCents), + ])} + /> + + {/* ── Client Retention ── */} + Client Retention + {clientReport && ( +
+ + + +
+ )} + + Churn Risk — Clients Without a Visit in 90+ Days +
[ + r.clientName, + fmtDate(r.lastAppointmentAt), + ])} + /> + {clientReport && clientReport.churnRiskTotal > 20 && ( +

+ Showing top 20 of {clientReport.churnRiskTotal} at-risk clients. +

+ )} + + ); +} + +// ─── Shared styles ──────────────────────────────────────────────────────────── + +const btnStyle: React.CSSProperties = { + padding: "0.35rem 0.75rem", + border: "1px solid #d1d5db", + borderRadius: 4, + background: "#f9fafb", + cursor: "pointer", + fontSize: 13, + fontWeight: 500, +}; + +const inputStyle: React.CSSProperties = { + padding: "0.3rem 0.4rem", + border: "1px solid #d1d5db", + borderRadius: 4, + fontSize: 13, + marginLeft: "0.25rem", +}; -- 2.52.0 From ddd2ee2bbe6fc507268631d568ea31aad5912000 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Tue, 17 Mar 2026 21:31:59 +0000 Subject: [PATCH 2/2] fix: remove eslint-disable comment for uninstalled react-hooks plugin (GRO-24) Co-Authored-By: Paperclip --- apps/web/src/pages/Reports.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index 6383757..40d0087 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -200,9 +200,7 @@ export function ReportsPage() { } } - useEffect(() => { - loadAll(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loadAll(); }, []); // run on mount only function exportCsv(type: "revenue" | "appointments" | "services") { const qs = buildQuery(fromDate, toDate, { type }); -- 2.52.0