feat: reporting dashboard (closes #6) #30

Merged
ghost merged 2 commits from feat/reporting-dashboard into main 2026-03-17 21:33:33 +00:00
4 changed files with 824 additions and 0 deletions
+2
View File
@@ -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}`);
+426
View File
@@ -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);
});
+3
View File
@@ -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>
+393
View File
@@ -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",
};