feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal - Inline packages/types/ as local packages/types module - Add tsconfig path aliases for @groombook/types - Port Dockerfile and CI workflow - Image name: ghcr.io/groombook/web Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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 #e5e7eb",
|
||||
borderRadius: 10,
|
||||
padding: "1rem 1.25rem",
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, 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: 15, fontWeight: 700, margin: "1.75rem 0 0.75rem", color: "#1a202c", borderBottom: "2px solid #e5e7eb", paddingBottom: "0.5rem" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
|
||||
return (
|
||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{headers.map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontWeight: 600, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f3f4f6" }}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} style={{ padding: "0.5rem 0.75rem", color: "#374151" }}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={headers.length} style={{ padding: "1.5rem 0.75rem", color: "#9ca3af", textAlign: "center" }}>
|
||||
No data for this period.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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}`),
|
||||
]);
|
||||
|
||||
const failures = [
|
||||
["summary", summRes],
|
||||
["revenue", revRes],
|
||||
["appointments", apptRes],
|
||||
["services", svcRes],
|
||||
["clients", clientRes],
|
||||
].filter(([, r]) => !(r as Response).ok);
|
||||
if (failures.length > 0) {
|
||||
const details = await Promise.all(
|
||||
failures.map(async ([name, r]) => {
|
||||
const res = r as Response;
|
||||
let body = "";
|
||||
try { body = await res.text(); } catch { /* ignore */ }
|
||||
return `${name} (HTTP ${res.status}${body ? `: ${body.slice(0, 120)}` : ""})`;
|
||||
})
|
||||
);
|
||||
throw new Error(`Failed to load report data — ${details.join(", ")}`);
|
||||
}
|
||||
|
||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||
]);
|
||||
|
||||
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: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
|
||||
{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.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
marginLeft: "0.25rem",
|
||||
};
|
||||
Reference in New Issue
Block a user