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:
@@ -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,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<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();
|
||||
}, []); // 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 (
|
||||
<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