From bbd9fae763bbdd8af27ddff333730e9b558eaad0 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 20:18:33 +0100 Subject: [PATCH] Phase 7: Final cleanup - Remove deprecated booking/appointment code Deleted deprecated files: - backend/routes/booking.py - frontend/app/admin/appointments/, booking/, appointments/, sum/, audit/ - frontend/app/utils/appointment.ts - frontend/e2e/booking.spec.ts, appointments.spec.ts Updated references: - exchange/page.tsx: Use /api/exchange/slots instead of /api/booking/slots - useRequireAuth.ts: Redirect to /admin/trades and /exchange - profile.tsx, invites.tsx: Update fallback redirect - E2E tests: Update all /audit references to /admin/trades - profile.test.tsx: Update admin redirect test --- backend/routes/booking.py | 383 -------------------- frontend/app/admin/appointments/page.tsx | 319 ----------------- frontend/app/appointments/page.tsx | 285 --------------- frontend/app/booking/page.tsx | 428 ----------------------- frontend/app/exchange/page.tsx | 4 +- frontend/app/hooks/useRequireAuth.ts | 8 +- frontend/app/invites/page.tsx | 2 +- frontend/app/profile/page.test.tsx | 6 +- frontend/app/profile/page.tsx | 2 +- frontend/app/utils/appointment.ts | 36 -- frontend/e2e/admin-invites.spec.ts | 2 +- frontend/e2e/appointments.spec.ts | 230 ------------ frontend/e2e/availability.spec.ts | 2 +- frontend/e2e/booking.spec.ts | 393 --------------------- frontend/e2e/price-history.spec.ts | 4 +- frontend/e2e/profile.spec.ts | 28 +- 16 files changed, 29 insertions(+), 2103 deletions(-) delete mode 100644 backend/routes/booking.py delete mode 100644 frontend/app/admin/appointments/page.tsx delete mode 100644 frontend/app/appointments/page.tsx delete mode 100644 frontend/app/booking/page.tsx delete mode 100644 frontend/app/utils/appointment.ts delete mode 100644 frontend/e2e/appointments.spec.ts delete mode 100644 frontend/e2e/booking.spec.ts diff --git a/backend/routes/booking.py b/backend/routes/booking.py deleted file mode 100644 index d46342d..0000000 --- a/backend/routes/booking.py +++ /dev/null @@ -1,383 +0,0 @@ -"""Booking routes for users to book appointments.""" - -from collections.abc import Sequence -from datetime import UTC, date, datetime, time, timedelta - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import and_, func, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload - -from auth import require_permission -from database import get_db -from date_validation import validate_date_in_range -from models import Appointment, AppointmentStatus, Availability, Permission, User -from pagination import calculate_offset, create_paginated_response -from schemas import ( - AppointmentResponse, - AvailableSlotsResponse, - BookableSlot, - BookingRequest, - PaginatedAppointments, -) -from shared_constants import SLOT_DURATION_MINUTES - -router = APIRouter(prefix="/api/booking", tags=["booking"]) - - -def _to_appointment_response( - appointment: Appointment, - user_email: str | None = None, -) -> AppointmentResponse: - """Convert an Appointment model to AppointmentResponse schema. - - Args: - appointment: The appointment model instance - user_email: Optional user email. If not provided, uses appointment.user.email - """ - email = user_email if user_email is not None else appointment.user.email - return AppointmentResponse( - id=appointment.id, - user_id=appointment.user_id, - user_email=email, - slot_start=appointment.slot_start, - slot_end=appointment.slot_end, - note=appointment.note, - status=appointment.status.value, - created_at=appointment.created_at, - cancelled_at=appointment.cancelled_at, - ) - - -def _get_valid_minute_boundaries() -> tuple[int, ...]: - """Get valid minute boundaries based on SLOT_DURATION_MINUTES. - - Assumes SLOT_DURATION_MINUTES divides 60 evenly (e.g., 15 minutes = 0, 15, 30, 45). - """ - boundaries: list[int] = [] - minute = 0 - while minute < 60: - boundaries.append(minute) - minute += SLOT_DURATION_MINUTES - return tuple(boundaries) - - -def _validate_booking_date(d: date) -> None: - """Validate a date is within the bookable range.""" - validate_date_in_range(d, context="book") - - -def _expand_availability_to_slots( - availability_slots: Sequence[Availability], - target_date: date, -) -> list[BookableSlot]: - """Expand availability time ranges into 15-minute bookable slots.""" - result: list[BookableSlot] = [] - - for avail in availability_slots: - # Create datetime objects for start and end - current = datetime.combine(target_date, avail.start_time, tzinfo=UTC) - end = datetime.combine(target_date, avail.end_time, tzinfo=UTC) - - # Generate 15-minute slots - while current + timedelta(minutes=SLOT_DURATION_MINUTES) <= end: - slot_end = current + timedelta(minutes=SLOT_DURATION_MINUTES) - result.append(BookableSlot(start_time=current, end_time=slot_end)) - current = slot_end - - return result - - -@router.get("/slots", response_model=AvailableSlotsResponse) -async def get_available_slots( - target_date: date = Query(..., alias="date", description="Date to get slots for"), - db: AsyncSession = Depends(get_db), - _current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), -) -> AvailableSlotsResponse: - """Get available booking slots for a specific date.""" - _validate_booking_date(target_date) - - # Get availability for this date - result = await db.execute( - select(Availability) - .where(Availability.date == target_date) - .order_by(Availability.start_time) - ) - availability_slots = result.scalars().all() - - if not availability_slots: - return AvailableSlotsResponse(date=target_date, slots=[]) - - # Expand to 15-minute slots - all_slots = _expand_availability_to_slots(availability_slots, target_date) - - # Get existing booked appointments for this date - day_start = datetime.combine(target_date, time.min, tzinfo=UTC) - day_end = datetime.combine(target_date, time.max, tzinfo=UTC) - - result = await db.execute( - select(Appointment.slot_start).where( - and_( - Appointment.slot_start >= day_start, - Appointment.slot_start <= day_end, - Appointment.status == AppointmentStatus.BOOKED, - ) - ) - ) - booked_starts = {row[0] for row in result.fetchall()} - - # Filter out already booked slots - available_slots = [ - slot for slot in all_slots if slot.start_time not in booked_starts - ] - - return AvailableSlotsResponse(date=target_date, slots=available_slots) - - -@router.post("", response_model=AppointmentResponse) -async def create_booking( - request: BookingRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), -) -> AppointmentResponse: - """Book an appointment slot.""" - slot_date = request.slot_start.date() - _validate_booking_date(slot_date) - - # Validate slot is on the correct minute boundary - valid_minutes = _get_valid_minute_boundaries() - if request.slot_start.minute not in valid_minutes: - raise HTTPException( - status_code=400, - detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary " - f"(valid minutes: {valid_minutes})", - ) - if request.slot_start.second != 0 or request.slot_start.microsecond != 0: - raise HTTPException( - status_code=400, - detail="Slot start time must not have seconds or microseconds", - ) - - # Verify slot falls within availability - slot_start_time = request.slot_start.time() - slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) - slot_end_time = slot_end_dt.time() - - result = await db.execute( - select(Availability).where( - and_( - Availability.date == slot_date, - Availability.start_time <= slot_start_time, - Availability.end_time >= slot_end_time, - ) - ) - ) - matching_availability = result.scalar_one_or_none() - - if not matching_availability: - slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M") - raise HTTPException( - status_code=400, - detail=f"Selected slot at {slot_str} UTC is not within " - f"any available time ranges for {slot_date}", - ) - - # Create the appointment - slot_end = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) - appointment = Appointment( - user_id=current_user.id, - slot_start=request.slot_start, - slot_end=slot_end, - note=request.note, - status=AppointmentStatus.BOOKED, - ) - - db.add(appointment) - - try: - await db.commit() - await db.refresh(appointment) - except IntegrityError: - await db.rollback() - raise HTTPException( - status_code=409, - detail="This slot has already been booked. Select another slot.", - ) from None - - return _to_appointment_response(appointment, current_user.email) - - -# ============================================================================= -# User's Appointments Endpoints -# ============================================================================= - -appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"]) - - -@appointments_router.get("", response_model=list[AppointmentResponse]) -async def get_my_appointments( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)), -) -> list[AppointmentResponse]: - """Get the current user's appointments, sorted by date (upcoming first).""" - result = await db.execute( - select(Appointment) - .where(Appointment.user_id == current_user.id) - .order_by(Appointment.slot_start.desc()) - ) - appointments = result.scalars().all() - - return [_to_appointment_response(apt, current_user.email) for apt in appointments] - - -@appointments_router.post( - "/{appointment_id}/cancel", response_model=AppointmentResponse -) -async def cancel_my_appointment( - appointment_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)), -) -> AppointmentResponse: - """Cancel one of the current user's appointments.""" - # Get the appointment with eager loading of user relationship - result = await db.execute( - select(Appointment) - .options(joinedload(Appointment.user)) - .where(Appointment.id == appointment_id) - ) - appointment = result.scalar_one_or_none() - - if not appointment: - raise HTTPException( - status_code=404, - detail=f"Appointment {appointment_id} not found", - ) - - # Verify ownership - if appointment.user_id != current_user.id: - raise HTTPException( - status_code=403, - detail="Cannot cancel another user's appointment", - ) - - # Check if already cancelled - if appointment.status != AppointmentStatus.BOOKED: - raise HTTPException( - status_code=400, - detail=f"Cannot cancel: status is '{appointment.status.value}'", - ) - - # Check if appointment is in the past - if appointment.slot_start <= datetime.now(UTC): - apt_time = appointment.slot_start.strftime("%Y-%m-%d %H:%M") - raise HTTPException( - status_code=400, - detail=f"Cannot cancel appointment at {apt_time} UTC: " - "already started or in the past", - ) - - # Cancel the appointment - appointment.status = AppointmentStatus.CANCELLED_BY_USER - appointment.cancelled_at = datetime.now(UTC) - - await db.commit() - await db.refresh(appointment) - - return _to_appointment_response(appointment, current_user.email) - - -# ============================================================================= -# Admin Appointments Endpoints -# ============================================================================= - -admin_appointments_router = APIRouter( - prefix="/api/admin/appointments", tags=["admin-appointments"] -) - - -@admin_appointments_router.get("", response_model=PaginatedAppointments) -async def get_all_appointments( - page: int = Query(1, ge=1), - per_page: int = Query(10, ge=1, le=100), - db: AsyncSession = Depends(get_db), - _current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), -) -> PaginatedAppointments: - """Get all appointments (admin only), sorted by date descending with pagination.""" - # Get total count - count_result = await db.execute(select(func.count(Appointment.id))) - total = count_result.scalar() or 0 - - # Get paginated appointments with explicit eager loading of user relationship - offset = calculate_offset(page, per_page) - result = await db.execute( - select(Appointment) - .options(joinedload(Appointment.user)) - .order_by(Appointment.slot_start.desc()) - .offset(offset) - .limit(per_page) - ) - appointments = result.scalars().all() - - # Build responses using the eager-loaded user relationship - records = [ - _to_appointment_response(apt) # Uses eager-loaded relationship - for apt in appointments - ] - - return create_paginated_response(records, total, page, per_page) - - -@admin_appointments_router.post( - "/{appointment_id}/cancel", response_model=AppointmentResponse -) -async def admin_cancel_appointment( - appointment_id: int, - db: AsyncSession = Depends(get_db), - _current_user: User = Depends( - require_permission(Permission.CANCEL_ANY_APPOINTMENT) - ), -) -> AppointmentResponse: - """Cancel any appointment (admin only).""" - # Get the appointment with eager loading of user relationship - result = await db.execute( - select(Appointment) - .options(joinedload(Appointment.user)) - .where(Appointment.id == appointment_id) - ) - appointment = result.scalar_one_or_none() - - if not appointment: - raise HTTPException( - status_code=404, - detail=f"Appointment {appointment_id} not found", - ) - - # Check if already cancelled - if appointment.status != AppointmentStatus.BOOKED: - raise HTTPException( - status_code=400, - detail=f"Cannot cancel: status is '{appointment.status.value}'", - ) - - # Check if appointment is in the past - if appointment.slot_start <= datetime.now(UTC): - apt_time = appointment.slot_start.strftime("%Y-%m-%d %H:%M") - raise HTTPException( - status_code=400, - detail=f"Cannot cancel appointment at {apt_time} UTC: " - "already started or in the past", - ) - - # Cancel the appointment - appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN - appointment.cancelled_at = datetime.now(UTC) - - await db.commit() - await db.refresh(appointment) - - return _to_appointment_response(appointment) # Uses eager-loaded relationship - - -# All routers from this module for easy registration -routers = [router, appointments_router, admin_appointments_router] diff --git a/frontend/app/admin/appointments/page.tsx b/frontend/app/admin/appointments/page.tsx deleted file mode 100644 index 4af5964..0000000 --- a/frontend/app/admin/appointments/page.tsx +++ /dev/null @@ -1,319 +0,0 @@ -"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 { formatDateTime } from "../../utils/date"; -import { getStatusDisplay } from "../../utils/appointment"; -import { sharedStyles } from "../../styles/shared"; - -type AppointmentResponse = components["schemas"]["AppointmentResponse"]; -type PaginatedAppointments = components["schemas"]["PaginatedResponse_AppointmentResponse_"]; - -const pageStyles: Record = { - 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", - }, - filterRow: { - display: "flex", - alignItems: "center", - gap: "0.75rem", - marginBottom: "1.5rem", - }, - filterLabel: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "rgba(255, 255, 255, 0.6)", - fontSize: "0.875rem", - }, - filterSelect: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.5rem 1rem", - background: "rgba(255, 255, 255, 0.05)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: "6px", - color: "#fff", - fontSize: "0.875rem", - }, - appointmentList: { - display: "flex", - flexDirection: "column", - gap: "0.75rem", - }, - appointmentCard: { - background: "rgba(255, 255, 255, 0.03)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "12px", - padding: "1.25rem", - transition: "all 0.2s", - }, - appointmentCardPast: { - opacity: 0.6, - }, - appointmentHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", - gap: "1rem", - }, - appointmentTime: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1rem", - fontWeight: 500, - color: "#fff", - marginBottom: "0.25rem", - }, - appointmentUser: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.5)", - marginBottom: "0.25rem", - }, - appointmentNote: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.4)", - fontStyle: "italic", - marginBottom: "0.5rem", - }, - statusBadge: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.75rem", - fontWeight: 500, - padding: "0.25rem 0.75rem", - borderRadius: "9999px", - display: "inline-block", - }, - buttonGroup: { - display: "flex", - gap: "0.5rem", - }, - cancelButton: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.35rem 0.75rem", - fontSize: "0.75rem", - background: "rgba(255, 255, 255, 0.05)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: "6px", - color: "rgba(255, 255, 255, 0.7)", - cursor: "pointer", - transition: "all 0.2s", - }, - confirmButton: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.35rem 0.75rem", - fontSize: "0.75rem", - background: "rgba(239, 68, 68, 0.2)", - border: "1px solid rgba(239, 68, 68, 0.3)", - borderRadius: "6px", - color: "#f87171", - cursor: "pointer", - transition: "all 0.2s", - }, - emptyState: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "rgba(255, 255, 255, 0.4)", - textAlign: "center", - padding: "3rem", - }, -}; - -const styles = { ...sharedStyles, ...pageStyles }; - -export default function AdminAppointmentsPage() { - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_ALL_APPOINTMENTS, - fallbackRedirect: "/", - }); - - const [appointments, setAppointments] = useState([]); - const [isLoadingAppointments, setIsLoadingAppointments] = useState(true); - const [cancellingId, setCancellingId] = useState(null); - const [confirmCancelId, setConfirmCancelId] = useState(null); - const [error, setError] = useState(null); - const [statusFilter, setStatusFilter] = useState("all"); - - const fetchAppointments = useCallback(async () => { - try { - // Fetch with large per_page to get all appointments for now - const data = await api.get("/api/admin/appointments?per_page=100"); - setAppointments(data.records); - } catch (err) { - console.error("Failed to fetch appointments:", err); - setError("Failed to load appointments"); - } finally { - setIsLoadingAppointments(false); - } - }, []); - - useEffect(() => { - if (user && isAuthorized) { - fetchAppointments(); - } - }, [user, isAuthorized, fetchAppointments]); - - const handleCancel = async (appointmentId: number) => { - setCancellingId(appointmentId); - setError(null); - - try { - await api.post(`/api/admin/appointments/${appointmentId}/cancel`, {}); - await fetchAppointments(); - setConfirmCancelId(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to cancel appointment"); - } finally { - setCancellingId(null); - } - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!isAuthorized) { - return null; - } - - const filteredAppointments = appointments.filter((apt) => { - if (statusFilter === "all") return true; - return apt.status === statusFilter; - }); - - const bookedCount = appointments.filter((a) => a.status === "booked").length; - - return ( -
-
-
-

