feat: reporting dashboard (closes #6) #30
@@ -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}`);
|
||||
|
||||
@@ -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<number>`COALESCE(SUM(${invoices.totalCents}), 0)::int`,
|
||||
paidCount: sql<number>`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<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`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<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(clients);
|
||||
|
||||
// New clients in the period
|
||||
const [newClientRow] = await db
|
||||
.select({
|
||||
newClients: sql<number>`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<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})::text`,
|
||||
totalCents: sql<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`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<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`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<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})::text`,
|
||||
total: sql<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`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<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
revenueCents: sql<number>`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<number>`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<string | null>`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<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`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);
|
||||
});
|
||||
@@ -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() {
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
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<string, string> = {}) {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
padding: "1rem 1.25rem",
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, margin: "0.25rem 0", color: "#111827" }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: "#9ca3af" }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: "1.5rem 0 0.75rem", color: "#111827", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.4rem" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
|
||||
return (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{headers.map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.4rem 0.75rem", borderBottom: "1px solid #e2e8f0", fontWeight: 600, color: "#374151" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f1f5f9" }}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} style={{ padding: "0.4rem 0.75rem", color: "#374151" }}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={headers.length} style={{ padding: "1rem 0.75rem", color: "#9ca3af" }}>
|
||||
No data for this period.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<Summary | null>(null);
|
||||
const [revenue, setRevenue] = useState<RevenueReport | null>(null);
|
||||
const [apptTrends, setApptTrends] = useState<ApptPeriod[]>([]);
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
const [clientReport, setClientReport] = useState<ClientReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<Summary>,
|
||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
||||
clientRes.json() as Promise<ClientReport>,
|
||||
]);
|
||||
|
||||
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(); }, []); // run on mount only
|
||||
|
||||
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 (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900 }}>
|
||||
{/* ── Controls ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>Reports</h1>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
From{" "}
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
To{" "}
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
Group by{" "}
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as "day" | "week" | "month")}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={loadAll} style={{ ...btnStyle, background: "#1d4ed8", color: "#fff", borderColor: "#1d4ed8" }}>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
|
||||
<button onClick={() => exportCsv("revenue")} style={btnStyle}>↓ Revenue CSV</button>
|
||||
<button onClick={() => exportCsv("appointments")} style={btnStyle}>↓ Appointments CSV</button>
|
||||
<button onClick={() => exportCsv("services")} style={btnStyle}>↓ Services CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "#dc2626", padding: "0.75rem", background: "#fef2f2", borderRadius: 6 }}>{error}</p>}
|
||||
|
||||
{/* ── KPI Cards ── */}
|
||||
{summary && (
|
||||
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
<StatCard
|
||||
label="Revenue"
|
||||
value={fmtMoney(summary.revenue.totalCents)}
|
||||
sub={`${summary.revenue.paidInvoices} paid invoices`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Appointments"
|
||||
value={String(summary.appointments.total)}
|
||||
sub={`${completionRate}% completion rate`}
|
||||
/>
|
||||
<StatCard
|
||||
label="No-shows"
|
||||
value={String(summary.appointments.noShow)}
|
||||
sub={`${noShowRate}% of appointments`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancellations"
|
||||
value={String(summary.appointments.cancelled)}
|
||||
/>
|
||||
<StatCard
|
||||
label="New Clients"
|
||||
value={String(summary.clients.new)}
|
||||
sub={`${summary.clients.total} total`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Revenue by Period ── */}
|
||||
<SectionHeader>Revenue by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
|
||||
<Table
|
||||
headers={["Period", "Invoices", "Revenue"]}
|
||||
rows={(revenue?.byPeriod ?? []).map((r) => [
|
||||
fmtDate(r.period),
|
||||
r.invoiceCount,
|
||||
fmtMoney(r.totalCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Revenue by Groomer ── */}
|
||||
<SectionHeader>Revenue by Groomer</SectionHeader>
|
||||
<Table
|
||||
headers={["Groomer", "Invoices", "Revenue"]}
|
||||
rows={(revenue?.byGroomer ?? []).map((r) => [
|
||||
r.staffName,
|
||||
r.invoiceCount,
|
||||
fmtMoney(r.totalCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Appointment Trends ── */}
|
||||
<SectionHeader>Appointment Trends by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
|
||||
<Table
|
||||
headers={["Period", "Total", "Completed", "Cancelled", "No-shows"]}
|
||||
rows={apptTrends.map((r) => [
|
||||
fmtDate(r.period),
|
||||
r.total,
|
||||
r.completed,
|
||||
r.cancelled,
|
||||
r.noShow,
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Service Popularity ── */}
|
||||
<SectionHeader>Service Popularity</SectionHeader>
|
||||
<Table
|
||||
headers={["Service", "Appointments", "Completed", "Revenue"]}
|
||||
rows={services.map((r) => [
|
||||
r.serviceName,
|
||||
r.appointmentCount,
|
||||
r.completedCount,
|
||||
fmtMoney(r.revenueCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Client Retention ── */}
|
||||
<SectionHeader>Client Retention</SectionHeader>
|
||||
{clientReport && (
|
||||
<div style={{ marginBottom: "0.75rem", display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
|
||||
<StatCard label="New Clients" value={String(clientReport.newClients.length)} />
|
||||
<StatCard label="Active This Period" value={String(clientReport.activeInPeriodCount)} />
|
||||
<StatCard
|
||||
label="Churn Risk (90+ days inactive)"
|
||||
value={String(clientReport.churnRiskTotal)}
|
||||
sub="Clients with no recent visit"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionHeader>Churn Risk — Clients Without a Visit in 90+ Days</SectionHeader>
|
||||
<Table
|
||||
headers={["Client", "Last Appointment"]}
|
||||
rows={(clientReport?.churnRisk ?? []).map((r) => [
|
||||
r.clientName,
|
||||
fmtDate(r.lastAppointmentAt),
|
||||
])}
|
||||
/>
|
||||
{clientReport && clientReport.churnRiskTotal > 20 && (
|
||||
<p style={{ fontSize: 12, color: "#6b7280", marginTop: "0.25rem" }}>
|
||||
Showing top 20 of {clientReport.churnRiskTotal} at-risk clients.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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",
|
||||
};
|
||||
Reference in New Issue
Block a user