fix(portal): implement reschedule button and modal for Customer Portal

- Add POST /api/portal/appointments/:id/reschedule endpoint with:
  - Session auth via X-Impersonation-Session-Id header
  - Ownership validation (clientId match)
  - Past/in-progress/cancelled/completed guard
  - Conflict detection for the target time slot
  - Duration-preserving reschedule (keeps original endTime offset)

- Add RescheduleFlow modal component in AppointmentsSection:
  - Date picker + time slot grid (same times as BookingFlow)
  - Shows current appointment summary
  - POSTs to /api/portal/appointments/:id/reschedule
  - Reloads page on success

- Wire Reschedule button in AppointmentCard (Appointments section)
- Wire Reschedule button in Dashboard next-appointment card
- Add showReschedule/rescheduleAppointment state in CustomerPortal

Fixes GRO-166

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Flea Flicker
2026-03-28 12:02:04 +00:00
parent b3a3f8023a
commit 96f1494126
4 changed files with 266 additions and 7 deletions
+99
View File
@@ -212,6 +212,105 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
});
});
// ─── Appointment reschedule ──────────────────────────────────────────────────
const rescheduleSchema = z.object({
startTime: z.string().datetime(),
});
portalRouter.post(
"/appointments/:id/reschedule",
zValidator("json", rescheduleSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
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 reschedule a past or in-progress appointment" }, 422);
}
if (appt.status === "cancelled" || appt.status === "completed") {
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
}
const newStart = new Date(body.startTime);
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
const newEnd = new Date(newStart.getTime() + durationMs);
const [existingConflict] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, appt.staffId),
lt(appointments.startTime, newEnd),
gt(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id)
)
)
.limit(1);
if (existingConflict) {
return c.json({ error: "The selected time slot is no longer available" }, 409);
}
const [updated] = await db
.update(appointments)
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
.where(eq(appointments.id, id))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
status: updated.status,
updatedAt: updated.updatedAt,
});
}
);
// ─── Client-facing waitlist routes ───────────────────────────────────────────
const createWaitlistEntrySchema = z.object({
+18 -2
View File
@@ -5,7 +5,7 @@ import {
Settings, LogOut, Shield,
} from "lucide-react";
import { Dashboard } from "./sections/Dashboard.js";
import { AppointmentsSection } from "./sections/Appointments.js";
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
import { PetProfiles } from "./sections/PetProfiles.js";
import { ReportCards } from "./sections/ReportCards.js";
import { BillingPayments } from "./sections/BillingPayments.js";
@@ -33,6 +33,8 @@ export function CustomerPortal() {
const [activeSection, setActiveSection] = useState<Section>("dashboard");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const { branding } = useBranding();
@@ -107,12 +109,17 @@ export function CustomerPortal() {
}
};
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
setRescheduleAppointment(appointment);
setShowReschedule(true);
}, []);
const isReadOnly = session?.status === "active";
const renderSection = () => {
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
case "appointments":
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
case "pets":
@@ -158,6 +165,15 @@ export function CustomerPortal() {
/>
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
/>
)}
{/* Mobile Header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
<button
+141 -3
View File
@@ -49,6 +49,8 @@ const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
export function AppointmentsSection({ readOnly, sessionId }: Props) {
const [showBooking, setShowBooking] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Appointment | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
@@ -90,6 +92,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
sessionId={sessionId}
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
/>
))}
{UPCOMING_APPOINTMENTS.length === 0 && (
@@ -108,6 +111,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
sessionId={sessionId}
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
/>
))}
</div>
@@ -119,14 +123,21 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
readOnly={readOnly}
/>
)}
{showReschedule && rescheduleAppointment && (
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={sessionId}
/>
)}
</div>
);
}
function AppointmentCard({
appointment: appt, expanded, onToggle, readOnly, sessionId,
appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule,
}: {
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null;
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; onReschedule: (appt: Appointment) => void;
}) {
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
@@ -176,7 +187,7 @@ function AppointmentCard({
)}
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
<div className="flex gap-2 mt-3">
<button className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
<button onClick={() => onReschedule(appt)} className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Reschedule
</button>
<CancelAppointmentButton appointment={appt} sessionId={sessionId} />
@@ -372,6 +383,133 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint
);
}
export function RescheduleFlow({
appointment: appt,
onClose,
sessionId,
}: {
appointment: Appointment;
onClose: () => void;
sessionId?: string | null;
}) {
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"];
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
const [hoursMinutes = "", period = ""] = selectedTime.split(" ");
const [hoursStr = "0", minutesStr = "0"] = hoursMinutes.split(":");
let hours = parseInt(hoursStr, 10);
const minutes = parseInt(minutesStr ?? "0", 10);
if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;
const isoTime = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`;
const startTime = new Date(`${selectedDate}T${isoTime}`).toISOString();
setSubmitting(true);
setError(null);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (sessionId) headers["X-Impersonation-Session-Id"] = sessionId;
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
method: "POST",
headers,
body: JSON.stringify({ startTime }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to reschedule" }));
throw new Error(err.error || `HTTP ${res.status}`);
}
setSuccess(true);
setTimeout(() => { window.location.reload(); }, 1500);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reschedule");
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-stone-200">
<h2 className="font-semibold text-stone-800">Reschedule Appointment</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600"></button>
</div>
<div className="p-5">
{success ? (
<div className="text-center py-8">
<div className="text-4xl mb-3"></div>
<h3 className="text-lg font-semibold text-stone-800 mb-1">Appointment Rescheduled!</h3>
<p className="text-sm text-stone-500">Redirecting...</p>
</div>
) : (
<>
{/* Current appointment summary */}
<div className="bg-stone-50 rounded-xl p-4 mb-4 text-sm">
<p className="font-medium text-stone-800">{appt.petName} {appt.services.join(", ")}</p>
<p className="text-stone-500 mt-0.5">
{formatDate(appt.date)} at {appt.time} with {appt.groomerName}
</p>
</div>
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
min={new Date().toISOString().split("T")[0]}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3"
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map(time => (
<button
key={time}
onClick={() => setSelectedTime(time)}
className={`px-3 py-2 rounded-lg text-sm border ${
selectedTime === time
? "border-(--color-accent) bg-(--color-accent-light) font-medium"
: "border-stone-200 hover:border-stone-300"
}`}
>
{time}
</button>
))}
</div>
)}
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!selectedDate || !selectedTime || submitting}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Rescheduling..." : "Confirm Reschedule"}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
const [step, setStep] = useState(1);
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
+8 -2
View File
@@ -1,9 +1,12 @@
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
import type { Appointment } from "../mockData.js";
interface Props {
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
readOnly: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onReschedule?: (appointment: any) => void;
}
function daysUntil(dateStr: string): number {
@@ -18,7 +21,7 @@ function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
}
export function Dashboard({ onNavigate, readOnly }: Props) {
export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
const nextAppt = UPCOMING_APPOINTMENTS[0];
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
const recentEvents = [
@@ -77,7 +80,10 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
</div>
{!readOnly && (
<div className="flex gap-2 mt-4">
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
<button
onClick={() => onReschedule?.(nextAppt)}
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
>
Reschedule
</button>
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">