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
+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=