259 lines
9.2 KiB
TypeScript
259 lines
9.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|