feat: tip and payment splitting between staff roles #34

Merged
ghost merged 3 commits from feat/tip-splitting into main 2026-03-17 22:03:46 +00:00
9 changed files with 452 additions and 17 deletions
+2
View File
@@ -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",
+71 -6
View File
@@ -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(
"/",
+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=
+135 -2
View File
@@ -39,6 +39,18 @@ const STATUS_COLORS: Record<string, string> = {
no_show: "#9ca3af",
};
const GROOMER_PALETTE = [
"#8b5cf6", // violet
"#0ea5e9", // sky
"#f43f5e", // rose
"#14b8a6", // teal
"#f97316", // orange
"#a855f7", // purple
"#84cc16", // lime
"#e879f9", // fuchsia
];
const UNASSIGNED_COLOR = "#94a3b8";
const STATUS_TRANSITIONS: Record<string, string[]> = {
scheduled: ["confirmed", "cancelled", "no_show"],
confirmed: ["in_progress", "cancelled", "no_show"],
@@ -57,6 +69,7 @@ interface BookingForm {
petId: string;
serviceId: string;
staffId: string;
batherStaffId: string;
date: string;
startTime: string;
notes: string;
@@ -70,6 +83,7 @@ const EMPTY_FORM: BookingForm = {
petId: "",
serviceId: "",
staffId: "",
batherStaffId: "",
date: formatDate(new Date()),
startTime: "09:00",
notes: "",
@@ -94,6 +108,10 @@ export function AppointmentsPage() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
// Groomer view state
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const weekEnd = addDays(weekStart, 6);
@@ -135,9 +153,35 @@ export function AppointmentsPage() {
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
// Assign a stable color to each active groomer by index
const activeGroomers = staff.filter((s) => s.active && s.role === "groomer");
const groomerColorMap = new Map<string, string>(
activeGroomers.map((s, i) => [s.id, GROOMER_PALETTE[i % GROOMER_PALETTE.length] ?? UNASSIGNED_COLOR])
);
function groomerColor(staffId: string | null): string {
if (!staffId) return UNASSIGNED_COLOR;
return groomerColorMap.get(staffId) ?? UNASSIGNED_COLOR;
}
function apptColor(a: Appointment): string {
return viewMode === "groomer" ? groomerColor(a.staffId) : (STATUS_COLORS[a.status] ?? "#94a3b8");
}
function toggleGroomer(key: string | null) {
setHiddenGroomers((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const apptsByDay = days.map((day) => {
const dateStr = formatDate(day);
return appointments.filter((a) => a.startTime.startsWith(dateStr));
const dayAppts = appointments.filter((a) => a.startTime.startsWith(dateStr));
if (viewMode !== "groomer" || hiddenGroomers.size === 0) return dayAppts;
return dayAppts.filter((a) => !hiddenGroomers.has(a.staffId));
});
function openNewForm(date?: Date) {
@@ -166,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,
@@ -252,6 +297,74 @@ export function AppointmentsPage() {
</button>
</div>
{/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
{(["status", "groomer"] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
style={{
...btnStyle,
backgroundColor: viewMode === mode ? "#1e293b" : "#f9fafb",
color: viewMode === mode ? "#fff" : "#374151",
borderColor: viewMode === mode ? "#1e293b" : "#d1d5db",
}}
>
{mode === "status" ? "Status" : "Groomer"}
</button>
))}
{viewMode === "groomer" && (
<>
<span style={{ fontSize: 13, color: "#6b7280", marginLeft: "0.5rem" }}>Show:</span>
{activeGroomers.map((s) => {
const color = groomerColorMap.get(s.id) ?? UNASSIGNED_COLOR;
const visible = !hiddenGroomers.has(s.id);
return (
<button
key={s.id}
onClick={() => toggleGroomer(s.id)}
title={visible ? `Hide ${s.name}` : `Show ${s.name}`}
style={{
...btnStyle,
backgroundColor: visible ? color : "#f1f5f9",
color: visible ? "#fff" : "#94a3b8",
borderColor: visible ? color : "#e2e8f0",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : color, display: "inline-block" }} />
{s.name}
</button>
);
})}
{/* Unassigned toggle */}
{(() => {
const visible = !hiddenGroomers.has(null);
return (
<button
onClick={() => toggleGroomer(null)}
style={{
...btnStyle,
backgroundColor: visible ? UNASSIGNED_COLOR : "#f1f5f9",
color: visible ? "#fff" : "#94a3b8",
borderColor: visible ? UNASSIGNED_COLOR : "#e2e8f0",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : UNASSIGNED_COLOR, display: "inline-block" }} />
Unassigned
</button>
);
})()}
</>
)}
</div>
{/* ── Weekly Calendar ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "0.5rem" }}>
{days.map((day, i) => {
@@ -291,12 +404,13 @@ export function AppointmentsPage() {
{(apptsByDay[i] ?? []).map((a) => {
const svc = services.find((s) => s.id === a.serviceId);
const cli = clients.find((c) => c.id === a.clientId);
const groomer = staff.find((s) => s.id === a.staffId);
return (
<div
key={a.id}
onClick={() => setSelectedAppt(a)}
style={{
background: STATUS_COLORS[a.status] ?? "#94a3b8",
background: apptColor(a),
color: "#fff",
borderRadius: 4,
padding: "0.2rem 0.35rem",
@@ -309,6 +423,11 @@ export function AppointmentsPage() {
<div style={{ fontWeight: 600 }}>{fmtTime(a.startTime)}</div>
<div>{cli?.name ?? "—"}</div>
<div style={{ opacity: 0.9 }}>{svc?.name ?? "—"}</div>
{viewMode === "groomer" && (
<div style={{ opacity: 0.85, fontSize: 10 }}>
{groomer?.name ?? "Unassigned"}
</div>
)}
{a.seriesId && (
<div style={{ opacity: 0.85, fontSize: 10 }}> recurring</div>
)}
@@ -380,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"
@@ -522,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() {
@@ -559,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("_", " ")],
+150 -9
View File
@@ -1,5 +1,5 @@
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 ────────────────────────────────────────────────────────────────────
@@ -146,10 +146,14 @@ function CreateFromAppointmentForm({
function InvoiceDetailModal({
invoice,
allStaff,
allAppointments,
onClose,
onUpdated,
}: {
invoice: Invoice;
allStaff: Staff[];
allAppointments: Appointment[];
onClose: () => void;
onUpdated: () => void;
}) {
@@ -158,6 +162,39 @@ function InvoiceDetailModal({
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
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() {
setSaving(true);
setError(null);
@@ -166,16 +203,32 @@ function InvoiceDetailModal({
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "paid",
paymentMethod,
tipCents,
}),
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
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();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update");
@@ -265,6 +318,88 @@ function InvoiceDetailModal({
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
</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" && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
<Field label="Payment Method">
@@ -330,6 +465,7 @@ export function InvoicesPage() {
const [clients, setClients] = useState<Client[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [allStaff, setAllStaff] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
@@ -337,22 +473,24 @@ export function InvoicesPage() {
const [statusFilter, setStatusFilter] = useState<string>("");
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/clients"),
fetch("/api/appointments"),
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");
}
const [invData, clientData, apptData, svcData] = await Promise.all([
const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
invRes.json() as Promise<Invoice[]>,
clientRes.json() as Promise<Client[]>,
apptRes.json() as Promise<Appointment[]>,
svcRes.json() as Promise<Service[]>,
staffRes.json() as Promise<Staff[]>,
]);
const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
@@ -365,6 +503,7 @@ export function InvoicesPage() {
setClients(clientData);
setAppointments(apptData);
setServices(svcData);
setAllStaff(staffData);
}
useEffect(() => {
@@ -461,6 +600,8 @@ export function InvoicesPage() {
{selectedInvoice && (
<InvoiceDetailModal
invoice={selectedInvoice}
allStaff={allStaff}
allAppointments={appointments}
onClose={() => setSelectedInvoice(null)}
onUpdated={() => {
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,
"tag": "0006_pet_profile_attributes",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773820800000,
"tag": "0007_tip_splitting",
"breakpoints": true
}
]
}
+18
View File
@@ -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(
+12
View File
@@ -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