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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
"/",
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
Reference in New Issue
Block a user