diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2714be4..1527128 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -42,6 +42,13 @@ const updateInvoiceSchema = z.object({ taxCents: z.number().int().nonnegative().optional(), tipCents: z.number().int().nonnegative().optional(), notes: z.string().max(2000).nullable().optional(), + tipSplits: z.array( + z.object({ + staffId: z.string().uuid().nullable(), + staffName: z.string().min(1).max(200), + sharePct: z.number().min(0).max(100), + }) + ).optional(), }); // List invoices @@ -334,7 +341,30 @@ invoicesRouter.patch( } } - const update: Record = { ...body, updatedAt: new Date() }; + // Tip split validation when marking as paid with a tip + const effectiveTipCents = body.tipCents ?? current.tipCents; + if (body.status === "paid" && effectiveTipCents > 0) { + if (body.tipSplits !== undefined) { + if (body.tipSplits.length === 0) { + return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); + } + const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); + if (totalBps !== 10000) { + return c.json({ error: "Split percentages must sum to 100" }, 422); + } + } else { + const existingSplits = await db + .select({ id: invoiceTipSplits.id }) + .from(invoiceTipSplits) + .where(eq(invoiceTipSplits.invoiceId, id)); + if (existingSplits.length === 0) { + return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); + } + } + } + + const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body; + const update: Record = { ...bodyWithoutSplits, updatedAt: new Date() }; // Auto-set paidAt when marking as paid if (body.status === "paid" && !body.paidAt && !current.paidAt) { @@ -348,11 +378,41 @@ invoicesRouter.patch( update.totalCents = current.subtotalCents + newTaxCents + newTipCents; } - const [updated] = await db - .update(invoices) - .set(update) - .where(eq(invoices.id, id)) - .returning(); + const [updated] = await db.transaction(async (tx) => { + const [upd] = await tx + .update(invoices) + .set(update) + .where(eq(invoices.id, id)) + .returning(); + + // Atomically save tip splits when marking paid with provided splits + if ( + body.status === "paid" && + effectiveTipCents > 0 && + incomingTipSplits !== undefined && + incomingTipSplits.length > 0 + ) { + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + + let remaining = effectiveTipCents; + const rows = incomingTipSplits.map((s, i) => { + const isLast = i === incomingTipSplits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents); + if (!isLast) remaining -= shareCents; + return { + invoiceId: id, + staffId: s.staffId, + staffName: s.staffName, + sharePct: s.sharePct.toFixed(2), + shareCents, + }; + }); + + await tx.insert(invoiceTipSplits).values(rows); + } + + return [upd]; + }); const lineItems = await db .select() diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..8f89d52 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -857,12 +857,56 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── 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}
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 2cbf3ae..04fc7ea 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -221,35 +221,31 @@ function InvoiceDetailModal({ } } 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({ status: "paid", paymentMethod, tipCents }), + body: JSON.stringify(patchBody), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } - // Save tip splits if applicable and tip > 0 - if (showSplits && tipCents > 0 && tipSplits.length > 0) { - const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0); - if (Math.abs(totalPct - 100) < 0.01) { - const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - splits: tipSplits.map((r) => ({ - staffId: r.staffId, - staffName: r.staffName, - sharePct: r.pct, - })), - }), - }); - if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)"); - } - } - onUpdated(); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to update"); @@ -686,19 +682,63 @@ export function InvoicesPage() { // ─── 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}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..d47bea4 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { loadStripe } from "@stripe/stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; @@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); + const completeModalRef = useRef(null); + const paymentModalRef = useRef(null); + + // Focus trap + Escape-to-close for both inline modals + useEffect(() => { + const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current; + if (!modalRef) return; + + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !modalRef) return; + const focusables = modalRef.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(); + }; + }, [isComplete, onClose]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); @@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr if (isComplete) { return ( -
-
+
+
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr } return ( -
-
+
+

Pay Outstanding Balance