import { useEffect, useState, useRef } from "react"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── interface InvoiceWithClient extends Invoice { clientName?: string; } // ─── Helpers ────────────────────────────────────────────────────────────────── function fmtMoney(cents: number) { return `$${(cents / 100).toFixed(2)}`; } function fmtDate(iso: string | null) { if (!iso) return "—"; return new Date(iso).toLocaleDateString(); } const STATUS_COLORS: Record = { draft: { bg: "#f3f4f6", color: "#6b7280" }, pending: { bg: "#fef3c7", color: "#92400e" }, paid: { bg: "#d1fae5", color: "#065f46" }, void: { bg: "#fee2e2", color: "#991b1b" }, }; // ─── Invoice Status Badge ───────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { const { bg, color } = STATUS_COLORS[status] ?? { bg: "#f3f4f6", color: "#374151" }; return ( {status} ); } // ─── Create Invoice Form ────────────────────────────────────────────────────── interface CreateFromApptProps { appointments: Appointment[]; clients: Client[]; services: Service[]; loading: boolean; onOpen: () => void; onCreated: () => void; onClose: () => void; } function CreateFromAppointmentForm({ appointments, clients, services, loading, onOpen, onCreated, onClose, }: CreateFromApptProps) { const [selectedApptId, setSelectedApptId] = useState(""); useEffect(() => { onOpen(); }, [onOpen]); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Only show completed appointments without an invoice already const completedAppts = appointments.filter((a) => a.status === "completed"); function getClientName(clientId: string) { return clients.find((c) => c.id === clientId)?.name ?? clientId; } function getServiceName(serviceId: string) { return services.find((s) => s.id === serviceId)?.name ?? serviceId; } async function submit(e: React.FormEvent) { e.preventDefault(); if (!selectedApptId) return; setSaving(true); setError(null); try { const res = await fetch(`/api/invoices/from-appointment/${selectedApptId}`, { method: "POST", }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } onCreated(); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to create invoice"); } finally { setSaving(false); } } if (loading) return

Loading…

; return (

Create Invoice from Appointment

{completedAppts.length === 0 && (

No completed appointments available. Mark an appointment as completed first.

)} {error &&

{error}

}
); } // ─── Invoice Detail Modal ───────────────────────────────────────────────────── function InvoiceDetailModal({ invoice, allStaff, allAppointments, loading, onOpen, onClose, onUpdated, }: { invoice: Invoice; allStaff: Staff[]; allAppointments: Appointment[]; loading: boolean; onOpen: () => void; onClose: () => void; onUpdated: () => void; }) { const [saving, setSaving] = useState(false); useEffect(() => { onOpen(); }, [onOpen]); const [error, setError] = useState(null); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); const [showRefundDialog, setShowRefundDialog] = useState(false); const [refundType, setRefundType] = useState<"full" | "partial">("full"); const [refundAmount, setRefundAmount] = useState(""); const [refundError, setRefundError] = useState(null); const [refunding, setRefunding] = useState(false); // Fetch current staff role to determine manager access const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null); useEffect(() => { fetch("/api/staff/me") .then((r) => r.json()) .then((d) => setStaffMe(d)) .catch(() => setStaffMe(null)); }, []); const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId ? allAppointments.find((a) => a.id === invoice.appointmentId) : undefined; function buildDefaultSplits(): Array<{ staffId: string | null; staffName: string; pct: number }> { const groomer = linkedAppt?.staffId ? allStaff.find((s) => s.id === linkedAppt.staffId) : undefined; const bather = linkedAppt?.batherStaffId ? allStaff.find((s) => s.id === linkedAppt.batherStaffId) : undefined; if (!groomer) return []; if (bather) { return [ { staffId: groomer.id, staffName: groomer.name, pct: 70 }, { staffId: bather.id, staffName: bather.name, pct: 30 }, ]; } return [{ staffId: groomer.id, staffName: groomer.name, pct: 100 }]; } const existingSplits = (invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => ({ staffId: s.staffId, staffName: s.staffName, pct: parseFloat(s.sharePct), })); const [tipSplits, setTipSplits] = useState>( existingSplits.length > 0 ? existingSplits : buildDefaultSplits() ); const [showSplits, setShowSplits] = useState(tipSplits.length > 0); async function markPaid() { setSaving(true); setError(null); const tipCents = Math.round(parseFloat(tipStr) * 100) || 0; // Real-time validation: prevent submit if tip splits don't sum to 100% if (showSplits && tipCents > 0 && tipSplits.length > 0) { const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0); if (Math.abs(totalPct - 100) >= 0.01) { setError("Tip split percentages must sum to 100%"); setSaving(false); return; } } try { const patchBody: { status: string; paymentMethod: string; tipCents: number; tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>; } = { status: "paid", paymentMethod, tipCents }; if (showSplits && tipCents > 0 && tipSplits.length > 0) { patchBody.tipSplits = tipSplits.map((r) => ({ staffId: r.staffId, staffName: r.staffName, sharePct: r.pct, })); } const res = await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patchBody), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } onUpdated(); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to update"); } finally { setSaving(false); } } async function voidInvoice() { if (!confirm("Void this invoice? This cannot be undone.")) return; setSaving(true); setError(null); try { const res = await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "void" }), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } onUpdated(); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to void"); } finally { setSaving(false); } } if (loading) return

