Merge pull request #333 from groombook/feature/gro-628-frontend-error-handling
feat(GRO-785): validate tip split totals before marking invoice paid
This commit was merged in pull request #333.
This commit is contained in:
@@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// 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({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -341,30 +349,23 @@ invoicesRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tip split validation when marking as paid with a tip
|
const tipCents = body.tipCents ?? current.tipCents;
|
||||||
const effectiveTipCents = body.tipCents ?? current.tipCents;
|
|
||||||
if (body.status === "paid" && effectiveTipCents > 0) {
|
// Validate tip splits when marking invoice as paid
|
||||||
if (body.tipSplits !== undefined) {
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
if (body.tipSplits.length === 0) {
|
if (body.tipSplits.length === 0) {
|
||||||
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
|
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||||
}
|
}
|
||||||
const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
if (totalBps !== 10000) {
|
if (Math.abs(totalPct - 100) > 0.01) {
|
||||||
return c.json({ error: "Split percentages must sum to 100" }, 422);
|
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||||
}
|
|
||||||
} 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;
|
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||||
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||||
|
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||||
|
|
||||||
// Auto-set paidAt when marking as paid
|
// Auto-set paidAt when marking as paid
|
||||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||||
@@ -378,47 +379,43 @@ invoicesRouter.patch(
|
|||||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db.transaction(async (tx) => {
|
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||||
const [upd] = await tx
|
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||||
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
|
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
|
const splits = body.tipSplits;
|
||||||
|
if (splits.length > 0) {
|
||||||
|
let remaining = tipCents;
|
||||||
|
const rows = splits.map((s, i) => {
|
||||||
|
const isLast = i === splits.length - 1;
|
||||||
|
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedInvoice] = await tx
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set(update)
|
.set(update)
|
||||||
.where(eq(invoices.id, id))
|
.where(eq(invoices.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Atomically save tip splits when marking paid with provided splits
|
const lineItems = await tx
|
||||||
if (
|
.select()
|
||||||
body.status === "paid" &&
|
.from(invoiceLineItems)
|
||||||
effectiveTipCents > 0 &&
|
.where(eq(invoiceLineItems.invoiceId, id));
|
||||||
incomingTipSplits !== undefined &&
|
|
||||||
incomingTipSplits.length > 0
|
|
||||||
) {
|
|
||||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
|
||||||
|
|
||||||
let remaining = effectiveTipCents;
|
return [updatedInvoice, lineItems];
|
||||||
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()
|
|
||||||
.from(invoiceLineItems)
|
|
||||||
.where(eq(invoiceLineItems.invoiceId, id));
|
|
||||||
|
|
||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useSearchParams } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Client modal ── */}
|
{/* ── Client modal ── */}
|
||||||
{showClientForm && (
|
{showClientForm && (
|
||||||
<Modal onClose={() => setShowClientForm(false)}>
|
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
|
||||||
<form onSubmit={submitClient}>
|
<form onSubmit={submitClient}>
|
||||||
<Field label="Full name">
|
<Field label="Full name">
|
||||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Pet modal ── */}
|
{/* ── Pet modal ── */}
|
||||||
{showPetForm && (
|
{showPetForm && (
|
||||||
<Modal onClose={() => setShowPetForm(false)}>
|
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
|
||||||
<form onSubmit={submitPet}>
|
<form onSubmit={submitPet}>
|
||||||
<Field label="Pet name">
|
<Field label="Pet name">
|
||||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Visit log modal ── */}
|
{/* ── Visit log modal ── */}
|
||||||
{showLogForm && logPetId && (
|
{showLogForm && logPetId && (
|
||||||
<Modal onClose={() => setShowLogForm(false)}>
|
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
|
||||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Delete confirmation modal ── */}
|
{/* ── Delete confirmation modal ── */}
|
||||||
{showDeleteConfirm && selectedClient && (
|
{showDeleteConfirm && selectedClient && (
|
||||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -856,7 +852,8 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
// ─── 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();
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -898,15 +895,17 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||||
>
|
>
|
||||||
|
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user