feat: reporting dashboard (closes groombook/groombook#6) (GRO-24)
- 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user