* 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:
committed by
GitHub
parent
e63ce83400
commit
f47717dfd8
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user