diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index b7c9fa4..4d6284f 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -76,6 +76,133 @@ portalRouter.patch( } ); +// ─── Appointment confirm/cancel ────────────────────────────────────────────── + +portalRouter.post("/appointments/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422); + } + + if (appt.confirmationStatus !== "pending") { + return c.json({ error: "Appointment is not pending confirmation" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json({ + id: updated.id, + confirmationStatus: updated.confirmationStatus, + confirmedAt: updated.confirmedAt, + updatedAt: updated.updatedAt, + }); +}); + +portalRouter.post("/appointments/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Appointment is already cancelled or completed" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json({ + id: updated.id, + status: updated.status, + confirmationStatus: updated.confirmationStatus, + cancelledAt: updated.cancelledAt, + updatedAt: updated.updatedAt, + }); +}); + // ─── Client-facing waitlist routes ─────────────────────────────────────────── const createWaitlistEntrySchema = z.object({ diff --git a/apps/web/src/portal/mockData.ts b/apps/web/src/portal/mockData.ts index 190b41a..330cfe8 100644 --- a/apps/web/src/portal/mockData.ts +++ b/apps/web/src/portal/mockData.ts @@ -41,6 +41,7 @@ export interface Appointment { duration: number; price: number; status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled"; + confirmationStatus: "pending" | "confirmed" | "cancelled"; notes: string; customerNotes: string; reportCardId?: string; @@ -177,21 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [ id: "a1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["De-shedding Treatment"], date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145, - status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed", + status: "confirmed", confirmationStatus: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed", customerNotes: "", }, { id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90, - status: "confirmed", notes: "First visit with Morgan — patient with anxious pets", + status: "confirmed", confirmationStatus: "confirmed", notes: "First visit with Morgan — patient with anxious pets", customerNotes: "", }, { id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55, - status: "pending", notes: "", + status: "pending", confirmationStatus: "pending", notes: "", customerNotes: "", }, ]; @@ -201,56 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [ id: "pa1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"], date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160, - status: "completed", notes: "", reportCardId: "rc1", + status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc1", customerNotes: "", }, { id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88, - status: "completed", notes: "", reportCardId: "rc2", + status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc2", customerNotes: "", }, { id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Puppy's First Groom"], addOns: [], date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["Nail Grinding"], date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132, - status: "completed", notes: "Holiday groom", + status: "completed", confirmationStatus: "confirmed", notes: "Holiday groom", customerNotes: "", }, { id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: [], date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Bath & Brush"], addOns: [], date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: ["De-shedding Treatment"], date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, ]; diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index bd38475..ba27732 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -41,6 +41,12 @@ const STATUS_COLORS: Record = { cancelled: "bg-red-100 text-red-600", }; +const CONFIRMATION_STATUS_COLORS: Record = { + confirmed: "bg-green-100 text-green-700", + pending: "bg-amber-100 text-amber-700", + cancelled: "bg-red-100 text-red-600", +}; + export function AppointmentsSection({ readOnly, sessionId }: Props) { const [showBooking, setShowBooking] = useState(false); const [expandedId, setExpandedId] = useState(null); @@ -165,14 +171,15 @@ function AppointmentCard({ {isUpcoming(appt) && !readOnly && ( )} + {isUpcoming(appt) && ( + {}} /> + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
- +
)} {appt.reportCardId && ( @@ -188,6 +195,112 @@ function AppointmentCard({ ); } +export function ConfirmationSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { + const [confirming, setConfirming] = useState(false); + const [confirmError, setConfirmError] = useState(null); + const [confirmSuccess, setConfirmSuccess] = useState(false); + + async function handleConfirm() { + if (!window.confirm("Confirm this appointment?")) return; + setConfirming(true); + setConfirmError(null); + try { + const headers: Record = {}; + if (sessionId) { + headers["X-Impersonation-Session-Id"] = sessionId; + } + const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { + method: "POST", + headers, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to confirm" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setConfirmSuccess(true); + setTimeout(() => setConfirmSuccess(false), 2000); + } catch (e) { + setConfirmError(e instanceof Error ? e.message : "Failed to confirm"); + } finally { + setConfirming(false); + } + } + + const statusLabel = appt.confirmationStatus === "confirmed" + ? "✓ Confirmed" + : appt.confirmationStatus === "pending" + ? "Pending confirmation" + : "Cancelled"; + + return ( +
+
+
+ + {statusLabel} + +
+ {!confirmSuccess && appt.confirmationStatus === "pending" && ( + + )} + {confirmSuccess && ( + Confirmed! + )} +
+ {confirmError &&

{confirmError}

} +
+ ); +} + +function CancelAppointmentButton({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(null); + + async function handleCancel() { + if (!window.confirm("Cancel this appointment? This cannot be undone.")) return; + setCancelling(true); + setCancelError(null); + try { + const headers: Record = {}; + if (sessionId) { + headers["X-Impersonation-Session-Id"] = sessionId; + } + const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { + method: "POST", + headers, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to cancel" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + window.location.reload(); + } catch (e) { + setCancelError(e instanceof Error ? e.message : "Failed to cancel"); + setCancelling(false); + } + } + + return ( + <> + + {cancelError &&

{cancelError}

} + + ); +} + export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { const [notes, setNotes] = useState(appt.customerNotes || ""); const [saving, setSaving] = useState(false);