diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2714be4..f59fc58 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js"; export const invoicesRouter = new Hono(); +// Convert Zod validation errors from 422 to 400 +invoicesRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + throw err; +}); + const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), clientId: z.string().uuid(), @@ -334,6 +342,29 @@ invoicesRouter.patch( } } + // Validate tip splits when marking invoice as paid + if (body.status === "paid" && current.tipCents > 0) { + const splits = await db + .select() + .from(invoiceTipSplits) + .where(eq(invoiceTipSplits.invoiceId, id)); + + if (splits.length === 0) { + return c.json( + { error: "Tip split percentages must sum to 100%" }, + 400 + ); + } + + const totalBps = splits.reduce((sum, s) => sum + Math.round(Number(s.sharePct) * 100), 0); + if (totalBps !== 10000) { + return c.json( + { error: "Tip split percentages must sum to 100%" }, + 400 + ); + } + } + const update: Record = { ...body, updatedAt: new Date() }; // Auto-set paidAt when marking as paid diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..c62690f 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useId } from "react"; import { useSearchParams } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; @@ -647,8 +647,7 @@ export function ClientsPage() { {/* ── Client modal ── */} {showClientForm && ( - setShowClientForm(false)}> -

{editingClient ? "Edit Client" : "New Client"}

+ setShowClientForm(false)}>
setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -678,8 +677,7 @@ export function ClientsPage() { {/* ── Pet modal ── */} {showPetForm && ( - setShowPetForm(false)}> -

{editingPet ? "Edit Pet" : "Add Pet"}

+ setShowPetForm(false)}> setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -753,8 +751,7 @@ export function ClientsPage() { {/* ── Visit log modal ── */} {showLogForm && logPetId && ( - setShowLogForm(false)}> -

Log Grooming Visit

+ setShowLogForm(false)}> {logsLoading[logPetId] &&

Loading history…

} {visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
@@ -817,8 +814,7 @@ export function ClientsPage() { {/* ── Delete confirmation modal ── */} {showDeleteConfirm && selectedClient && ( - setShowDeleteConfirm(false)}> -

Permanently Delete Client

+ setShowDeleteConfirm(false)}>

This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone.

@@ -856,13 +852,20 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { +function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) { + const titleId = useId(); return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
+

{title}

{children}