feat: multi-pet client group booking (closes #10) (#31)

* feat: multi-pet client group booking (closes groombook/groombook#10) (GRO-27)

- Add appointment_groups table: links multiple appointments from one client visit
- Add group_id FK on appointments (nullable, backward-compatible)
- Add GET/POST/PATCH/DELETE /api/appointment-groups endpoints
  - POST creates group record + one appointment per pet atomically (with conflict checks)
  - DELETE soft-cancels all appointments in the group
- Add GroupBooking.tsx page at /group-bookings with:
  - Dynamic pet-slot form (min 2 pets, each with their own groomer/service/end time)
  - Auto-calculates end time from service duration
  - Group card list showing all pets, groomers, and statuses side-by-side
  - Client filter and cancel-all action
- Wire into nav and routing in App.tsx
- Export AppointmentGroup type; add groupId field to Appointment type

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: remove eslint-disable for uninstalled react-hooks plugin; remove unused clientMap (GRO-27)

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 #31.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 21:36:03 +00:00
committed by GitHub
parent e63ce83400
commit f47717dfd8
7 changed files with 901 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
import { bookRouter } from "./routes/book.js";
import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { authMiddleware } from "./middleware/auth.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -42,6 +43,7 @@ api.route("/appointments", appointmentsRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+277
View File
@@ -0,0 +1,277 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import {
and,
eq,
getDb,
gte,
lt,
lte,
ne,
appointmentGroups,
appointments,
clients,
pets,
services,
staff,
} from "@groombook/db";
export const appointmentGroupsRouter = new Hono();
// ─── Schemas ──────────────────────────────────────────────────────────────────
const petAppointmentSchema = z.object({
petId: z.string().uuid(),
serviceId: z.string().uuid(),
staffId: z.string().uuid().optional(),
// Each pet may have a different end time (e.g. small dog done faster)
endTime: z.string().datetime(),
priceCents: z.number().int().positive().optional(),
});
const createGroupSchema = z.object({
clientId: z.string().uuid(),
startTime: z.string().datetime(),
// One entry per pet
pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"),
notes: z.string().max(2000).optional(),
});
const updateGroupSchema = z.object({
notes: z.string().max(2000).nullable().optional(),
});
// ─── List groups (compact, with appointment count and start time) ─────────────
appointmentGroupsRouter.get("/", async (c) => {
const db = getDb();
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
: [];
const groups = await db
.select()
.from(appointmentGroups)
.where(groupConditions.length > 0 ? and(...groupConditions) : undefined)
.orderBy(appointmentGroups.createdAt);
if (groups.length === 0) return c.json([]);
// Fetch appointments for all groups (filter by time range if provided)
const apptConditions = [];
if (from) apptConditions.push(gte(appointments.startTime, new Date(from)));
if (to) apptConditions.push(lte(appointments.startTime, new Date(to)));
const allAppts = await db
.select()
.from(appointments)
.where(apptConditions.length > 0 ? and(...apptConditions) : undefined);
const groupApptMap = new Map<string, typeof appointments.$inferSelect[]>();
for (const appt of allAppts) {
if (!appt.groupId) continue;
if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []);
groupApptMap.get(appt.groupId)!.push(appt);
}
const result = groups
.map((g) => ({
...g,
appointments: (groupApptMap.get(g.id) ?? []).sort(
(a, b) => a.startTime.getTime() - b.startTime.getTime()
),
}))
.filter((g) => !from || g.appointments.length > 0);
return c.json(result);
});
// ─── Get single group with its appointments ───────────────────────────────────
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [group] = await db
.select()
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
const groupAppts = await db
.select({
id: appointments.id,
petId: appointments.petId,
petName: pets.name,
serviceId: appointments.serviceId,
serviceName: services.name,
staffId: appointments.staffId,
staffName: staff.name,
status: appointments.status,
startTime: appointments.startTime,
endTime: appointments.endTime,
priceCents: appointments.priceCents,
notes: appointments.notes,
})
.from(appointments)
.leftJoin(pets, eq(appointments.petId, pets.id))
.leftJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(eq(appointments.groupId, id))
.orderBy(appointments.startTime);
const [client] = await db
.select({ name: clients.name, email: clients.email })
.from(clients)
.where(eq(clients.id, group.clientId));
return c.json({ ...group, client, appointments: groupAppts });
});
// ─── Create group booking ─────────────────────────────────────────────────────
appointmentGroupsRouter.post(
"/",
zValidator("json", createGroupSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const startTime = new Date(body.startTime);
// Verify client exists
const [client] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.id, body.clientId));
if (!client) return c.json({ error: "Client not found" }, 404);
// Verify all pets belong to this client
const petIds = body.pets.map((p) => p.petId);
const petRows = await db
.select({ id: pets.id, clientId: pets.clientId })
.from(pets)
.where(eq(pets.clientId, body.clientId));
const ownedPetIds = new Set(petRows.map((p) => p.id));
const unauthorized = petIds.filter((id) => !ownedPetIds.has(id));
if (unauthorized.length > 0) {
return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422);
}
// Deduplicate pets in a single booking
if (new Set(petIds).size !== petIds.length) {
return c.json({ error: "Each pet can only appear once per group booking" }, 422);
}
try {
const result = await db.transaction(async (tx) => {
// Check conflicts for each staff member
for (const pet of body.pets) {
if (!pet.staffId) continue;
const endTime = new Date(pet.endTime);
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, pet.staffId),
lt(appointments.startTime, endTime),
gte(appointments.endTime, startTime),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(
new Error(`Staff conflict for pet ${pet.petId}`),
{ statusCode: 409, petId: pet.petId, staffId: pet.staffId }
);
}
}
// Create the group record
const [group] = await tx
.insert(appointmentGroups)
.values({ clientId: body.clientId, notes: body.notes ?? null })
.returning();
if (!group) throw new Error("Failed to create appointment group");
// Create one appointment per pet
const createdAppts = [];
for (const pet of body.pets) {
const endTime = new Date(pet.endTime);
const [appt] = await tx
.insert(appointments)
.values({
clientId: body.clientId,
petId: pet.petId,
serviceId: pet.serviceId,
staffId: pet.staffId ?? null,
startTime,
endTime,
priceCents: pet.priceCents ?? null,
groupId: group.id,
})
.returning();
if (appt) createdAppts.push(appt);
}
return { group, appointments: createdAppts };
});
return c.json(result, 201);
} catch (err: unknown) {
const e = err as Error & { statusCode?: number };
if (e.statusCode === 409) {
return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409);
}
throw err;
}
}
);
// ─── Update group notes ───────────────────────────────────────────────────────
appointmentGroupsRouter.patch(
"/:id",
zValidator("json", updateGroupSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const [updated] = await db
.update(appointmentGroups)
.set({ ...body, updatedAt: new Date() })
.where(eq(appointmentGroups.id, id))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
}
);
// ─── Cancel all appointments in a group ──────────────────────────────────────
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
.where(eq(appointments.groupId, id));
return c.json({ ok: true });
});
+3
View File
@@ -6,6 +6,7 @@ import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
import { ReportsPage } from "./pages/Reports.js";
import { GroupBookingPage } from "./pages/GroupBooking.js";
const NAV_LINKS = [
{ to: "/", label: "Appointments" },
@@ -13,6 +14,7 @@ const NAV_LINKS = [
{ to: "/services", label: "Services" },
{ to: "/staff", label: "Staff" },
{ to: "/invoices", label: "Invoices" },
{ to: "/group-bookings", label: "Group Bookings" },
{ to: "/reports", label: "Reports" },
];
@@ -76,6 +78,7 @@ export function App() {
<Route path="/staff" element={<StaffPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/book" element={<BookPage />} />
<Route path="/group-bookings" element={<GroupBookingPage />} />
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</main>
+582
View File
@@ -0,0 +1,582 @@
import { useEffect, useState } from "react";
import type { Client, Pet, Service, Staff } from "@groombook/types";
// ─── Types ────────────────────────────────────────────────────────────────────
interface PetSlot {
petId: string;
serviceId: string;
staffId: string;
endTime: string; // HH:MM
}
interface GroupAppointment {
id: string;
petId: string;
petName?: string;
serviceId: string;
serviceName?: string;
staffId: string | null;
staffName?: string | null;
status: string;
startTime: string;
endTime: string;
}
interface AppointmentGroup {
id: string;
clientId: string;
notes: string | null;
createdAt: string;
appointments: GroupAppointment[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString();
}
const STATUS_COLORS: Record<string, string> = {
scheduled: "#3b82f6",
confirmed: "#10b981",
in_progress: "#f59e0b",
completed: "#6b7280",
cancelled: "#ef4444",
no_show: "#9ca3af",
};
// ─── New Group Booking Form ───────────────────────────────────────────────────
function NewGroupBookingForm({
clients,
pets,
services,
staff,
onCreated,
onClose,
}: {
clients: Client[];
pets: Pet[];
services: Service[];
staff: Staff[];
onCreated: () => void;
onClose: () => void;
}) {
const [clientId, setClientId] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState("09:00");
const [notes, setNotes] = useState("");
const [petSlots, setPetSlots] = useState<PetSlot[]>([
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const clientPets = pets.filter((p) => p.clientId === clientId);
const activeServices = services.filter((s) => s.active);
const activeStaff = staff.filter((s) => s.active);
function addPetSlot() {
setPetSlots((prev) => [
...prev,
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
]);
}
function removePetSlot(i: number) {
setPetSlots((prev) => prev.filter((_, idx) => idx !== i));
}
function updateSlot(i: number, field: keyof PetSlot, value: string) {
setPetSlots((prev) =>
prev.map((slot, idx) =>
idx === i ? { ...slot, [field]: value } : slot
)
);
}
// Auto-set end time based on service duration when service changes
function handleServiceChange(i: number, serviceId: string) {
const svc = services.find((s) => s.id === serviceId);
if (svc && startTime) {
const [h, m] = startTime.split(":").map(Number);
const totalMins = (h ?? 0) * 60 + (m ?? 0) + svc.durationMinutes;
const endH = String(Math.floor(totalMins / 60) % 24).padStart(2, "0");
const endM = String(totalMins % 60).padStart(2, "0");
updateSlot(i, "serviceId", serviceId);
updateSlot(i, "endTime", `${endH}:${endM}`);
} else {
updateSlot(i, "serviceId", serviceId);
}
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!clientId) { setError("Please select a client"); return; }
if (petSlots.length < 2) { setError("Add at least 2 pets"); return; }
if (petSlots.some((s) => !s.petId || !s.serviceId)) {
setError("Each pet slot needs a pet and service selected");
return;
}
setSaving(true);
setError(null);
const payload = {
clientId,
startTime: `${date}T${startTime}:00.000Z`,
notes: notes || undefined,
pets: petSlots.map((slot) => ({
petId: slot.petId,
serviceId: slot.serviceId,
staffId: slot.staffId || undefined,
endTime: `${date}T${slot.endTime}:00.000Z`,
})),
};
try {
const res = await fetch("/api/appointment-groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
onCreated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to create group booking");
} finally {
setSaving(false);
}
}
return (
<Modal onClose={onClose}>
<h2 style={{ marginTop: 0 }}>New Group Booking</h2>
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 0 }}>
Book multiple pets from the same client in a single visit. Each pet can have a different groomer.
</p>
<form onSubmit={submit}>
<Field label="Client">
<select
value={clientId}
onChange={(e) => { setClientId(e.target.value); setPetSlots([{ petId: "", serviceId: "", staffId: "", endTime: "10:00" }, { petId: "", serviceId: "", staffId: "", endTime: "10:00" }]); }}
required
style={inputStyle}
>
<option value=""> Select client </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</Field>
<div style={{ display: "flex", gap: "0.75rem" }}>
<Field label="Date" style={{ flex: 1 }}>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required style={inputStyle} />
</Field>
<Field label="Start Time" style={{ flex: 1 }}>
<input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} required style={inputStyle} />
</Field>
</div>
<div style={{ marginBottom: "0.75rem" }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: "0.5rem", color: "#374151" }}>
Pets ({petSlots.length})
</div>
{petSlots.map((slot, i) => (
<div
key={i}
style={{
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: 6,
padding: "0.75rem",
marginBottom: "0.5rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Pet {i + 1}</span>
{petSlots.length > 2 && (
<button
type="button"
onClick={() => removePetSlot(i)}
style={{ ...btnStyle, color: "#dc2626", fontSize: 12, padding: "0.2rem 0.5rem" }}
>
Remove
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
<Field label="Pet">
<select
value={slot.petId}
onChange={(e) => updateSlot(i, "petId", e.target.value)}
required
style={inputStyle}
disabled={!clientId}
>
<option value=""> Select pet </option>
{clientPets.map((p) => (
<option key={p.id} value={p.id}>{p.name} ({p.species})</option>
))}
</select>
</Field>
<Field label="Service">
<select
value={slot.serviceId}
onChange={(e) => handleServiceChange(i, e.target.value)}
required
style={inputStyle}
>
<option value=""> Select service </option>
{activeServices.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="Groomer (optional)">
<select
value={slot.staffId}
onChange={(e) => updateSlot(i, "staffId", e.target.value)}
style={inputStyle}
>
<option value=""> Unassigned </option>
{activeStaff.filter((s) => s.role === "groomer").map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="End Time">
<input
type="time"
value={slot.endTime}
onChange={(e) => updateSlot(i, "endTime", e.target.value)}
required
style={inputStyle}
/>
</Field>
</div>
</div>
))}
<button type="button" onClick={addPetSlot} style={btnStyle}>
+ Add another pet
</button>
</div>
<Field label="Notes (optional)">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
/>
</Field>
{error && <p style={{ color: "#dc2626", margin: "0.5rem 0 0", fontSize: 13 }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
>
{saving ? "Booking…" : "Create Group Booking"}
</button>
<button type="button" onClick={onClose} style={btnStyle}>
Cancel
</button>
</div>
</form>
</Modal>
);
}
// ─── Group Card ───────────────────────────────────────────────────────────────
function GroupCard({
group,
onCancel,
}: {
group: AppointmentGroup;
onCancel: (id: string) => void;
}) {
const startTime = group.appointments[0]?.startTime;
const allCancelled = group.appointments.every((a) => a.status === "cancelled");
return (
<div
style={{
border: "1px solid #e2e8f0",
borderRadius: 8,
marginBottom: "0.75rem",
background: allCancelled ? "#f8fafc" : "#fff",
opacity: allCancelled ? 0.6 : 1,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.75rem 1rem",
borderBottom: "1px solid #e2e8f0",
background: "#f8fafc",
borderRadius: "8px 8px 0 0",
}}
>
<div>
<strong style={{ fontSize: 14 }}>
Group Visit {startTime ? fmtDate(startTime) : "—"}
{startTime && ` at ${fmtTime(startTime)}`}
</strong>
{group.notes && (
<span style={{ marginLeft: "0.75rem", fontSize: 12, color: "#6b7280" }}>
{group.notes}
</span>
)}
</div>
{!allCancelled && (
<button
onClick={() => onCancel(group.id)}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", fontSize: 12 }}
>
Cancel All
</button>
)}
</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "#fafafa" }}>
{["Pet", "Service", "Groomer", "End Time", "Status"].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.4rem 1rem", fontWeight: 600, color: "#6b7280", fontSize: 12 }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{group.appointments.map((appt) => (
<tr key={appt.id}>
<td style={tdStyle}>{appt.petName ?? appt.petId}</td>
<td style={tdStyle}>{appt.serviceName ?? appt.serviceId}</td>
<td style={tdStyle}>{appt.staffName ?? <span style={{ color: "#9ca3af" }}>Unassigned</span>}</td>
<td style={tdStyle}>{fmtTime(appt.endTime)}</td>
<td style={tdStyle}>
<span
style={{
padding: "2px 8px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background: `${STATUS_COLORS[appt.status] ?? "#6b7280"}22`,
color: STATUS_COLORS[appt.status] ?? "#374151",
}}
>
{appt.status.replace("_", " ")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function GroupBookingPage() {
const [groups, setGroups] = useState<AppointmentGroup[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [staff, setStaff] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [clientFilter, setClientFilter] = useState("");
async function loadAll() {
setLoading(true);
setError(null);
try {
const qs = clientFilter ? `?clientId=${clientFilter}` : "";
const [groupRes, clientRes, petRes, svcRes, staffRes] = await Promise.all([
fetch(`/api/appointment-groups${qs}`),
fetch("/api/clients"),
fetch("/api/pets"),
fetch("/api/services"),
fetch("/api/staff"),
]);
if (!groupRes.ok || !clientRes.ok || !petRes.ok || !svcRes.ok || !staffRes.ok) {
throw new Error("Failed to load data");
}
const [groupData, clientData, petData, svcData, staffData] = await Promise.all([
groupRes.json() as Promise<AppointmentGroup[]>,
clientRes.json() as Promise<Client[]>,
petRes.json() as Promise<Pet[]>,
svcRes.json() as Promise<Service[]>,
staffRes.json() as Promise<Staff[]>,
]);
setGroups(groupData);
setClients(clientData);
setPets(petData);
setServices(svcData);
setStaff(staffData);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}
useEffect(() => {
loadAll();
}, [clientFilter]); // re-fetch when client filter changes
async function cancelGroup(groupId: string) {
if (!confirm("Cancel all appointments in this group visit?")) return;
const res = await fetch(`/api/appointment-groups/${groupId}`, { method: "DELETE" });
if (res.ok) loadAll();
}
if (loading && groups.length === 0) return <p style={{ padding: "1rem" }}>Loading</p>;
if (error) return <p style={{ padding: "1rem", color: "#dc2626" }}>Error: {error}</p>;
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.25rem", flexWrap: "wrap" }}>
<h1 style={{ margin: 0 }}>Group Bookings</h1>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
style={{ ...inputStyle, width: "auto", minWidth: 180 }}
>
<option value="">All Clients</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
>
+ New Group Booking
</button>
</div>
{groups.length === 0 ? (
<div style={{ textAlign: "center", padding: "3rem 1rem", color: "#6b7280" }}>
<p style={{ fontSize: 16, marginBottom: "0.5rem" }}>No group bookings yet.</p>
<p style={{ fontSize: 13 }}>
Use group bookings when a client brings multiple pets in the same visit each pet can have a different groomer working simultaneously.
</p>
</div>
) : (
groups.map((group) => (
<GroupCard
key={group.id}
group={{
...group,
appointments: group.appointments.map((appt) => ({
...appt,
petName: pets.find((p) => p.id === appt.petId)?.name,
serviceName: services.find((s) => s.id === appt.serviceId)?.name,
staffName: staff.find((s) => s.id === appt.staffId)?.name,
})),
}}
onCancel={cancelGroup}
/>
))
)}
{showCreate && (
<NewGroupBookingForm
clients={clients}
pets={pets}
services={services}
staff={staff}
onCreated={() => { setShowCreate(false); loadAll(); }}
onClose={() => setShowCreate(false)}
/>
)}
</div>
);
}
// ─── Shared UI helpers ────────────────────────────────────────────────────────
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 640, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
{children}
</div>
</div>
);
}
function Field({
label,
children,
style,
}: {
label: string;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div style={{ marginBottom: "0.5rem", ...style }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.2rem", fontSize: 12, color: "#6b7280" }}>
{label}
</label>
{children}
</div>
);
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
cursor: "pointer",
fontSize: 13,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.4rem 0.5rem",
border: "1px solid #d1d5db",
borderRadius: 4,
fontSize: 13,
boxSizing: "border-box",
};
const tdStyle: React.CSSProperties = {
padding: "0.45rem 1rem",
borderBottom: "1px solid #f1f5f9",
color: "#374151",
};
@@ -0,0 +1,12 @@
-- Appointment groups: link multiple appointments from the same client visit.
-- Each appointment in a group is for a different pet and may have a different groomer.
CREATE TABLE appointment_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Link appointments to a group (nullable — non-grouped appointments are unaffected)
ALTER TABLE appointments ADD COLUMN group_id UUID REFERENCES appointment_groups(id) ON DELETE SET NULL;
+16
View File
@@ -102,6 +102,18 @@ export const recurringSeries = pgTable("recurring_series", {
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// appointmentGroups links multiple appointments from the same client visit.
// Each pet in the group gets its own appointment row with its own groomer.
export const appointmentGroups = pgTable("appointment_groups", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
@@ -127,6 +139,10 @@ export const appointments = pgTable("appointments", {
onDelete: "set null",
}),
seriesIndex: integer("series_index"),
// Multi-pet group booking: links this appointment to others in the same visit
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+9
View File
@@ -61,6 +61,14 @@ export interface RecurringSeries {
createdAt: string;
}
export interface AppointmentGroup {
id: string;
clientId: string;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface Appointment {
id: string;
clientId: string;
@@ -74,6 +82,7 @@ export interface Appointment {
priceCents: number | null;
seriesId: string | null;
seriesIndex: number | null;
groupId: string | null;
createdAt: string;
updatedAt: string;
}