feat: tip and payment splitting between staff roles (#34)

* 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 <noreply@paperclip.ing>

* 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 <noreply@paperclip.ing>

* fix: remove unused staff import from invoices route

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #34.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 22:03:46 +00:00
committed by GitHub
parent 1b3a23bd52
commit 4ab5597fd5
9 changed files with 334 additions and 15 deletions
+2
View File
@@ -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",
+71 -6
View File
@@ -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(
"/",
+32
View File
@@ -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<number>`SUM(${invoiceTipSplits.shareCents})::int`,
invoiceCount: sql<number>`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=