From 4ab5597fd525fdf8971d41a3b42386a89e21eb44 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:03:46 +0000 Subject: [PATCH] feat: tip and payment splitting between staff roles (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: multi-groomer calendar view with per-groomer filtering Add groomer view mode to the appointments calendar: - Toggle between "Status" (existing) and "Groomer" color coding - Per-groomer visibility toggles with color-coded buttons - Appointments colored by assigned groomer in groomer view - Groomer name shown on appointment blocks in groomer view - Unassigned appointments shown in neutral gray Satisfies groombook/groombook#11 requirements for side-by-side/unified groomer schedule visibility and per-groomer filter/toggle. Co-Authored-By: Paperclip * feat: tip and payment splitting between staff roles Implements groombook/groombook#12 — track which staff worked on each pet and calculate tip distribution based on who was involved. Changes: - DB: Add bather_staff_id to appointments (optional secondary staff) - DB: Add invoice_tip_splits table (per-staff tip share ledger) - API: appointments POST/PATCH accept batherStaffId - API: GET /invoices/:id now includes tipSplits[] - API: POST /invoices/:id/tip-splits — saves tip distribution - API: GET /reports/tip-splits — payroll summary of tip earnings - Frontend: Bather/Assistant select on New Appointment form - Frontend: Tip Distribution section in Invoice Detail modal - Auto-populates 70%/30% split when bather is assigned - Editable percentages before payment; saved on Mark as Paid - Displays recorded splits on paid invoices Co-Authored-By: Paperclip * fix: remove unused staff import from invoices route Co-Authored-By: Paperclip --------- Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/api/src/routes/appointments.ts | 2 + apps/api/src/routes/invoices.ts | 77 ++++++++- apps/api/src/routes/reports.ts | 32 ++++ apps/web/src/pages/Appointments.tsx | 17 ++ apps/web/src/pages/Invoices.tsx | 159 +++++++++++++++++- packages/db/migrations/0007_tip_splitting.sql | 25 +++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 18 ++ packages/types/src/index.ts | 12 ++ 9 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 packages/db/migrations/0007_tip_splitting.sql diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 843e532..30a7634 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -26,6 +26,7 @@ const createAppointmentSchema = z.object({ petId: z.string().uuid(), serviceId: z.string().uuid(), staffId: z.string().uuid().optional(), + batherStaffId: z.string().uuid().optional(), startTime: z.string().datetime(), endTime: z.string().datetime(), notes: z.string().max(2000).optional(), @@ -41,6 +42,7 @@ const createAppointmentSchema = z.object({ const updateAppointmentSchema = z.object({ staffId: z.string().uuid().nullable().optional(), + batherStaffId: z.string().uuid().nullable().optional(), status: z .enum([ "scheduled", diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2777d98..9994d7f 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -7,6 +7,7 @@ import { getDb, invoices, invoiceLineItems, + invoiceTipSplits, appointments, services, } from "@groombook/db"; @@ -59,7 +60,7 @@ invoicesRouter.get("/", async (c) => { return c.json(rows); }); -// Get single invoice with line items +// Get single invoice with line items and tip splits invoicesRouter.get("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); @@ -67,14 +68,78 @@ invoicesRouter.get("/:id", async (c) => { const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); if (!invoice) return c.json({ error: "Not found" }, 404); - const lineItems = await db - .select() - .from(invoiceLineItems) - .where(eq(invoiceLineItems.invoiceId, id)); + const [lineItems, tipSplits] = await Promise.all([ + db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), + db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), + ]); - return c.json({ ...invoice, lineItems }); + return c.json({ ...invoice, lineItems, tipSplits }); }); +// Save tip splits for an invoice (replaces existing splits) +const tipSplitSchema = z.object({ + splits: z.array( + z.object({ + staffId: z.string().uuid().nullable(), + staffName: z.string().min(1).max(200), + sharePct: z.number().min(0).max(100), + }) + ).min(1).refine( + (splits) => { + const total = splits.reduce((sum, s) => sum + s.sharePct, 0); + return Math.abs(total - 100) < 0.01; + }, + { message: "Split percentages must sum to 100" } + ), +}); + +invoicesRouter.post( + "/:id/tip-splits", + zValidator("json", tipSplitSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422); + + const tipCents = invoice.tipCents; + + await db.transaction(async (tx) => { + // Remove existing splits + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + + // Insert new splits, distributing tipCents proportionally + let remaining = tipCents; + const rows = body.splits.map((s, i) => { + const isLast = i === body.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, + }; + }); + + if (rows.length > 0) { + await tx.insert(invoiceTipSplits).values(rows); + } + }); + + const splits = await db + .select() + .from(invoiceTipSplits) + .where(eq(invoiceTipSplits.invoiceId, id)); + + return c.json(splits, 201); + } +); + // Create invoice (optionally pre-populated from an appointment) invoicesRouter.post( "/", diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 644cd8a..7a56a31 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -9,6 +9,7 @@ import { appointments, clients, invoices, + invoiceTipSplits, services, staff, } from "@groombook/db"; @@ -303,6 +304,37 @@ reportsRouter.get("/clients", async (c) => { }); }); +// ─── Tip splits payroll report ──────────────────────────────────────────────── +// GET /api/reports/tip-splits?from=&to= +// Aggregates tip earnings per staff member for the period + +reportsRouter.get("/tip-splits", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const rows = await db + .select({ + staffId: invoiceTipSplits.staffId, + staffName: invoiceTipSplits.staffName, + totalTipCents: sql`SUM(${invoiceTipSplits.shareCents})::int`, + invoiceCount: sql`COUNT(DISTINCT ${invoiceTipSplits.invoiceId})::int`, + }) + .from(invoiceTipSplits) + .innerJoin(invoices, eq(invoiceTipSplits.invoiceId, invoices.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy(invoiceTipSplits.staffId, invoiceTipSplits.staffName) + .orderBy(sql`SUM(${invoiceTipSplits.shareCents}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), rows }); +}); + // ─── CSV export ─────────────────────────────────────────────────────────────── // GET /api/reports/export.csv?type=revenue|appointments|services&from=&to= diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 8aee4cf..20c5fae 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -69,6 +69,7 @@ interface BookingForm { petId: string; serviceId: string; staffId: string; + batherStaffId: string; date: string; startTime: string; notes: string; @@ -82,6 +83,7 @@ const EMPTY_FORM: BookingForm = { petId: "", serviceId: "", staffId: "", + batherStaffId: "", date: formatDate(new Date()), startTime: "09:00", notes: "", @@ -208,6 +210,7 @@ export function AppointmentsPage() { petId: form.petId, serviceId: form.serviceId, staffId: form.staffId || undefined, + batherStaffId: form.batherStaffId || undefined, startTime: startISO, endTime: endISO, notes: form.notes || undefined, @@ -496,6 +499,18 @@ export function AppointmentsPage() { ))} + + + c.id === appt.clientId); const service = services.find((s) => s.id === appt.serviceId); const groomer = staff.find((s) => s.id === appt.staffId); + const bather = staff.find((s) => s.id === appt.batherStaffId); const transitions = STATUS_TRANSITIONS[appt.status] ?? []; function handleDeleteClick() { @@ -675,6 +691,7 @@ function AppointmentDetail({ ["Client", client?.name ?? "—"], ["Service", service?.name ?? "—"], ["Groomer", groomer?.name ?? "Unassigned"], + ...(bather ? [["Bather/Asst.", bather.name] as [string, string]] : []), ["Start", new Date(appt.startTime).toLocaleString()], ["End", new Date(appt.endTime).toLocaleString()], ["Status", appt.status.replace("_", " ")], diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 9102ff4..dd86cf7 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { Invoice, Client, Appointment, Service } from "@groombook/types"; +import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -146,10 +146,14 @@ function CreateFromAppointmentForm({ function InvoiceDetailModal({ invoice, + allStaff, + allAppointments, onClose, onUpdated, }: { invoice: Invoice; + allStaff: Staff[]; + allAppointments: Appointment[]; onClose: () => void; onUpdated: () => void; }) { @@ -158,6 +162,39 @@ function InvoiceDetailModal({ const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); + // 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); @@ -166,16 +203,32 @@ function InvoiceDetailModal({ 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({ status: "paid", paymentMethod, tipCents }), }); 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"); @@ -265,6 +318,88 @@ function InvoiceDetailModal({ {invoice.paymentMethod && } + {/* ── 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" && (
@@ -330,6 +465,7 @@ export function InvoicesPage() { const [clients, setClients] = useState([]); const [appointments, setAppointments] = useState([]); const [services, setServices] = useState([]); + const [allStaff, setAllStaff] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -337,22 +473,24 @@ export function InvoicesPage() { const [statusFilter, setStatusFilter] = useState(""); async function loadAll() { - const [invRes, clientRes, apptRes, svcRes] = await Promise.all([ + const [invRes, clientRes, apptRes, svcRes, staffRes] = await Promise.all([ fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")), fetch("/api/clients"), fetch("/api/appointments"), fetch("/api/services?includeInactive=true"), + fetch("/api/staff"), ]); - if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok) { + if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) { throw new Error("Failed to load data"); } - const [invData, clientData, apptData, svcData] = await Promise.all([ + const [invData, clientData, apptData, svcData, staffData] = await Promise.all([ invRes.json() as Promise, clientRes.json() as Promise, apptRes.json() as Promise, svcRes.json() as Promise, + staffRes.json() as Promise, ]); const clientMap = new Map(clientData.map((c) => [c.id, c.name])); @@ -365,6 +503,7 @@ export function InvoicesPage() { setClients(clientData); setAppointments(apptData); setServices(svcData); + setAllStaff(staffData); } useEffect(() => { @@ -461,6 +600,8 @@ export function InvoicesPage() { {selectedInvoice && ( setSelectedInvoice(null)} onUpdated={() => { setSelectedInvoice(null); diff --git a/packages/db/migrations/0007_tip_splitting.sql b/packages/db/migrations/0007_tip_splitting.sql new file mode 100644 index 0000000..64ec22a --- /dev/null +++ b/packages/db/migrations/0007_tip_splitting.sql @@ -0,0 +1,25 @@ +-- Add bather/assistant staff tracking to appointments and tip split ledger (closes groombook/groombook#12) + +-- Secondary staff member (e.g., bather) who assisted the primary groomer +ALTER TABLE "appointments" + ADD COLUMN "bather_staff_id" uuid REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint + +-- Stores per-staff tip allocations calculated when an invoice is paid +CREATE TABLE "invoice_tip_splits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "staff_id" uuid, + "staff_name" text NOT NULL, + "share_pct" numeric(5, 2) NOT NULL, + "share_cents" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invoice_tip_splits" + ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk" + FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "invoice_tip_splits" + ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk" + FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index b0eb036..9a5743d 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773783600000, "tag": "0006_pet_profile_attributes", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1773820800000, + "tag": "0007_tip_splitting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 28cbcb8..8987c2a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -133,6 +133,10 @@ export const appointments = pgTable("appointments", { staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null", }), + // Optional secondary staff (bather/assistant) for tip-split tracking + batherStaffId: uuid("bather_staff_id").references(() => staff.id, { + onDelete: "set null", + }), status: appointmentStatusEnum("status").notNull().default("scheduled"), startTime: timestamp("start_time").notNull(), endTime: timestamp("end_time").notNull(), @@ -184,6 +188,20 @@ export const invoiceLineItems = pgTable("invoice_line_items", { createdAt: timestamp("created_at").notNull().defaultNow(), }); +// Per-staff tip allocation calculated when an invoice is paid. +// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted. +export const invoiceTipSplits = pgTable("invoice_tip_splits", { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }), + staffName: text("staff_name").notNull(), + sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(), + shareCents: integer("share_cents").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + // Tracks which reminder emails have been sent per appointment (prevents duplicates). // reminder_type values: "confirmation", "24h", "2h" export const reminderLogs = pgTable( diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 65af285..dae5721 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -91,6 +91,7 @@ export interface Appointment { petId: string; serviceId: string; staffId: string | null; + batherStaffId: string | null; status: AppointmentStatus; startTime: string; endTime: string; @@ -103,6 +104,16 @@ export interface Appointment { updatedAt: string; } +export interface InvoiceTipSplit { + id: string; + invoiceId: string; + staffId: string | null; + staffName: string; + sharePct: string; + shareCents: number; + createdAt: string; +} + export type InvoiceStatus = "draft" | "pending" | "paid" | "void"; export type PaymentMethod = "cash" | "card" | "check" | "other"; @@ -131,6 +142,7 @@ export interface Invoice { createdAt: string; updatedAt: string; lineItems?: InvoiceLineItem[]; + tipSplits?: InvoiceTipSplit[]; } // Paginated list response