diff --git a/backend/routes/booking.py b/backend/routes/booking.py index 2902a17..2981502 100644 --- a/backend/routes/booking.py +++ b/backend/routes/booking.py @@ -266,6 +266,13 @@ async def cancel_my_appointment( detail=f"Cannot cancel appointment with status '{appointment.status.value}'" ) + # Check if appointment is in the past + if appointment.slot_start <= datetime.now(timezone.utc): + raise HTTPException( + status_code=400, + detail="Cannot cancel a past appointment" + ) + # Cancel the appointment appointment.status = AppointmentStatus.CANCELLED_BY_USER appointment.cancelled_at = datetime.now(timezone.utc) @@ -346,6 +353,13 @@ async def admin_cancel_appointment( detail=f"Cannot cancel appointment with status '{appointment.status.value}'" ) + # Check if appointment is in the past + if appointment.slot_start <= datetime.now(timezone.utc): + raise HTTPException( + status_code=400, + detail="Cannot cancel a past appointment" + ) + # Cancel the appointment appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN appointment.cancelled_at = datetime.now(timezone.utc) diff --git a/backend/tests/test_booking.py b/backend/tests/test_booking.py index 1e5a65f..86e6cc7 100644 --- a/backend/tests/test_booking.py +++ b/backend/tests/test_booking.py @@ -3,9 +3,11 @@ Booking API Tests Tests for the user booking endpoints. """ -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone import pytest +from models import Appointment, AppointmentStatus + def tomorrow() -> date: return date.today() + timedelta(days=1) @@ -656,6 +658,30 @@ class TestCancelAppointment: ) assert len(slots_response.json()["slots"]) == 2 + @pytest.mark.asyncio + async def test_cannot_cancel_past_appointment(self, client_factory, regular_user): + """User cannot cancel a past appointment.""" + # Create a past appointment directly in DB + async with client_factory.get_db_session() as db: + past_time = datetime.now(timezone.utc) - timedelta(hours=1) + appointment = Appointment( + user_id=regular_user["user"]["id"], + slot_start=past_time, + slot_end=past_time + timedelta(minutes=15), + status=AppointmentStatus.BOOKED, + ) + db.add(appointment) + await db.commit() + await db.refresh(appointment) + apt_id = appointment.id + + # Try to cancel + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post(f"/api/appointments/{apt_id}/cancel") + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + # ============================================================================= # Admin Appointments Tests @@ -806,3 +832,27 @@ class TestAdminCancelAppointment: assert response.status_code == 400 assert "cancelled_by_user" in response.json()["detail"] + @pytest.mark.asyncio + async def test_admin_cannot_cancel_past_appointment(self, client_factory, regular_user, admin_user): + """Admin cannot cancel a past appointment.""" + # Create a past appointment directly in DB + async with client_factory.get_db_session() as db: + past_time = datetime.now(timezone.utc) - timedelta(hours=1) + appointment = Appointment( + user_id=regular_user["user"]["id"], + slot_start=past_time, + slot_end=past_time + timedelta(minutes=15), + status=AppointmentStatus.BOOKED, + ) + db.add(appointment) + await db.commit() + await db.refresh(appointment) + apt_id = appointment.id + + # Admin tries to cancel + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + response = await admin_client.post(f"/api/admin/appointments/{apt_id}/cancel") + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + diff --git a/frontend/app/admin/appointments/page.tsx b/frontend/app/admin/appointments/page.tsx index 3dcebed..11b4b93 100644 --- a/frontend/app/admin/appointments/page.tsx +++ b/frontend/app/admin/appointments/page.tsx @@ -1,9 +1,9 @@ "use client"; +import React from "react"; import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; import { api } from "../../api"; -import { sharedStyles } from "../../styles/shared"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; @@ -24,19 +24,167 @@ function formatDateTime(isoString: string): string { } // Helper to get status display -function getStatusDisplay(status: string): { text: string; color: string } { +function getStatusDisplay(status: string): { text: string; bgColor: string; textColor: string } { switch (status) { case "booked": - return { text: "Booked", color: "#28a745" }; + return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" }; case "cancelled_by_user": - return { text: "Cancelled by user", color: "#dc3545" }; + return { text: "Cancelled by user", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" }; case "cancelled_by_admin": - return { text: "Cancelled by admin", color: "#dc3545" }; + return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" }; default: - return { text: status, color: "#666" }; + return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" }; } } +const styles: 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", + }, + errorBanner: { + background: "rgba(239, 68, 68, 0.15)", + border: "1px solid rgba(239, 68, 68, 0.3)", + color: "#f87171", + padding: "1rem", + borderRadius: "8px", + marginBottom: "1rem", + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + }, + 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", + }, +}; + export default function AdminAppointmentsPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_ALL_APPOINTMENTS, @@ -89,12 +237,9 @@ export default function AdminAppointmentsPage() { if (isLoading) { return ( -
-
-
-

Loading...

-
-
+
+
Loading...
+
); } @@ -108,41 +253,27 @@ export default function AdminAppointmentsPage() { }); const bookedCount = appointments.filter((a) => a.status === "booked").length; - const cancelledCount = appointments.filter((a) => a.status !== "booked").length; return ( -
+
-
-

All Appointments

-

+

+

All Appointments

+

View and manage all user appointments

{error && ( -
- {error} -
+
{error}
)} {/* Status Filter */} -
- +
+ Filter: