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
+17
View File
@@ -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() {
))}
</select>
</Field>
<Field label="Bather / Assistant (optional)">
<select
value={form.batherStaffId}
onChange={(e) => setForm((f) => ({ ...f, batherStaffId: e.target.value }))}
style={inputStyle}
>
<option value=""> none </option>
{staff.filter((s) => s.active).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="Date">
<input
type="date"
@@ -638,6 +653,7 @@ function AppointmentDetail({
const client = clients.find((c) => 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("_", " ")],