Loading…

; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc; return (

Invoice

{["Description", "Qty", "Unit Price", "Total"].map((h) => ( ))} {(invoice.lineItems ?? []).map((item) => ( ))}
{h}
{item.description} {item.quantity} {fmtMoney(item.unitPriceCents)} {fmtMoney(item.totalCents)}
{invoice.status !== "paid" && invoice.status !== "void" ? (
Tip setTipStr(e.target.value)} style={{ ...inputStyle, width: 80, textAlign: "right" }} />
) : ( )} {invoice.paidAt && } {invoice.paymentMethod && } {invoice.stripePaymentIntentId && ( <> {invoice.cardLast4 && ( )} {invoice.paymentStatus && ( )} {invoice.stripeRefundId && ( )} )}
{/* ── Tip Distribution ── */} {invoice.status !== "void" && (
Tip Distribution {invoice.status !== "paid" && ( )}
{/* Show existing splits on paid invoices */} {invoice.status === "paid" && (invoice.tipSplits ?? []).length > 0 && ( {(invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => ( ))}
{s.staffName} {parseFloat(s.sharePct).toFixed(0)}% {fmtMoney(s.shareCents)}
)} {invoice.status === "paid" && (invoice.tipSplits ?? []).length === 0 && (

No split recorded.

)} {/* Editable splits before payment */} {invoice.status !== "paid" && showSplits && (
{tipSplits.map((row, idx) => { const splitTipCents = Math.round((row.pct / 100) * (Math.round(parseFloat(tipStr) * 100) || 0)); return (
setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, staffName: e.target.value } : r))} style={{ ...inputStyle, flex: 1 }} placeholder="Name" /> setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, pct: Number(e.target.value) } : r))} style={{ ...inputStyle, width: 60, textAlign: "right" }} /> % {fmtMoney(splitTipCents)}
); })}
{(() => { const total = tipSplits.reduce((s, r) => s + r.pct, 0); const ok = Math.abs(total - 100) < 0.01; return Total: {total.toFixed(0)}%{ok ? " ✓" : " (must be 100%)"}; })()}
)}
)} {invoice.status !== "paid" && invoice.status !== "void" && (
{error &&

{error}

}
)} {(invoice.status === "paid" || invoice.status === "void") && (
{invoice.stripeRefundId && (
Refunded
)}
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && ( )}
)} {showRefundDialog && (

Process Refund

{refundType === "partial" && (
setRefundAmount(e.target.value)} style={{ ...inputStyle, width: 100 }} />
)} {refundError &&

{refundError}

}
)}
); } function SummaryRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { return (
{label} {value}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── interface PaginatedResponse { data: T[]; total: number; } export function InvoicesPage() { const [invoiceList, setInvoiceList] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreate, setShowCreate] = useState(false); const [selectedInvoice, setSelectedInvoice] = useState(null); const [statusFilter, setStatusFilter] = useState(""); const [total, setTotal] = useState(0); const [offset, setOffset] = useState(0); const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null); const [createLoading, setCreateLoading] = useState(false); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailLoading, setDetailLoading] = useState(false); const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); const LIMIT = 50; useEffect(() => { fetch("/api/invoices/stats/summary") .then((r) => r.ok ? r.json() : null) .then((data) => { if (data) setPaymentStats(data); }) .catch(() => {}); }, []); async function loadInvoices(newOffset: number) { const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) }); if (statusFilter) params.set("status", statusFilter); const res = await fetch(`/api/invoices?${params}`); if (!res.ok) throw new Error("Failed to load invoices"); const page = (await res.json()) as PaginatedResponse; setInvoiceList(page.data); setTotal(page.total); setOffset(newOffset); } useEffect(() => { setLoading(true); loadInvoices(0) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) .finally(() => setLoading(false)); }, [statusFilter]); function loadCreateData() { if (createData) return Promise.resolve(); setCreateLoading(true); return Promise.all([ fetch("/api/clients"), fetch("/api/appointments"), fetch("/api/services?includeInactive=true"), ]) .then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()])) .then(([clients, appointments, services]) => { setCreateData({ clients, appointments, services }); }) .finally(() => setCreateLoading(false)); } function loadDetailData() { if (detailData) return Promise.resolve(); setDetailLoading(true); return Promise.all([fetch("/api/staff"), fetch("/api/appointments")]) .then(([s, a]) => Promise.all([s.json(), a.json()])) .then(([staff, appointments]) => { setDetailData({ staff, appointments }); }) .finally(() => setDetailLoading(false)); } async function openInvoiceDetail(inv: InvoiceWithClient) { const res = await fetch(`/api/invoices/${inv.id}`); if (!res.ok) return; const data = (await res.json()) as Invoice; setSelectedInvoice(data); loadDetailData(); } if (loading) return

