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
This commit is contained in:
parent
9e8d0af435
commit
bbd9fae763
16 changed files with 29 additions and 2103 deletions
|
|
@ -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]
|
|
||||||
|
|
@ -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<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",
|
|
||||||
},
|
|
||||||
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<AppointmentResponse[]>([]);
|
|
||||||
const [isLoadingAppointments, setIsLoadingAppointments] = useState(true);
|
|
||||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
|
||||||
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
const fetchAppointments = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// Fetch with large per_page to get all appointments for now
|
|
||||||
const data = await api.get<PaginatedAppointments>("/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<AppointmentResponse>(`/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 (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<div style={styles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<Header currentPage="admin-appointments" />
|
|
||||||
<div style={styles.content}>
|
|
||||||
<h1 style={styles.pageTitle}>All Appointments</h1>
|
|
||||||
<p style={styles.pageSubtitle}>View and manage all user appointments</p>
|
|
||||||
|
|
||||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div style={styles.filterRow}>
|
|
||||||
<span style={styles.filterLabel}>Filter:</span>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
style={styles.filterSelect}
|
|
||||||
>
|
|
||||||
<option value="all">All ({appointments.length})</option>
|
|
||||||
<option value="booked">Booked ({bookedCount})</option>
|
|
||||||
<option value="cancelled_by_user">Cancelled by User</option>
|
|
||||||
<option value="cancelled_by_admin">Cancelled by Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingAppointments ? (
|
|
||||||
<div style={styles.emptyState}>Loading appointments...</div>
|
|
||||||
) : appointments.length === 0 ? (
|
|
||||||
<div style={styles.emptyState}>No appointments yet.</div>
|
|
||||||
) : filteredAppointments.length === 0 ? (
|
|
||||||
<div style={styles.emptyState}>No appointments match the filter.</div>
|
|
||||||
) : (
|
|
||||||
<div style={styles.appointmentList}>
|
|
||||||
{filteredAppointments.map((apt) => {
|
|
||||||
const status = getStatusDisplay(apt.status);
|
|
||||||
const isPast = new Date(apt.slot_start) <= new Date();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={apt.id}
|
|
||||||
style={{
|
|
||||||
...styles.appointmentCard,
|
|
||||||
...(isPast ? styles.appointmentCardPast : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={styles.appointmentHeader}>
|
|
||||||
<div>
|
|
||||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
|
||||||
<div style={styles.appointmentUser}>{apt.user_email}</div>
|
|
||||||
{apt.note && <div style={styles.appointmentNote}>"{apt.note}"</div>}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
...styles.statusBadge,
|
|
||||||
background: status.bgColor,
|
|
||||||
color: status.textColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apt.status === "booked" && (
|
|
||||||
<div style={styles.buttonGroup}>
|
|
||||||
{confirmCancelId === apt.id ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancel(apt.id)}
|
|
||||||
disabled={cancellingId === apt.id}
|
|
||||||
style={styles.confirmButton}
|
|
||||||
>
|
|
||||||
{cancellingId === apt.id ? "..." : "Confirm"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmCancelId(null)}
|
|
||||||
style={styles.cancelButton}
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmCancelId(apt.id)}
|
|
||||||
style={styles.cancelButton}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<AppointmentResponse[]>([]);
|
|
||||||
const [isLoadingAppointments, setIsLoadingAppointments] = useState(true);
|
|
||||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
|
||||||
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchAppointments = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.get<AppointmentResponse[]>("/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<AppointmentResponse>(`/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 (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<Header currentPage="appointments" />
|
|
||||||
<div style={styles.content}>
|
|
||||||
<h1 style={typographyStyles.pageTitle}>My Appointments</h1>
|
|
||||||
<p style={typographyStyles.pageSubtitle}>View and manage your booked appointments</p>
|
|
||||||
|
|
||||||
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
|
|
||||||
|
|
||||||
{isLoadingAppointments ? (
|
|
||||||
<div style={styles.emptyState}>Loading appointments...</div>
|
|
||||||
) : appointments.length === 0 ? (
|
|
||||||
<div style={styles.emptyState}>
|
|
||||||
<p>You don't have any appointments yet.</p>
|
|
||||||
<a href="/booking" style={styles.emptyStateLink}>
|
|
||||||
Book an appointment
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Upcoming Appointments */}
|
|
||||||
{upcomingAppointments.length > 0 && (
|
|
||||||
<div style={styles.section}>
|
|
||||||
<h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
|
|
||||||
<div style={styles.appointmentList}>
|
|
||||||
{upcomingAppointments.map((apt) => {
|
|
||||||
const status = getStatusDisplay(apt.status, true);
|
|
||||||
return (
|
|
||||||
<div key={apt.id} style={styles.appointmentCard}>
|
|
||||||
<div style={styles.appointmentHeader}>
|
|
||||||
<div>
|
|
||||||
<div style={styles.appointmentTime}>
|
|
||||||
{formatDateTime(apt.slot_start)}
|
|
||||||
</div>
|
|
||||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
...badgeStyles.badge,
|
|
||||||
background: status.bgColor,
|
|
||||||
color: status.textColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apt.status === "booked" && (
|
|
||||||
<div style={styles.buttonGroup}>
|
|
||||||
{confirmCancelId === apt.id ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancel(apt.id)}
|
|
||||||
disabled={cancellingId === apt.id}
|
|
||||||
style={styles.confirmButton}
|
|
||||||
>
|
|
||||||
{cancellingId === apt.id ? "..." : "Confirm"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmCancelId(null)}
|
|
||||||
style={buttonStyles.secondaryButton}
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmCancelId(apt.id)}
|
|
||||||
style={buttonStyles.secondaryButton}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Past/Cancelled Appointments */}
|
|
||||||
{pastOrCancelledAppointments.length > 0 && (
|
|
||||||
<div style={styles.section}>
|
|
||||||
<h2 style={typographyStyles.sectionTitleMuted}>
|
|
||||||
Past & Cancelled ({pastOrCancelledAppointments.length})
|
|
||||||
</h2>
|
|
||||||
<div style={styles.appointmentList}>
|
|
||||||
{pastOrCancelledAppointments.map((apt) => {
|
|
||||||
const status = getStatusDisplay(apt.status, true);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={apt.id}
|
|
||||||
style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
|
|
||||||
>
|
|
||||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
|
||||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
...badgeStyles.badge,
|
|
||||||
background: status.bgColor,
|
|
||||||
color: status.textColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page-specific styles
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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<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 [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(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<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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch availability for all dates on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user || !isAuthorized) return;
|
|
||||||
|
|
||||||
const fetchAllAvailability = async () => {
|
|
||||||
setIsLoadingAvailability(true);
|
|
||||||
const availabilitySet = new Set<string>();
|
|
||||||
|
|
||||||
// Fetch availability for all dates in parallel
|
|
||||||
const promises = dates.map(async (date) => {
|
|
||||||
try {
|
|
||||||
const dateStr = formatDate(date);
|
|
||||||
const data = await api.get<AvailableSlotsResponse>(`/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<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) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to book appointment");
|
|
||||||
} finally {
|
|
||||||
setIsBooking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelSlotSelection = () => {
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setNote("");
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<Header currentPage="booking" />
|
|
||||||
<div style={styles.content}>
|
|
||||||
<h1 style={typographyStyles.pageTitle}>Book an Appointment</h1>
|
|
||||||
<p style={typographyStyles.pageSubtitle}>
|
|
||||||
Select a date to see available {slotDurationMinutes}-minute slots
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
|
|
||||||
|
|
||||||
{error && <div style={bannerStyles.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 dateStr = formatDate(date);
|
|
||||||
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
|
||||||
const hasAvailability = datesWithAvailability.has(dateStr);
|
|
||||||
const isDisabled = !hasAvailability || isLoadingAvailability;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={dateStr}
|
|
||||||
onClick={() => handleDateSelect(date)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
style={{
|
|
||||||
...styles.dateButton,
|
|
||||||
...(isSelected ? styles.dateButtonSelected : {}),
|
|
||||||
...(isDisabled ? styles.dateButtonDisabled : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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={formStyles.label}>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={formStyles.textarea}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...formStyles.charCount,
|
|
||||||
...(note.length >= noteMaxLength ? formStyles.charCountWarning : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{note.length}/{noteMaxLength}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.buttonRow}>
|
|
||||||
<button
|
|
||||||
onClick={handleBook}
|
|
||||||
disabled={isBooking}
|
|
||||||
style={{
|
|
||||||
...styles.bookButton,
|
|
||||||
...(isBooking ? buttonStyles.buttonDisabled : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isBooking ? "Booking..." : "Book Appointment"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelSlotSelection}
|
|
||||||
disabled={isBooking}
|
|
||||||
style={styles.cancelButton}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page-specific styles
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: "2rem",
|
|
||||||
maxWidth: "900px",
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
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)",
|
|
||||||
border: "1px solid #a78bfa",
|
|
||||||
},
|
|
||||||
dateButtonDisabled: {
|
|
||||||
opacity: 0.4,
|
|
||||||
cursor: "not-allowed",
|
|
||||||
background: "rgba(255, 255, 255, 0.01)",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.04)",
|
|
||||||
},
|
|
||||||
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)",
|
|
||||||
border: "1px solid #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",
|
|
||||||
},
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -152,7 +152,7 @@ export default function ExchangePage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||||
setAvailableSlots(data.slots);
|
setAvailableSlots(data.slots);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch slots:", err);
|
console.error("Failed to fetch slots:", err);
|
||||||
|
|
@ -173,7 +173,7 @@ export default function ExchangePage() {
|
||||||
const promises = dates.map(async (date) => {
|
const promises = dates.map(async (date) => {
|
||||||
try {
|
try {
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||||
if (data.slots.length > 0) {
|
if (data.slots.length > 0) {
|
||||||
availabilitySet.add(dateStr);
|
availabilitySet.add(dateStr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
||||||
// Redirect to the most appropriate page based on permissions
|
// Redirect to the most appropriate page based on permissions
|
||||||
const redirect =
|
const redirect =
|
||||||
fallbackRedirect ??
|
fallbackRedirect ??
|
||||||
(hasPermission(Permission.VIEW_AUDIT)
|
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
|
||||||
? "/audit"
|
? "/admin/trades"
|
||||||
: hasPermission(Permission.VIEW_COUNTER)
|
: hasPermission(Permission.CREATE_EXCHANGE)
|
||||||
? "/"
|
? "/exchange"
|
||||||
: "/login");
|
: "/login");
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ type Invite = components["schemas"]["UserInviteResponse"];
|
||||||
export default function InvitesPage() {
|
export default function InvitesPage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredPermission: Permission.VIEW_OWN_INVITES,
|
requiredPermission: Permission.VIEW_OWN_INVITES,
|
||||||
fallbackRedirect: "/audit",
|
fallbackRedirect: "/admin/trades",
|
||||||
});
|
});
|
||||||
const [invites, setInvites] = useState<Invite[]>([]);
|
const [invites, setInvites] = useState<Invite[]>([]);
|
||||||
const [isLoadingInvites, setIsLoadingInvites] = useState(true);
|
const [isLoadingInvites, setIsLoadingInvites] = useState(true);
|
||||||
|
|
|
||||||
|
|
@ -242,18 +242,18 @@ describe("ProfilePage - Access Control", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirects admin to audit page", async () => {
|
test("redirects admin to admin trades page", async () => {
|
||||||
mockUser = {
|
mockUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
email: "admin@example.com",
|
email: "admin@example.com",
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
permissions: ["view_audit"],
|
permissions: ["view_all_exchanges"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<ProfilePage />);
|
render(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPush).toHaveBeenCalledWith("/audit");
|
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function toFormData(data: ProfileData): FormData {
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredPermission: Permission.MANAGE_OWN_PROFILE,
|
requiredPermission: Permission.MANAGE_OWN_PROFILE,
|
||||||
fallbackRedirect: "/audit",
|
fallbackRedirect: "/admin/trades",
|
||||||
});
|
});
|
||||||
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/**
|
|
||||||
* Appointment-related utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StatusDisplay {
|
|
||||||
text: string;
|
|
||||||
bgColor: string;
|
|
||||||
textColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display information for an appointment status.
|
|
||||||
*
|
|
||||||
* @param status - The appointment status string
|
|
||||||
* @param isOwnView - If true, uses "Cancelled by you" instead of "Cancelled by user"
|
|
||||||
*/
|
|
||||||
export function getStatusDisplay(status: string, isOwnView: boolean = false): StatusDisplay {
|
|
||||||
switch (status) {
|
|
||||||
case "booked":
|
|
||||||
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
|
|
||||||
case "cancelled_by_user":
|
|
||||||
return {
|
|
||||||
text: isOwnView ? "Cancelled by you" : "Cancelled by user",
|
|
||||||
bgColor: "rgba(239, 68, 68, 0.2)",
|
|
||||||
textColor: "#f87171",
|
|
||||||
};
|
|
||||||
case "cancelled_by_admin":
|
|
||||||
return {
|
|
||||||
text: "Cancelled by admin",
|
|
||||||
bgColor: "rgba(239, 68, 68, 0.2)",
|
|
||||||
textColor: "#f87171",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@ async function loginAsAdmin(page: Page) {
|
||||||
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
||||||
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL("/audit");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Admin Invites Page", () => {
|
test.describe("Admin Invites Page", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { test, expect, Page } from "@playwright/test";
|
|
||||||
import { getTomorrowDateStr } from "./helpers/date";
|
|
||||||
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appointments Page E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for viewing and cancelling user appointments.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Set up availability and create a booking
|
|
||||||
async function createTestBooking(page: Page) {
|
|
||||||
const dateStr = getTomorrowDateStr();
|
|
||||||
|
|
||||||
// First login as admin to set availability
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
const adminCookies = await page.context().cookies();
|
|
||||||
const adminAuthCookie = adminCookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (!adminAuthCookie) throw new Error("No admin auth cookie");
|
|
||||||
|
|
||||||
await page.request.put(`${API_URL}/api/admin/availability`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${adminAuthCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
date: dateStr,
|
|
||||||
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login as regular user
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
const userCookies = await page.context().cookies();
|
|
||||||
const userAuthCookie = userCookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (!userAuthCookie) throw new Error("No user auth cookie");
|
|
||||||
|
|
||||||
// Create booking - use a random minute to avoid conflicts with parallel tests
|
|
||||||
const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min
|
|
||||||
const hour = 9 + Math.floor(randomMinute / 60);
|
|
||||||
const minute = randomMinute % 60;
|
|
||||||
const timeStr = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
|
|
||||||
|
|
||||||
const response = await page.request.post(`${API_URL}/api/booking`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${userAuthCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
slot_start: `${dateStr}T${timeStr}Z`,
|
|
||||||
note: "Test appointment",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Appointments Page - Regular User Access", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user can access appointments page", async ({ page }) => {
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
await expect(page).toHaveURL("/appointments");
|
|
||||||
await expect(page.getByRole("heading", { name: "My Appointments" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user sees Appointments link in navigation", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Appointments" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows empty state when no appointments", async ({ page }) => {
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
await expect(page.getByText("don't have any appointments")).toBeVisible();
|
|
||||||
await expect(page.getByRole("link", { name: "Book an appointment" })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Appointments Page - With Bookings", () => {
|
|
||||||
test("shows user's appointments", async ({ page }) => {
|
|
||||||
// Create a booking first
|
|
||||||
await createTestBooking(page);
|
|
||||||
|
|
||||||
// Go to appointments page
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
// Should see the appointment
|
|
||||||
await expect(page.getByText("Test appointment")).toBeVisible();
|
|
||||||
await expect(page.getByText("Booked", { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can cancel an appointment", async ({ page }) => {
|
|
||||||
// Create a booking
|
|
||||||
await createTestBooking(page);
|
|
||||||
|
|
||||||
// Go to appointments page
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
// Click cancel button
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).first().click();
|
|
||||||
|
|
||||||
// Confirm cancellation
|
|
||||||
await page.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
|
|
||||||
// Should show cancelled status
|
|
||||||
await expect(page.getByText("Cancelled by you")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can abort cancellation", async ({ page }) => {
|
|
||||||
// Create a booking
|
|
||||||
await createTestBooking(page);
|
|
||||||
|
|
||||||
// Go to appointments page
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
// Wait for appointments to load
|
|
||||||
await expect(page.getByRole("heading", { name: /Upcoming/ })).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Click cancel button
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).first().click();
|
|
||||||
|
|
||||||
// Click No to abort
|
|
||||||
await page.getByRole("button", { name: "No" }).click();
|
|
||||||
|
|
||||||
// Should still show as booked (use first() since there may be multiple bookings)
|
|
||||||
await expect(page.getByText("Booked", { exact: true }).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Appointments Page - Access Control", () => {
|
|
||||||
test("admin cannot access appointments page", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
// Should be redirected
|
|
||||||
await expect(page).not.toHaveURL("/appointments");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin does not see Appointments link", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/audit");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Appointments" })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unauthenticated user redirected to login", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
|
|
||||||
await page.goto("/appointments");
|
|
||||||
|
|
||||||
await expect(page).toHaveURL("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Appointments API", () => {
|
|
||||||
test("regular user can view appointments via API", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const response = await page.request.get(`${API_URL}/api/appointments`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
expect(Array.isArray(await response.json())).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user can cancel appointment via API", async ({ page }) => {
|
|
||||||
// Create a booking
|
|
||||||
const booking = await createTestBooking(page);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie && booking && booking.id) {
|
|
||||||
const response = await page.request.post(`${API_URL}/api/appointments/${booking.id}/cancel`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.status).toBe("cancelled_by_user");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin cannot view user appointments via API", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const response = await page.request.get(`${API_URL}/api/appointments`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -30,7 +30,7 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin sees Availability link in nav", async ({ page }) => {
|
test("admin sees Availability link in nav", async ({ page }) => {
|
||||||
await page.goto("/audit");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
||||||
await expect(availabilityLink).toBeVisible();
|
await expect(availabilityLink).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
import { test, expect, Page } from "@playwright/test";
|
|
||||||
import { getTomorrowDateStr } from "./helpers/date";
|
|
||||||
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Booking Page E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for the user booking page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Set up availability for a date using the API with retry logic
|
|
||||||
async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (!authCookie) {
|
|
||||||
throw new Error("No auth cookie found when trying to set availability");
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
if (attempt > 0) {
|
|
||||||
// Wait before retry
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await page.request.put(`${API_URL}/api/admin/availability`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
date: dateStr,
|
|
||||||
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok()) {
|
|
||||||
return; // Success
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await response.text();
|
|
||||||
lastError = new Error(`Failed to set availability: ${response.status()} - ${body}`);
|
|
||||||
|
|
||||||
// Only retry on 500 errors
|
|
||||||
if (response.status() !== 500) {
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Booking Page - Regular User Access", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user can access booking page", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
await expect(page).toHaveURL("/booking");
|
|
||||||
await expect(page.getByRole("heading", { name: "Book an Appointment" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user sees Book link in navigation", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Book" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("booking page shows date selection", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
|
|
||||||
// Should see multiple date buttons
|
|
||||||
const dateButtons = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
|
||||||
await expect(dateButtons.first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selecting date shows slots section", async ({ page }) => {
|
|
||||||
// First set up availability for tomorrow so we have an enabled date
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
await setAvailability(page, getTomorrowDateStr());
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Wait for availability check to complete
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Find an enabled date button (one with availability)
|
|
||||||
const dateButtons = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
|
||||||
let enabledButton = null;
|
|
||||||
const buttonCount = await dateButtons.count();
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = dateButtons.nth(i);
|
|
||||||
const isDisabled = await button.isDisabled().catch(() => true);
|
|
||||||
if (!isDisabled) {
|
|
||||||
enabledButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have at least one enabled date (tomorrow)
|
|
||||||
expect(enabledButton).not.toBeNull();
|
|
||||||
await enabledButton!.click();
|
|
||||||
|
|
||||||
// Should show Available Slots section (use heading to be specific)
|
|
||||||
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows no slots or message when no availability", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Wait for date buttons to load and availability check to complete
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Find an enabled date button (one that has availability or is still loading)
|
|
||||||
// If all dates are disabled, we can't test clicking, so verify disabled state
|
|
||||||
const dateButtons = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
|
||||||
const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ });
|
|
||||||
const enabledCount = await enabledButtons.count();
|
|
||||||
|
|
||||||
if (enabledCount > 0) {
|
|
||||||
// Click the first enabled date button
|
|
||||||
await enabledButtons.first().click();
|
|
||||||
|
|
||||||
// Wait for the section to appear
|
|
||||||
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
|
||||||
|
|
||||||
// Should either show no slots message OR show no slot buttons
|
|
||||||
// Wait a moment for API to return
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// If no availability is set, we'll see the "No available slots" message
|
|
||||||
const noSlotsMessage = page.getByText("No available slots for this date");
|
|
||||||
const isNoSlotsVisible = await noSlotsMessage.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
if (!isNoSlotsVisible) {
|
|
||||||
// There might be some slots from shared state - just verify the section loads
|
|
||||||
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// All dates are disabled - verify that disabled dates are shown
|
|
||||||
const disabledButtons = dateButtons.filter({ hasText: /disabled/ });
|
|
||||||
await expect(disabledButtons.first()).toBeDisabled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking Page - With Availability", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
// Login as admin to set availability
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
await setAvailability(page, getTomorrowDateStr());
|
|
||||||
await clearAuth(page);
|
|
||||||
// Login as regular user
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows available slots when availability is set", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Get tomorrow's display name to click the correct button
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
|
||||||
|
|
||||||
// Click tomorrow's date using the weekday name
|
|
||||||
const dateButton = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
|
||||||
.first();
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for "Available Slots" section to appear
|
|
||||||
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for loading to finish (no "Loading slots..." text)
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Should see some slot buttons (look for any button with time-like pattern)
|
|
||||||
// The format might be "09:00" or "9:00 AM" depending on locale
|
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking slot shows confirmation form", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Get tomorrow's display name
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
|
||||||
|
|
||||||
// Click tomorrow's date
|
|
||||||
const dateButton = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
|
||||||
.first();
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for any slot to appear
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Click first slot
|
|
||||||
await slotButtons.first().click();
|
|
||||||
|
|
||||||
// Should show confirmation form
|
|
||||||
await expect(page.getByText("Confirm Booking")).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Book Appointment" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can book an appointment with note", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Get tomorrow's display name
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
|
||||||
|
|
||||||
// Click tomorrow's date
|
|
||||||
const dateButton = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
|
||||||
.first();
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for slots to load
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Click second slot (to avoid booking same slot as other tests)
|
|
||||||
await slotButtons.nth(1).click();
|
|
||||||
|
|
||||||
// Add a note
|
|
||||||
await page.fill("textarea", "Test booking note");
|
|
||||||
|
|
||||||
// Book
|
|
||||||
await page.getByRole("button", { name: "Book Appointment" }).click();
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
await expect(page.getByText(/Appointment booked/)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("booked slot disappears from available slots", async ({ page }) => {
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Get tomorrow's display name
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
|
||||||
|
|
||||||
// Click tomorrow's date
|
|
||||||
const dateButton = page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: new RegExp(`^${weekday}`) })
|
|
||||||
.first();
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for slots to load
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Count initial slots
|
|
||||||
const initialCount = await slotButtons.count();
|
|
||||||
|
|
||||||
// Click any slot (3rd to avoid conflicts)
|
|
||||||
const slotToBook = slotButtons.nth(2);
|
|
||||||
const _slotText = await slotToBook.textContent();
|
|
||||||
await slotToBook.click();
|
|
||||||
|
|
||||||
// Book it
|
|
||||||
await page.getByRole("button", { name: "Book Appointment" }).click();
|
|
||||||
|
|
||||||
// Wait for booking form to disappear (indicates booking completed)
|
|
||||||
await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for success message
|
|
||||||
await expect(page.getByText(/Appointment booked/)).toBeVisible();
|
|
||||||
|
|
||||||
// Should have one less slot now
|
|
||||||
const newCount = await slotButtons.count();
|
|
||||||
expect(newCount).toBe(initialCount - 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking Page - Access Control", () => {
|
|
||||||
test("admin cannot access booking page", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
// Should be redirected away (to audit or home)
|
|
||||||
await expect(page).not.toHaveURL("/booking");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin does not see Book link", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/audit");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Book" })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unauthenticated user redirected to login", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
|
|
||||||
await page.goto("/booking");
|
|
||||||
|
|
||||||
await expect(page).toHaveURL("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking API", () => {
|
|
||||||
test("regular user can book via API", async ({ page, request }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
// Set up availability as admin
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
const dateStr = getTomorrowDateStr();
|
|
||||||
await setAvailability(page, dateStr);
|
|
||||||
await clearAuth(page);
|
|
||||||
|
|
||||||
// Login as regular user
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
// Use 11:45 to avoid conflicts with other tests using 10:00
|
|
||||||
const response = await request.post(`${API_URL}/api/booking`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
slot_start: `${dateStr}T11:45:00Z`,
|
|
||||||
note: "API test booking",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.note).toBe("API test booking");
|
|
||||||
expect(data.status).toBe("booked");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin cannot book via API", async ({ page, request }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
const dateStr = getTomorrowDateStr();
|
|
||||||
await setAvailability(page, dateStr);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const response = await request.post(`${API_URL}/api/booking`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
slot_start: `${dateStr}T10:15:00Z`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -61,8 +61,8 @@ test.describe("Price History - E2E", () => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
|
||||||
// Admin should be on audit page by default
|
// Admin should be on admin trades page by default
|
||||||
await expect(page).toHaveURL("/audit");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
|
|
||||||
// Prices nav link should be visible
|
// Prices nav link should be visible
|
||||||
await expect(page.getByRole("link", { name: "Prices" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Prices" })).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ test.describe("Profile - Regular User Access", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can navigate to profile page from counter", async ({ page }) => {
|
test("can navigate to profile page from exchange", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Should see My Profile link
|
// Should see My Profile link
|
||||||
await expect(page.getByText("My Profile")).toBeVisible();
|
await expect(page.getByText("My Profile")).toBeVisible();
|
||||||
|
|
@ -86,8 +86,8 @@ test.describe("Profile - Regular User Access", () => {
|
||||||
await expect(page).toHaveURL("/profile");
|
await expect(page).toHaveURL("/profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can navigate to profile page from sum", async ({ page }) => {
|
test("can navigate to profile page from trades", async ({ page }) => {
|
||||||
await page.goto("/sum");
|
await page.goto("/trades");
|
||||||
|
|
||||||
// Should see My Profile link
|
// Should see My Profile link
|
||||||
await expect(page.getByText("My Profile")).toBeVisible();
|
await expect(page.getByText("My Profile")).toBeVisible();
|
||||||
|
|
@ -126,12 +126,12 @@ test.describe("Profile - Regular User Access", () => {
|
||||||
await expect(loginEmailInput).toBeDisabled();
|
await expect(loginEmailInput).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigation shows Counter, Sum, and My Profile", async ({ page }) => {
|
test("navigation shows Exchange, My Trades, and My Profile", async ({ page }) => {
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// Should see all nav items (Counter and Sum as links)
|
// Should see all nav items (Exchange and My Trades as links)
|
||||||
await expect(page.locator('a[href="/"]')).toBeVisible();
|
await expect(page.locator('a[href="/exchange"]')).toBeVisible();
|
||||||
await expect(page.locator('a[href="/sum"]')).toBeVisible();
|
await expect(page.locator('a[href="/trades"]')).toBeVisible();
|
||||||
// My Profile is the page title (h1) since we're on this page
|
// My Profile is the page title (h1) since we're on this page
|
||||||
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
@ -314,20 +314,20 @@ test.describe("Profile - Admin User Access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin does not see My Profile link", async ({ page }) => {
|
test("admin does not see My Profile link", async ({ page }) => {
|
||||||
await page.goto("/audit");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
// Should be on audit page
|
// Should be on admin trades page
|
||||||
await expect(page).toHaveURL("/audit");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
|
|
||||||
// Should NOT see My Profile link
|
// Should NOT see My Profile link
|
||||||
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
|
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin cannot access profile page - redirected to audit", async ({ page }) => {
|
test("admin cannot access profile page - redirected to admin trades", async ({ page }) => {
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// Should be redirected to audit
|
// Should be redirected to admin trades
|
||||||
await expect(page).toHaveURL("/audit");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin API call to profile returns 403", async ({ page, request }) => {
|
test("admin API call to profile returns 403", async ({ page, request }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue