Add check to both user and admin cancel endpoints to reject cancellation of appointments whose slot_start is in the past. This matches the spec requirement that cancellations can only happen 'before the appointment'. Added tests for both user and admin cancel endpoints. Also includes frontend styling updates.
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Permission } from "../../auth-context";
|
|
import { api } from "../../api";
|
|
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; bgColor: string; textColor: string } {
|
|
switch (status) {
|
|
case "booked":
|
|
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
|
|
case "cancelled_by_user":
|
|
return { text: "Cancelled by user", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
|
case "cancelled_by_admin":
|
|
return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
|
default:
|
|
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
|
}
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
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,
|
|
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 (
|
|
<main style={styles.main}>
|
|
<div style={styles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
return (
|
|
<main style={styles.main}>
|
|
<Header currentPage="admin-appointments" />
|
|
<div style={styles.content}>
|
|
<h1 style={styles.pageTitle}>All Appointments</h1>
|
|
<p style={styles.pageSubtitle}>
|
|
View and manage all user appointments
|
|
</p>
|
|
|
|
{error && (
|
|
<div style={styles.errorBanner}>{error}</div>
|
|
)}
|
|
|
|
{/* Status Filter */}
|
|
<div style={styles.filterRow}>
|
|
<span style={styles.filterLabel}>Filter:</span>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
style={styles.filterSelect}
|
|
>
|
|
<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 ? (
|
|
<div style={styles.emptyState}>Loading appointments...</div>
|
|
) : appointments.length === 0 ? (
|
|
<div style={styles.emptyState}>No appointments yet.</div>
|
|
) : filteredAppointments.length === 0 ? (
|
|
<div style={styles.emptyState}>No appointments match the filter.</div>
|
|
) : (
|
|
<div style={styles.appointmentList}>
|
|
{filteredAppointments.map((apt) => {
|
|
const status = getStatusDisplay(apt.status);
|
|
const isPast = new Date(apt.slot_start) <= new Date();
|
|
return (
|
|
<div
|
|
key={apt.id}
|
|
style={{
|
|
...styles.appointmentCard,
|
|
...(isPast ? styles.appointmentCardPast : {}),
|
|
}}
|
|
>
|
|
<div style={styles.appointmentHeader}>
|
|
<div>
|
|
<div style={styles.appointmentTime}>
|
|
{formatDateTime(apt.slot_start)}
|
|
</div>
|
|
<div style={styles.appointmentUser}>
|
|
{apt.user_email}
|
|
</div>
|
|
{apt.note && (
|
|
<div style={styles.appointmentNote}>
|
|
"{apt.note}"
|
|
</div>
|
|
)}
|
|
<span style={{
|
|
...styles.statusBadge,
|
|
background: status.bgColor,
|
|
color: status.textColor,
|
|
}}>
|
|
{status.text}
|
|
</span>
|
|
</div>
|
|
|
|
{apt.status === "booked" && (
|
|
<div style={styles.buttonGroup}>
|
|
{confirmCancelId === apt.id ? (
|
|
<>
|
|
<button
|
|
onClick={() => handleCancel(apt.id)}
|
|
disabled={cancellingId === apt.id}
|
|
style={styles.confirmButton}
|
|
>
|
|
{cancellingId === apt.id ? "..." : "Confirm"}
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmCancelId(null)}
|
|
style={styles.cancelButton}
|
|
>
|
|
No
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setConfirmCancelId(apt.id)}
|
|
style={styles.cancelButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|