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:
committed by
GitHub
parent
1b3a23bd52
commit
4ab5597fd5
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user