Loading…

; if (error) return

Error: {error}

; return (

Invoices

{/* Payment Stats Summary */} {paymentStats && (
Revenue (paid)
{fmtMoney(paymentStats.revenueThisMonth)}
Outstanding
{fmtMoney(paymentStats.outstanding)}
Refunds (this mo.)
{fmtMoney(paymentStats.refundsThisMonth)}
{paymentStats.methodBreakdown.length > 0 && (
By method
{paymentStats.methodBreakdown.map((b) => (
{b.method ?? "other"}: {b.total}
))}
)}
)} {invoiceList.length === 0 ? (

No invoices yet. Create one from a completed appointment.

) : ( <>
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( ))} {invoiceList.map((inv) => ( ))}
{h}
{fmtDate(inv.createdAt)} {inv.clientName ?? "—"} {fmtMoney(inv.subtotalCents)} {fmtMoney(inv.taxCents)} {fmtMoney(inv.tipCents)} {fmtMoney(inv.totalCents)}
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total} invoices
)} {showCreate && ( loadCreateData()} onCreated={() => { setShowCreate(false); setCreateData(null); loadInvoices(0).catch(() => {}); }} onClose={() => setShowCreate(false)} /> )} {selectedInvoice && ( loadDetailData()} onClose={() => { setSelectedInvoice(null); setDetailData(null); }} onUpdated={() => { setSelectedInvoice(null); setDetailData(null); loadInvoices(offset).catch(() => {}); }} /> )}
); } // ─── Shared UI helpers ──────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { const modalRef = useRef(null); useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement; const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); const firstFocusable = focusableElements?.[0]; firstFocusable?.focus(); function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { onClose(); return; } if (e.key !== "Tab") return; if (!modalRef.current) return; const focusables = modalRef.current.querySelectorAll(focusableSelectors); const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last?.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first?.focus(); } } } document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); previouslyFocused?.focus(); }; }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }} >
{children}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
); } 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 = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", };