All Appointments

-

View and manage all user appointments

- - {error &&
{error}
} - - {/* Status Filter */} -
- Filter: - -
- - {isLoadingAppointments ? ( -
Loading appointments...
- ) : appointments.length === 0 ? ( -
No appointments yet.
- ) : filteredAppointments.length === 0 ? ( -
No appointments match the filter.
- ) : ( -
- {filteredAppointments.map((apt) => { - const status = getStatusDisplay(apt.status); - const isPast = new Date(apt.slot_start) <= new Date(); - return ( -
-
-
-
{formatDateTime(apt.slot_start)}
-
{apt.user_email}
- {apt.note &&
"{apt.note}"
} - - {status.text} - -
- - {apt.status === "booked" && ( -
- {confirmCancelId === apt.id ? ( - <> - - - - ) : ( - - )} -
- )} -
-
- ); - })} -
- )} -
-
- ); -} diff --git a/frontend/app/appointments/page.tsx b/frontend/app/appointments/page.tsx deleted file mode 100644 index 38a6136..0000000 --- a/frontend/app/appointments/page.tsx +++ /dev/null @@ -1,285 +0,0 @@ -"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 { formatDateTime } from "../utils/date"; -import { getStatusDisplay } from "../utils/appointment"; -import { - layoutStyles, - typographyStyles, - bannerStyles, - badgeStyles, - buttonStyles, -} from "../styles/shared"; - -type AppointmentResponse = components["schemas"]["AppointmentResponse"]; - -export default function AppointmentsPage() { - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_OWN_APPOINTMENTS, - fallbackRedirect: "/", - }); - - const [appointments, setAppointments] = useState([]); - const [isLoadingAppointments, setIsLoadingAppointments] = useState(true); - const [cancellingId, setCancellingId] = useState(null); - const [confirmCancelId, setConfirmCancelId] = useState(null); - const [error, setError] = useState(null); - - const fetchAppointments = useCallback(async () => { - try { - const data = await api.get("/api/appointments"); - setAppointments(data); - } catch (err) { - console.error("Failed to fetch appointments:", err); - setError("Failed to load appointments"); - } finally { - setIsLoadingAppointments(false); - } - }, []); - - useEffect(() => { - if (user && isAuthorized) { - fetchAppointments(); - } - }, [user, isAuthorized, fetchAppointments]); - - const handleCancel = async (appointmentId: number) => { - setCancellingId(appointmentId); - setError(null); - - try { - await api.post(`/api/appointments/${appointmentId}/cancel`, {}); - await fetchAppointments(); - setConfirmCancelId(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to cancel appointment"); - } finally { - setCancellingId(null); - } - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!isAuthorized) { - return null; - } - - const upcomingAppointments = appointments.filter( - (apt) => apt.status === "booked" && new Date(apt.slot_start) > new Date() - ); - const pastOrCancelledAppointments = appointments.filter( - (apt) => apt.status !== "booked" || new Date(apt.slot_start) <= new Date() - ); - - return ( -
-
-
-

My Appointments

-

View and manage your booked appointments

- - {error &&
{error}
} - - {isLoadingAppointments ? ( -
Loading appointments...
- ) : appointments.length === 0 ? ( -
-

You don't have any appointments yet.

- - Book an appointment - -
- ) : ( - <> - {/* Upcoming Appointments */} - {upcomingAppointments.length > 0 && ( -
-

Upcoming ({upcomingAppointments.length})

-
- {upcomingAppointments.map((apt) => { - const status = getStatusDisplay(apt.status, true); - return ( -
-
-
-
- {formatDateTime(apt.slot_start)} -
- {apt.note &&
{apt.note}
} - - {status.text} - -
- - {apt.status === "booked" && ( -
- {confirmCancelId === apt.id ? ( - <> - - - - ) : ( - - )} -
- )} -
-
- ); - })} -
-
- )} - - {/* Past/Cancelled Appointments */} - {pastOrCancelledAppointments.length > 0 && ( -
-

- Past & Cancelled ({pastOrCancelledAppointments.length}) -

-
- {pastOrCancelledAppointments.map((apt) => { - const status = getStatusDisplay(apt.status, true); - return ( -
-
{formatDateTime(apt.slot_start)}
- {apt.note &&
{apt.note}
} - - {status.text} - -
- ); - })} -
-
- )} - - )} -
-
- ); -} - -// Page-specific styles -const styles: Record = { - content: { - flex: 1, - padding: "2rem", - maxWidth: "800px", - margin: "0 auto", - width: "100%", - }, - section: { - marginBottom: "2rem", - }, - sectionTitle: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1.1rem", - fontWeight: 500, - color: "#fff", - marginBottom: "1rem", - }, - appointmentList: { - display: "flex", - flexDirection: "column", - gap: "0.75rem", - }, - appointmentCard: { - background: "rgba(255, 255, 255, 0.03)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "12px", - padding: "1.25rem", - transition: "all 0.2s", - }, - appointmentCardPast: { - opacity: 0.6, - background: "rgba(255, 255, 255, 0.01)", - }, - appointmentHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", - gap: "1rem", - }, - appointmentTime: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1rem", - fontWeight: 500, - color: "#fff", - marginBottom: "0.25rem", - }, - appointmentNote: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.5)", - marginBottom: "0.5rem", - }, - buttonGroup: { - display: "flex", - gap: "0.5rem", - }, - confirmButton: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.35rem 0.75rem", - fontSize: "0.75rem", - background: "rgba(239, 68, 68, 0.2)", - border: "1px solid rgba(239, 68, 68, 0.3)", - borderRadius: "6px", - color: "#f87171", - cursor: "pointer", - transition: "all 0.2s", - }, - emptyState: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "rgba(255, 255, 255, 0.4)", - textAlign: "center", - padding: "3rem", - }, - emptyStateLink: { - color: "#a78bfa", - textDecoration: "none", - }, -}; diff --git a/frontend/app/booking/page.tsx b/frontend/app/booking/page.tsx deleted file mode 100644 index 49fa872..0000000 --- a/frontend/app/booking/page.tsx +++ /dev/null @@ -1,428 +0,0 @@ -"use client"; - -import React from "react"; -import { useEffect, useState, useCallback, useMemo } 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"; -import { formatDate, formatTime, getDateRange } from "../utils/date"; -import { - layoutStyles, - typographyStyles, - bannerStyles, - formStyles, - buttonStyles, -} from "../styles/shared"; - -const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking; - -type BookableSlot = components["schemas"]["BookableSlot"]; -type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; -type AppointmentResponse = components["schemas"]["AppointmentResponse"]; - -export default function BookingPage() { - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.BOOK_APPOINTMENT, - fallbackRedirect: "/", - }); - - const [selectedDate, setSelectedDate] = useState(null); - const [availableSlots, setAvailableSlots] = useState([]); - const [selectedSlot, setSelectedSlot] = useState(null); - const [note, setNote] = useState(""); - const [isLoadingSlots, setIsLoadingSlots] = useState(false); - const [isBooking, setIsBooking] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [datesWithAvailability, setDatesWithAvailability] = useState>(new Set()); - const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); - - // Memoize dates to prevent infinite re-renders - const dates = useMemo( - () => getDateRange(minAdvanceDays, maxAdvanceDays), - [minAdvanceDays, maxAdvanceDays] - ); - - const fetchSlots = useCallback(async (date: Date) => { - setIsLoadingSlots(true); - setError(null); - setAvailableSlots([]); - setSelectedSlot(null); - - try { - const dateStr = formatDate(date); - const data = await api.get(`/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); - } - }, []); - - // Fetch availability for all dates on mount - useEffect(() => { - if (!user || !isAuthorized) return; - - const fetchAllAvailability = async () => { - setIsLoadingAvailability(true); - const availabilitySet = new Set(); - - // Fetch availability for all dates in parallel - const promises = dates.map(async (date) => { - try { - const dateStr = formatDate(date); - const data = await api.get(`/api/booking/slots?date=${dateStr}`); - if (data.slots.length > 0) { - availabilitySet.add(dateStr); - } - } catch (err) { - // Silently fail for individual dates - they'll just be marked as unavailable - console.error(`Failed to fetch availability for ${formatDate(date)}:`, err); - } - }); - - await Promise.all(promises); - setDatesWithAvailability(availabilitySet); - setIsLoadingAvailability(false); - }; - - fetchAllAvailability(); - }, [user, isAuthorized]); // Removed dates from dependencies - dates is memoized and stable - - useEffect(() => { - if (selectedDate && user && isAuthorized) { - fetchSlots(selectedDate); - } - }, [selectedDate, user, isAuthorized, fetchSlots]); - - const handleDateSelect = (date: Date) => { - const dateStr = formatDate(date); - // Only allow selection if date has availability - if (datesWithAvailability.has(dateStr)) { - 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("/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) { - setError(err instanceof Error ? err.message : "Failed to book appointment"); - } finally { - setIsBooking(false); - } - }; - - const cancelSlotSelection = () => { - setSelectedSlot(null); - setNote(""); - setError(null); - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!isAuthorized) { - return null; - } - - return ( -
-
-
-

Book an Appointment

-

- Select a date to see available {slotDurationMinutes}-minute slots -

- - {successMessage &&
{successMessage}
} - - {error &&
{error}
} - - {/* Date Selection */} -
-

Select a Date

-
- {dates.map((date) => { - const dateStr = formatDate(date); - const isSelected = selectedDate && formatDate(selectedDate) === dateStr; - const hasAvailability = datesWithAvailability.has(dateStr); - const isDisabled = !hasAvailability || isLoadingAvailability; - - return ( - - ); - })} -
-
- - {/* Available Slots */} - {selectedDate && ( -
-

- Available Slots for{" "} - {selectedDate.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - })} -

- - {isLoadingSlots ? ( -
Loading slots...
- ) : availableSlots.length === 0 ? ( -
No available slots for this date
- ) : ( -
- {availableSlots.map((slot) => { - const isSelected = selectedSlot?.start_time === slot.start_time; - return ( - - ); - })} -
- )} -
- )} - - {/* Booking Form */} - {selectedSlot && ( -
-

Confirm Booking

-

- Time: {formatTime(selectedSlot.start_time)} -{" "} - {formatTime(selectedSlot.end_time)} -

- -
- -