diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8e7488f..40ca8ca 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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}`); diff --git a/apps/api/src/routes/appointmentGroups.ts b/apps/api/src/routes/appointmentGroups.ts new file mode 100644 index 0000000..e2790a4 --- /dev/null +++ b/apps/api/src/routes/appointmentGroups.ts @@ -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(); + 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 }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a76def2..4ba6a85 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx new file mode 100644 index 0000000..8662a07 --- /dev/null +++ b/apps/web/src/pages/GroupBooking.tsx @@ -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 = { + 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([ + { petId: "", serviceId: "", staffId: "", endTime: "10:00" }, + { petId: "", serviceId: "", staffId: "", endTime: "10:00" }, + ]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + +

New Group Booking

+

+ Book multiple pets from the same client in a single visit. Each pet can have a different groomer. +

+
+ + + + +
+ + setDate(e.target.value)} required style={inputStyle} /> + + + setStartTime(e.target.value)} required style={inputStyle} /> + +
+ +
+
+ Pets ({petSlots.length}) +
+ {petSlots.map((slot, i) => ( +
+
+ Pet {i + 1} + {petSlots.length > 2 && ( + + )} +
+
+ + + + + + + + + + + updateSlot(i, "endTime", e.target.value)} + required + style={inputStyle} + /> + +
+
+ ))} + +
+ + +