Phase 6: Admin appointments view and cancellation with UI and backend tests
This commit is contained in:
parent
5108a620e7
commit
b3e00b0745
12 changed files with 814 additions and 548 deletions
259
frontend/app/admin/appointments/page.tsx
Normal file
259
frontend/app/admin/appointments/page.tsx
Normal 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" }}>
|
||||
"{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>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue