Phase 4: Booking UI for regular users with date selection, slot booking, and e2e tests
This commit is contained in:
parent
06817875f7
commit
8ff03a8ec3
3 changed files with 712 additions and 1 deletions
350
frontend/app/booking/page.tsx
Normal file
350
frontend/app/booking/page.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
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;
|
||||
}
|
||||
|
||||
export default function BookingPage() {
|
||||
const router = useRouter();
|
||||
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 (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="booking" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="booking" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>Book an Appointment</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
Select a date to see available {slotDurationMinutes}-minute slots
|
||||
</p>
|
||||
|
||||
{successMessage && (
|
||||
<div style={{
|
||||
background: "#d4edda",
|
||||
border: "1px solid #c3e6cb",
|
||||
color: "#155724",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
color: "#721c24",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Selection */}
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>Select a Date</h2>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
}}>
|
||||
{dates.map((date) => {
|
||||
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
|
||||
return (
|
||||
<button
|
||||
key={formatDate(date)}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
background: isSelected ? "#e7f3ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
minWidth: "100px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</div>
|
||||
<div style={{ color: "#666" }}>
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Slots */}
|
||||
{selectedDate && (
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>
|
||||
Available Slots for {selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})}
|
||||
</h2>
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<p>Loading slots...</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No available slots for this date</p>
|
||||
) : (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
}}>
|
||||
{availableSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||
return (
|
||||
<button
|
||||
key={slot.start_time}
|
||||
onClick={() => handleSlotSelect(slot)}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
background: isSelected ? "#e7f3ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.start_time)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Form */}
|
||||
{selectedSlot && (
|
||||
<div style={{
|
||||
background: "#f9f9f9",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "1.5rem",
|
||||
maxWidth: "400px",
|
||||
}}>
|
||||
<h3 style={{ marginBottom: "1rem" }}>
|
||||
Confirm Booking
|
||||
</h3>
|
||||
<p style={{ marginBottom: "1rem" }}>
|
||||
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: 500 }}>
|
||||
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={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
minHeight: "80px",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: "0.75rem",
|
||||
color: note.length >= noteMaxLength ? "#dc3545" : "#666",
|
||||
textAlign: "right",
|
||||
}}>
|
||||
{note.length}/{noteMaxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={isBooking}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "0.75rem",
|
||||
background: isBooking ? "#ccc" : "#0070f3",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: isBooking ? "not-allowed" : "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isBooking ? "Booking..." : "Book Appointment"}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelSlotSelection}
|
||||
disabled={isBooking}
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
background: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
|
|||
|
||||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites" | "admin-availability";
|
||||
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "audit" | "admin-invites" | "admin-availability";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage: PageId;
|
||||
|
|
@ -24,6 +24,7 @@ interface NavItem {
|
|||
const REGULAR_NAV_ITEMS: NavItem[] = [
|
||||
{ id: "counter", label: "Counter", href: "/" },
|
||||
{ id: "sum", label: "Sum", href: "/sum" },
|
||||
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
|
||||
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
|
||||
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue