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(),
|
petId: z.string().uuid(),
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
staffId: z.string().uuid().optional(),
|
staffId: z.string().uuid().optional(),
|
||||||
|
batherStaffId: z.string().uuid().optional(),
|
||||||
startTime: z.string().datetime(),
|
startTime: z.string().datetime(),
|
||||||
endTime: z.string().datetime(),
|
endTime: z.string().datetime(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
@@ -41,6 +42,7 @@ const createAppointmentSchema = z.object({
|
|||||||
|
|
||||||
const updateAppointmentSchema = z.object({
|
const updateAppointmentSchema = z.object({
|
||||||
staffId: z.string().uuid().nullable().optional(),
|
staffId: z.string().uuid().nullable().optional(),
|
||||||
|
batherStaffId: z.string().uuid().nullable().optional(),
|
||||||
status: z
|
status: z
|
||||||
.enum([
|
.enum([
|
||||||
"scheduled",
|
"scheduled",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getDb,
|
getDb,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
|
invoiceTipSplits,
|
||||||
appointments,
|
appointments,
|
||||||
services,
|
services,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
@@ -59,7 +60,7 @@ invoicesRouter.get("/", async (c) => {
|
|||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single invoice with line items
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
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));
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
const lineItems = await db
|
const [lineItems, tipSplits] = await Promise.all([
|
||||||
.select()
|
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||||
.from(invoiceLineItems)
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
.where(eq(invoiceLineItems.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)
|
// Create invoice (optionally pre-populated from an appointment)
|
||||||
invoicesRouter.post(
|
invoicesRouter.post(
|
||||||
"/",
|
"/",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
invoices,
|
invoices,
|
||||||
|
invoiceTipSplits,
|
||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} 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 ───────────────────────────────────────────────────────────────
|
// ─── CSV export ───────────────────────────────────────────────────────────────
|
||||||
// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to=
|
// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to=
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface BookingForm {
|
|||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
staffId: string;
|
staffId: string;
|
||||||
|
batherStaffId: string;
|
||||||
date: string;
|
date: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
@@ -82,6 +83,7 @@ const EMPTY_FORM: BookingForm = {
|
|||||||
petId: "",
|
petId: "",
|
||||||
serviceId: "",
|
serviceId: "",
|
||||||
staffId: "",
|
staffId: "",
|
||||||
|
batherStaffId: "",
|
||||||
date: formatDate(new Date()),
|
date: formatDate(new Date()),
|
||||||
startTime: "09:00",
|
startTime: "09:00",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -208,6 +210,7 @@ export function AppointmentsPage() {
|
|||||||
petId: form.petId,
|
petId: form.petId,
|
||||||
serviceId: form.serviceId,
|
serviceId: form.serviceId,
|
||||||
staffId: form.staffId || undefined,
|
staffId: form.staffId || undefined,
|
||||||
|
batherStaffId: form.batherStaffId || undefined,
|
||||||
startTime: startISO,
|
startTime: startISO,
|
||||||
endTime: endISO,
|
endTime: endISO,
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
@@ -496,6 +499,18 @@ export function AppointmentsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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">
|
<Field label="Date">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -638,6 +653,7 @@ function AppointmentDetail({
|
|||||||
const client = clients.find((c) => c.id === appt.clientId);
|
const client = clients.find((c) => c.id === appt.clientId);
|
||||||
const service = services.find((s) => s.id === appt.serviceId);
|
const service = services.find((s) => s.id === appt.serviceId);
|
||||||
const groomer = staff.find((s) => s.id === appt.staffId);
|
const groomer = staff.find((s) => s.id === appt.staffId);
|
||||||
|
const bather = staff.find((s) => s.id === appt.batherStaffId);
|
||||||
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
|
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
|
||||||
|
|
||||||
function handleDeleteClick() {
|
function handleDeleteClick() {
|
||||||
@@ -675,6 +691,7 @@ function AppointmentDetail({
|
|||||||
["Client", client?.name ?? "—"],
|
["Client", client?.name ?? "—"],
|
||||||
["Service", service?.name ?? "—"],
|
["Service", service?.name ?? "—"],
|
||||||
["Groomer", groomer?.name ?? "Unassigned"],
|
["Groomer", groomer?.name ?? "Unassigned"],
|
||||||
|
...(bather ? [["Bather/Asst.", bather.name] as [string, string]] : []),
|
||||||
["Start", new Date(appt.startTime).toLocaleString()],
|
["Start", new Date(appt.startTime).toLocaleString()],
|
||||||
["End", new Date(appt.endTime).toLocaleString()],
|
["End", new Date(appt.endTime).toLocaleString()],
|
||||||
["Status", appt.status.replace("_", " ")],
|
["Status", appt.status.replace("_", " ")],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -146,10 +146,14 @@ function CreateFromAppointmentForm({
|
|||||||
|
|
||||||
function InvoiceDetailModal({
|
function InvoiceDetailModal({
|
||||||
invoice,
|
invoice,
|
||||||
|
allStaff,
|
||||||
|
allAppointments,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
}: {
|
}: {
|
||||||
invoice: Invoice;
|
invoice: Invoice;
|
||||||
|
allStaff: Staff[];
|
||||||
|
allAppointments: Appointment[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -158,6 +162,39 @@ function InvoiceDetailModal({
|
|||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
|
|
||||||
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
|
const linkedAppt = invoice.appointmentId
|
||||||
|
? allAppointments.find((a) => a.id === invoice.appointmentId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function buildDefaultSplits(): Array<{ staffId: string | null; staffName: string; pct: number }> {
|
||||||
|
const groomer = linkedAppt?.staffId
|
||||||
|
? allStaff.find((s) => s.id === linkedAppt.staffId)
|
||||||
|
: undefined;
|
||||||
|
const bather = linkedAppt?.batherStaffId
|
||||||
|
? allStaff.find((s) => s.id === linkedAppt.batherStaffId)
|
||||||
|
: undefined;
|
||||||
|
if (!groomer) return [];
|
||||||
|
if (bather) {
|
||||||
|
return [
|
||||||
|
{ staffId: groomer.id, staffName: groomer.name, pct: 70 },
|
||||||
|
{ staffId: bather.id, staffName: bather.name, pct: 30 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ staffId: groomer.id, staffName: groomer.name, pct: 100 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSplits = (invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => ({
|
||||||
|
staffId: s.staffId,
|
||||||
|
staffName: s.staffName,
|
||||||
|
pct: parseFloat(s.sharePct),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [tipSplits, setTipSplits] = useState<Array<{ staffId: string | null; staffName: string; pct: number }>>(
|
||||||
|
existingSplits.length > 0 ? existingSplits : buildDefaultSplits()
|
||||||
|
);
|
||||||
|
const [showSplits, setShowSplits] = useState(tipSplits.length > 0);
|
||||||
|
|
||||||
async function markPaid() {
|
async function markPaid() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -166,16 +203,32 @@ function InvoiceDetailModal({
|
|||||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
||||||
status: "paid",
|
|
||||||
paymentMethod,
|
|
||||||
tipCents,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = (await res.json()) as { error?: string };
|
const err = (await res.json()) as { error?: string };
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save tip splits if applicable and tip > 0
|
||||||
|
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||||
|
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) < 0.01) {
|
||||||
|
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
splits: tipSplits.map((r) => ({
|
||||||
|
staffId: r.staffId,
|
||||||
|
staffName: r.staffName,
|
||||||
|
sharePct: r.pct,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUpdated();
|
onUpdated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to update");
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
@@ -265,6 +318,88 @@ function InvoiceDetailModal({
|
|||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tip Distribution ── */}
|
||||||
|
{invoice.status !== "void" && (
|
||||||
|
<div style={{ marginTop: "0.75rem", borderTop: "1px solid #e2e8f0", paddingTop: "0.75rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>Tip Distribution</span>
|
||||||
|
{invoice.status !== "paid" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSplits((v) => !v)}
|
||||||
|
style={{ ...btnStyle, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{showSplits ? "Hide" : "Set up"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show existing splits on paid invoices */}
|
||||||
|
{invoice.status === "paid" && (invoice.tipSplits ?? []).length > 0 && (
|
||||||
|
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
|
||||||
|
<tbody>
|
||||||
|
{(invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style={{ padding: "2px 0", color: "#374151" }}>{s.staffName}</td>
|
||||||
|
<td style={{ padding: "2px 0", color: "#6b7280", textAlign: "right" }}>{parseFloat(s.sharePct).toFixed(0)}%</td>
|
||||||
|
<td style={{ padding: "2px 0", textAlign: "right", fontWeight: 600 }}>{fmtMoney(s.shareCents)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{invoice.status === "paid" && (invoice.tipSplits ?? []).length === 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: "#9ca3af", margin: 0 }}>No split recorded.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable splits before payment */}
|
||||||
|
{invoice.status !== "paid" && showSplits && (
|
||||||
|
<div>
|
||||||
|
{tipSplits.map((row, idx) => {
|
||||||
|
const splitTipCents = Math.round((row.pct / 100) * (Math.round(parseFloat(tipStr) * 100) || 0));
|
||||||
|
return (
|
||||||
|
<div key={idx} style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
value={row.staffName}
|
||||||
|
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, staffName: e.target.value } : r))}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={row.pct}
|
||||||
|
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, pct: Number(e.target.value) } : r))}
|
||||||
|
style={{ ...inputStyle, width: 60, textAlign: "right" }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#6b7280" }}>%</span>
|
||||||
|
<span style={{ minWidth: 60, textAlign: "right", color: "#374151" }}>{fmtMoney(splitTipCents)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTipSplits((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", padding: "0.2rem 0.4rem" }}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.25rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setTipSplits((prev) => [...prev, { staffId: null, staffName: "", pct: 0 }])}
|
||||||
|
style={{ ...btnStyle, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
+ Add person
|
||||||
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const total = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||||
|
const ok = Math.abs(total - 100) < 0.01;
|
||||||
|
return <span style={{ fontSize: 12, color: ok ? "#10b981" : "#ef4444" }}>Total: {total.toFixed(0)}%{ok ? " ✓" : " (must be 100%)"}</span>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invoice.status !== "paid" && invoice.status !== "void" && (
|
{invoice.status !== "paid" && invoice.status !== "void" && (
|
||||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
<Field label="Payment Method">
|
<Field label="Payment Method">
|
||||||
@@ -330,6 +465,7 @@ export function InvoicesPage() {
|
|||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [allStaff, setAllStaff] = useState<Staff[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -337,22 +473,24 @@ export function InvoicesPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const [invRes, clientRes, apptRes, svcRes] = await Promise.all([
|
const [invRes, clientRes, apptRes, svcRes, staffRes] = await Promise.all([
|
||||||
fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")),
|
fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")),
|
||||||
fetch("/api/clients"),
|
fetch("/api/clients"),
|
||||||
fetch("/api/appointments"),
|
fetch("/api/appointments"),
|
||||||
fetch("/api/services?includeInactive=true"),
|
fetch("/api/services?includeInactive=true"),
|
||||||
|
fetch("/api/staff"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok) {
|
if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) {
|
||||||
throw new Error("Failed to load data");
|
throw new Error("Failed to load data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [invData, clientData, apptData, svcData] = await Promise.all([
|
const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
|
||||||
invRes.json() as Promise<Invoice[]>,
|
invRes.json() as Promise<Invoice[]>,
|
||||||
clientRes.json() as Promise<Client[]>,
|
clientRes.json() as Promise<Client[]>,
|
||||||
apptRes.json() as Promise<Appointment[]>,
|
apptRes.json() as Promise<Appointment[]>,
|
||||||
svcRes.json() as Promise<Service[]>,
|
svcRes.json() as Promise<Service[]>,
|
||||||
|
staffRes.json() as Promise<Staff[]>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
|
const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
|
||||||
@@ -365,6 +503,7 @@ export function InvoicesPage() {
|
|||||||
setClients(clientData);
|
setClients(clientData);
|
||||||
setAppointments(apptData);
|
setAppointments(apptData);
|
||||||
setServices(svcData);
|
setServices(svcData);
|
||||||
|
setAllStaff(staffData);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -461,6 +600,8 @@ export function InvoicesPage() {
|
|||||||
{selectedInvoice && (
|
{selectedInvoice && (
|
||||||
<InvoiceDetailModal
|
<InvoiceDetailModal
|
||||||
invoice={selectedInvoice}
|
invoice={selectedInvoice}
|
||||||
|
allStaff={allStaff}
|
||||||
|
allAppointments={appointments}
|
||||||
onClose={() => setSelectedInvoice(null)}
|
onClose={() => setSelectedInvoice(null)}
|
||||||
onUpdated={() => {
|
onUpdated={() => {
|
||||||
setSelectedInvoice(null);
|
setSelectedInvoice(null);
|
||||||
|
|||||||
@@ -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,
|
"when": 1773783600000,
|
||||||
"tag": "0006_pet_profile_attributes",
|
"tag": "0006_pet_profile_attributes",
|
||||||
"breakpoints": true
|
"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, {
|
staffId: uuid("staff_id").references(() => staff.id, {
|
||||||
onDelete: "set null",
|
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"),
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||||
startTime: timestamp("start_time").notNull(),
|
startTime: timestamp("start_time").notNull(),
|
||||||
endTime: timestamp("end_time").notNull(),
|
endTime: timestamp("end_time").notNull(),
|
||||||
@@ -184,6 +188,20 @@ export const invoiceLineItems = pgTable("invoice_line_items", {
|
|||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
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).
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||||
// reminder_type values: "confirmation", "24h", "2h"
|
// reminder_type values: "confirmation", "24h", "2h"
|
||||||
export const reminderLogs = pgTable(
|
export const reminderLogs = pgTable(
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export interface Appointment {
|
|||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
staffId: string | null;
|
staffId: string | null;
|
||||||
|
batherStaffId: string | null;
|
||||||
status: AppointmentStatus;
|
status: AppointmentStatus;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
@@ -103,6 +104,16 @@ export interface Appointment {
|
|||||||
updatedAt: string;
|
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 InvoiceStatus = "draft" | "pending" | "paid" | "void";
|
||||||
export type PaymentMethod = "cash" | "card" | "check" | "other";
|
export type PaymentMethod = "cash" | "card" | "check" | "other";
|
||||||
|
|
||||||
@@ -131,6 +142,7 @@ export interface Invoice {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginated list response
|
// Paginated list response
|
||||||
|
|||||||
Reference in New Issue
Block a user