Phase 6: Admin appointments view and cancellation with UI and backend tests

This commit is contained in:
counterweight 2025-12-21 00:30:09 +01:00
parent 5108a620e7
commit b3e00b0745
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
12 changed files with 814 additions and 548 deletions

View file

@ -0,0 +1,259 @@
"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 user", color: "#dc3545" };
case "cancelled_by_admin":
return { text: "Cancelled by admin", color: "#dc3545" };
default:
return { text: status, color: "#666" };
}
}
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 {
const data = await api.get<AppointmentResponse[]>("/api/admin/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/admin/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="admin-appointments" />
<main style={sharedStyles.mainContent}>
<p>Loading...</p>
</main>
</div>
);
}
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;
const cancelledCount = appointments.filter((a) => a.status !== "booked").length;
return (
<div style={sharedStyles.pageContainer}>
<Header currentPage="admin-appointments" />
<main style={sharedStyles.mainContent}>
<h1 style={{ marginBottom: "0.5rem" }}>All Appointments</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
View and manage all user appointments
</p>
{error && (
<div style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
color: "#721c24",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
}}>
{error}
</div>
)}
{/* Status Filter */}
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<label style={{ fontWeight: 500 }}>Filter:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<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 ? (
<p>Loading appointments...</p>
) : appointments.length === 0 ? (
<p style={{ color: "#666" }}>No appointments yet.</p>
) : filteredAppointments.length === 0 ? (
<p style={{ color: "#666" }}>No appointments match the filter.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{filteredAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
const isPast = new Date(apt.slot_start) <= new Date();
return (
<div
key={apt.id}
style={{
border: "1px solid #ddd",
borderRadius: "8px",
padding: "1rem",
background: isPast ? "#fafafa" : "#fff",
opacity: isPast && apt.status !== "booked" ? 0.7 : 1,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
{formatDateTime(apt.slot_start)}
</div>
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.25rem" }}>
User: {apt.user_email}
</div>
{apt.note && (
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem", fontStyle: "italic" }}>
&quot;{apt.note}&quot;
</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>
)}
</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" | "appointments" | "audit" | "admin-invites" | "admin-availability";
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability" | "admin-appointments";
interface HeaderProps {
currentPage: PageId;
@ -34,6 +34,7 @@ const ADMIN_NAV_ITEMS: NavItem[] = [
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
{ id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true },
{ id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true },
{ id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true },
];
export function Header({ currentPage }: HeaderProps) {

View file

@ -436,6 +436,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/appointments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get All Appointments
* @description Get all appointments (admin only), sorted by date descending.
*/
get: operations["get_all_appointments_api_admin_appointments_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/appointments/{appointment_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Admin Cancel Appointment
* @description Cancel any appointment (admin only).
*/
post: operations["admin_cancel_appointment_api_admin_appointments__appointment_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/meta/constants": {
parameters: {
query?: never;
@ -1575,6 +1615,57 @@ export interface operations {
};
};
};
get_all_appointments_api_admin_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"][];
};
};
};
};
admin_cancel_appointment_api_admin_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;