Phase 5: User appointments view and cancellation with UI and e2e tests

This commit is contained in:
counterweight 2025-12-21 00:24:16 +01:00
parent 8ff03a8ec3
commit 5108a620e7
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
14 changed files with 1539 additions and 4 deletions

View file

@ -0,0 +1,290 @@
"use client";
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";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
// Helper to format datetime
function formatDateTime(isoString: string): string {
const d = new Date(isoString);
return d.toLocaleString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
// Helper to get status display
function getStatusDisplay(status: string): { text: string; color: string } {
switch (status) {
case "booked":
return { text: "Booked", color: "#28a745" };
case "cancelled_by_user":
return { text: "Cancelled by you", color: "#dc3545" };
case "cancelled_by_admin":
return { text: "Cancelled by admin", color: "#dc3545" };
default:
return { text: status, color: "#666" };
}
}
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) {
if (err instanceof Error) {
setError(err.message);
} else {
setError("Failed to cancel appointment");
}
} finally {
setCancellingId(null);
}
};
if (isLoading) {
return (
<div style={sharedStyles.pageContainer}>
<Header currentPage="appointments" />
<main style={sharedStyles.mainContent}>
<p>Loading...</p>
</main>
</div>
);
}
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 (
<div style={sharedStyles.pageContainer}>
<Header currentPage="appointments" />
<main style={sharedStyles.mainContent}>
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
View and manage your booked appointments
</p>
{error && (
<div style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
color: "#721c24",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
}}>
{error}
</div>
)}
{isLoadingAppointments ? (
<p>Loading appointments...</p>
) : appointments.length === 0 ? (
<div style={{
textAlign: "center",
padding: "2rem",
color: "#666",
}}>
<p>You don&apos;t have any appointments yet.</p>
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
</div>
) : (
<>
{/* Upcoming Appointments */}
{upcomingAppointments.length > 0 && (
<div style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
Upcoming ({upcomingAppointments.length})
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{upcomingAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
return (
<div
key={apt.id}
style={{
border: "1px solid #ddd",
borderRadius: "8px",
padding: "1rem",
background: "#fff",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
{apt.note}
</div>
)}
<span style={{
color: status.color,
fontSize: "0.75rem",
fontWeight: 500,
}}>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div>
{confirmCancelId === apt.id ? (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => handleCancel(apt.id)}
disabled={cancellingId === apt.id}
style={{
padding: "0.25rem 0.75rem",
background: "#dc3545",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: cancellingId === apt.id ? "not-allowed" : "pointer",
fontSize: "0.75rem",
}}
>
{cancellingId === apt.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
color: "#666",
}}
>
Cancel
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Past/Cancelled Appointments */}
{pastOrCancelledAppointments.length > 0 && (
<div>
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
Past & Cancelled ({pastOrCancelledAppointments.length})
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{pastOrCancelledAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
return (
<div
key={apt.id}
style={{
border: "1px solid #eee",
borderRadius: "8px",
padding: "1rem",
background: "#fafafa",
opacity: 0.8,
}}
>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
{apt.note}
</div>
)}
<span style={{
color: status.color,
fontSize: "0.75rem",
fontWeight: 500,
}}>
{status.text}
</span>
</div>
);
})}
</div>
</div>
)}
</>
)}
</main>
</div>
);
}

View file

@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles;
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "audit" | "admin-invites" | "admin-availability";
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability";
interface HeaderProps {
currentPage: PageId;
@ -25,6 +25,7 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
{ id: "counter", label: "Counter", href: "/" },
{ id: "sum", label: "Sum", href: "/sum" },
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
];

View file

@ -396,6 +396,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/appointments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get My Appointments
* @description Get the current user's appointments, sorted by date (upcoming first).
*/
get: operations["get_my_appointments_api_appointments_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/appointments/{appointment_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Cancel My Appointment
* @description Cancel one of the current user's appointments.
*/
post: operations["cancel_my_appointment_api_appointments__appointment_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/meta/constants": {
parameters: {
query?: never;
@ -1484,6 +1524,57 @@ export interface operations {
};
};
};
get_my_appointments_api_appointments_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AppointmentResponse"][];
};
};
};
};
cancel_my_appointment_api_appointments__appointment_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
appointment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AppointmentResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_constants_api_meta_constants_get: {
parameters: {
query?: never;