Add check to both user and admin cancel endpoints to reject cancellation of appointments whose slot_start is in the past. This matches the spec requirement that cancellations can only happen 'before the appointment'. Added tests for both user and admin cancel endpoints. Also includes frontend styling updates.
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Permission } from "../auth-context";
|
|
import { api } from "../api";
|
|
import { Header } from "../components/Header";
|
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
|
import { components } from "../generated/api";
|
|
import constants from "../../../shared/constants.json";
|
|
|
|
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
|
|
|
|
type BookableSlot = components["schemas"]["BookableSlot"];
|
|
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
|
|
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
|
|
|
// Helper to format date as YYYY-MM-DD in local timezone
|
|
function formatDate(d: Date): string {
|
|
const year = d.getFullYear();
|
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
// Helper to format time from ISO string
|
|
function formatTime(isoString: string): string {
|
|
const d = new Date(isoString);
|
|
return d.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
// Get date range for booking (tomorrow to +30 days)
|
|
function getBookableDates(): Date[] {
|
|
const dates: Date[] = [];
|
|
const today = new Date();
|
|
for (let i = minAdvanceDays; i <= maxAdvanceDays; i++) {
|
|
const d = new Date(today);
|
|
d.setDate(today.getDate() + i);
|
|
dates.push(d);
|
|
}
|
|
return dates;
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
main: {
|
|
minHeight: "100vh",
|
|
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
},
|
|
loader: {
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
padding: "2rem",
|
|
maxWidth: "900px",
|
|
margin: "0 auto",
|
|
width: "100%",
|
|
},
|
|
pageTitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.75rem",
|
|
fontWeight: 600,
|
|
color: "#fff",
|
|
marginBottom: "0.5rem",
|
|
},
|
|
pageSubtitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
fontSize: "0.9rem",
|
|
marginBottom: "1.5rem",
|
|
},
|
|
successBanner: {
|
|
background: "rgba(34, 197, 94, 0.15)",
|
|
border: "1px solid rgba(34, 197, 94, 0.3)",
|
|
color: "#4ade80",
|
|
padding: "1rem",
|
|
borderRadius: "8px",
|
|
marginBottom: "1rem",
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "0.875rem",
|
|
},
|
|
errorBanner: {
|
|
background: "rgba(239, 68, 68, 0.15)",
|
|
border: "1px solid rgba(239, 68, 68, 0.3)",
|
|
color: "#f87171",
|
|
padding: "1rem",
|
|
borderRadius: "8px",
|
|
marginBottom: "1rem",
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "0.875rem",
|
|
},
|
|
section: {
|
|
marginBottom: "2rem",
|
|
},
|
|
sectionTitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.1rem",
|
|
fontWeight: 500,
|
|
color: "#fff",
|
|
marginBottom: "1rem",
|
|
},
|
|
dateGrid: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.5rem",
|
|
},
|
|
dateButton: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
padding: "0.75rem 1rem",
|
|
background: "rgba(255, 255, 255, 0.03)",
|
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
borderRadius: "10px",
|
|
cursor: "pointer",
|
|
minWidth: "90px",
|
|
textAlign: "center" as const,
|
|
transition: "all 0.2s",
|
|
},
|
|
dateButtonSelected: {
|
|
background: "rgba(167, 139, 250, 0.15)",
|
|
borderColor: "#a78bfa",
|
|
},
|
|
dateWeekday: {
|
|
color: "#fff",
|
|
fontWeight: 500,
|
|
fontSize: "0.875rem",
|
|
marginBottom: "0.25rem",
|
|
},
|
|
dateDay: {
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
fontSize: "0.8rem",
|
|
},
|
|
slotGrid: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.5rem",
|
|
},
|
|
slotButton: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
padding: "0.6rem 1.25rem",
|
|
background: "rgba(255, 255, 255, 0.03)",
|
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
borderRadius: "8px",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: "0.9rem",
|
|
transition: "all 0.2s",
|
|
},
|
|
slotButtonSelected: {
|
|
background: "rgba(167, 139, 250, 0.15)",
|
|
borderColor: "#a78bfa",
|
|
},
|
|
emptyState: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
color: "rgba(255, 255, 255, 0.4)",
|
|
padding: "1rem 0",
|
|
},
|
|
confirmCard: {
|
|
background: "rgba(255, 255, 255, 0.03)",
|
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
borderRadius: "12px",
|
|
padding: "1.5rem",
|
|
maxWidth: "400px",
|
|
},
|
|
confirmTitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.1rem",
|
|
fontWeight: 500,
|
|
color: "#fff",
|
|
marginBottom: "1rem",
|
|
},
|
|
confirmTime: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
color: "rgba(255, 255, 255, 0.7)",
|
|
marginBottom: "1rem",
|
|
},
|
|
inputLabel: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
display: "block",
|
|
color: "rgba(255, 255, 255, 0.7)",
|
|
fontSize: "0.875rem",
|
|
marginBottom: "0.5rem",
|
|
},
|
|
textarea: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
width: "100%",
|
|
padding: "0.75rem",
|
|
background: "rgba(255, 255, 255, 0.05)",
|
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
borderRadius: "8px",
|
|
color: "#fff",
|
|
fontSize: "0.875rem",
|
|
minHeight: "80px",
|
|
resize: "vertical" as const,
|
|
},
|
|
charCount: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "0.75rem",
|
|
color: "rgba(255, 255, 255, 0.4)",
|
|
textAlign: "right" as const,
|
|
marginTop: "0.25rem",
|
|
},
|
|
charCountWarning: {
|
|
color: "#f87171",
|
|
},
|
|
buttonRow: {
|
|
display: "flex",
|
|
gap: "0.75rem",
|
|
marginTop: "1.5rem",
|
|
},
|
|
bookButton: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
flex: 1,
|
|
padding: "0.75rem",
|
|
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
|
|
border: "none",
|
|
borderRadius: "8px",
|
|
color: "#fff",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
},
|
|
bookButtonDisabled: {
|
|
opacity: 0.5,
|
|
cursor: "not-allowed",
|
|
},
|
|
cancelButton: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
padding: "0.75rem 1.25rem",
|
|
background: "rgba(255, 255, 255, 0.05)",
|
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
borderRadius: "8px",
|
|
color: "rgba(255, 255, 255, 0.7)",
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
},
|
|
};
|
|
|
|
export default function BookingPage() {
|
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
|
requiredPermission: Permission.BOOK_APPOINTMENT,
|
|
fallbackRedirect: "/",
|
|
});
|
|
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
|
|
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
|
|
const [note, setNote] = useState("");
|
|
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
|
|
const [isBooking, setIsBooking] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
|
|
const dates = getBookableDates();
|
|
|
|
const fetchSlots = useCallback(async (date: Date) => {
|
|
setIsLoadingSlots(true);
|
|
setError(null);
|
|
setAvailableSlots([]);
|
|
setSelectedSlot(null);
|
|
|
|
try {
|
|
const dateStr = formatDate(date);
|
|
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
|
setAvailableSlots(data.slots);
|
|
} catch (err) {
|
|
console.error("Failed to fetch slots:", err);
|
|
setError("Failed to load available slots");
|
|
} finally {
|
|
setIsLoadingSlots(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedDate && user && isAuthorized) {
|
|
fetchSlots(selectedDate);
|
|
}
|
|
}, [selectedDate, user, isAuthorized, fetchSlots]);
|
|
|
|
const handleDateSelect = (date: Date) => {
|
|
setSelectedDate(date);
|
|
setSuccessMessage(null);
|
|
};
|
|
|
|
const handleSlotSelect = (slot: BookableSlot) => {
|
|
setSelectedSlot(slot);
|
|
setNote("");
|
|
setError(null);
|
|
};
|
|
|
|
const handleBook = async () => {
|
|
if (!selectedSlot) return;
|
|
|
|
setIsBooking(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const appointment = await api.post<AppointmentResponse>("/api/booking", {
|
|
slot_start: selectedSlot.start_time,
|
|
note: note || null,
|
|
});
|
|
|
|
setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`);
|
|
setSelectedSlot(null);
|
|
setNote("");
|
|
|
|
// Refresh slots to show the booked one is gone
|
|
if (selectedDate) {
|
|
await fetchSlots(selectedDate);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
setError(err.message);
|
|
} else {
|
|
setError("Failed to book appointment");
|
|
}
|
|
} finally {
|
|
setIsBooking(false);
|
|
}
|
|
};
|
|
|
|
const cancelSlotSelection = () => {
|
|
setSelectedSlot(null);
|
|
setNote("");
|
|
setError(null);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main style={styles.main}>
|
|
<div style={styles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!isAuthorized) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<main style={styles.main}>
|
|
<Header currentPage="booking" />
|
|
<div style={styles.content}>
|
|
<h1 style={styles.pageTitle}>Book an Appointment</h1>
|
|
<p style={styles.pageSubtitle}>
|
|
Select a date to see available {slotDurationMinutes}-minute slots
|
|
</p>
|
|
|
|
{successMessage && (
|
|
<div style={styles.successBanner}>{successMessage}</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div style={styles.errorBanner}>{error}</div>
|
|
)}
|
|
|
|
{/* Date Selection */}
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
|
<div style={styles.dateGrid}>
|
|
{dates.map((date) => {
|
|
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
|
|
return (
|
|
<button
|
|
key={formatDate(date)}
|
|
onClick={() => handleDateSelect(date)}
|
|
style={{
|
|
...styles.dateButton,
|
|
...(isSelected ? styles.dateButtonSelected : {}),
|
|
}}
|
|
>
|
|
<div style={styles.dateWeekday}>
|
|
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
|
</div>
|
|
<div style={styles.dateDay}>
|
|
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Available Slots */}
|
|
{selectedDate && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>
|
|
Available Slots for {selectedDate.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric"
|
|
})}
|
|
</h2>
|
|
|
|
{isLoadingSlots ? (
|
|
<div style={styles.emptyState}>Loading slots...</div>
|
|
) : availableSlots.length === 0 ? (
|
|
<div style={styles.emptyState}>No available slots for this date</div>
|
|
) : (
|
|
<div style={styles.slotGrid}>
|
|
{availableSlots.map((slot) => {
|
|
const isSelected = selectedSlot?.start_time === slot.start_time;
|
|
return (
|
|
<button
|
|
key={slot.start_time}
|
|
onClick={() => handleSlotSelect(slot)}
|
|
style={{
|
|
...styles.slotButton,
|
|
...(isSelected ? styles.slotButtonSelected : {}),
|
|
}}
|
|
>
|
|
{formatTime(slot.start_time)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Booking Form */}
|
|
{selectedSlot && (
|
|
<div style={styles.confirmCard}>
|
|
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
|
|
<p style={styles.confirmTime}>
|
|
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
|
</p>
|
|
|
|
<div>
|
|
<label style={styles.inputLabel}>
|
|
Note (optional, max {noteMaxLength} chars)
|
|
</label>
|
|
<textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
|
|
placeholder="Add a note about your appointment..."
|
|
style={styles.textarea}
|
|
/>
|
|
<div style={{
|
|
...styles.charCount,
|
|
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
|
|
}}>
|
|
{note.length}/{noteMaxLength}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={styles.buttonRow}>
|
|
<button
|
|
onClick={handleBook}
|
|
disabled={isBooking}
|
|
style={{
|
|
...styles.bookButton,
|
|
...(isBooking ? styles.bookButtonDisabled : {}),
|
|
}}
|
|
>
|
|
{isBooking ? "Booking..." : "Book Appointment"}
|
|
</button>
|
|
<button
|
|
onClick={cancelSlotSelection}
|
|
disabled={isBooking}
|
|
style={styles.cancelButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|