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.
377 lines
12 KiB
TypeScript
377 lines
12 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 you", 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: "800px",
|
|
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",
|
|
},
|
|
section: {
|
|
marginBottom: "2rem",
|
|
},
|
|
sectionTitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.1rem",
|
|
fontWeight: 500,
|
|
color: "#fff",
|
|
marginBottom: "1rem",
|
|
},
|
|
sectionTitleMuted: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.1rem",
|
|
fontWeight: 500,
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
marginBottom: "1rem",
|
|
},
|
|
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,
|
|
background: "rgba(255, 255, 255, 0.01)",
|
|
},
|
|
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",
|
|
},
|
|
appointmentNote: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "0.875rem",
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
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",
|
|
},
|
|
emptyStateLink: {
|
|
color: "#a78bfa",
|
|
textDecoration: "none",
|
|
},
|
|
};
|
|
|
|
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 (
|
|
<main style={styles.main}>
|
|
<div style={styles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<main style={styles.main}>
|
|
<Header currentPage="appointments" />
|
|
<div style={styles.content}>
|
|
<h1 style={styles.pageTitle}>My Appointments</h1>
|
|
<p style={styles.pageSubtitle}>
|
|
View and manage your booked appointments
|
|
</p>
|
|
|
|
{error && (
|
|
<div style={styles.errorBanner}>{error}</div>
|
|
)}
|
|
|
|
{isLoadingAppointments ? (
|
|
<div style={styles.emptyState}>Loading appointments...</div>
|
|
) : appointments.length === 0 ? (
|
|
<div style={styles.emptyState}>
|
|
<p>You don't have any appointments yet.</p>
|
|
<a href="/booking" style={styles.emptyStateLink}>Book an appointment</a>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Upcoming Appointments */}
|
|
{upcomingAppointments.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>
|
|
Upcoming ({upcomingAppointments.length})
|
|
</h2>
|
|
<div style={styles.appointmentList}>
|
|
{upcomingAppointments.map((apt) => {
|
|
const status = getStatusDisplay(apt.status);
|
|
return (
|
|
<div key={apt.id} style={styles.appointmentCard}>
|
|
<div style={styles.appointmentHeader}>
|
|
<div>
|
|
<div style={styles.appointmentTime}>
|
|
{formatDateTime(apt.slot_start)}
|
|
</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>
|
|
)}
|
|
|
|
{/* Past/Cancelled Appointments */}
|
|
{pastOrCancelledAppointments.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitleMuted}>
|
|
Past & Cancelled ({pastOrCancelledAppointments.length})
|
|
</h2>
|
|
<div style={styles.appointmentList}>
|
|
{pastOrCancelledAppointments.map((apt) => {
|
|
const status = getStatusDisplay(apt.status);
|
|
return (
|
|
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}>
|
|
<div style={styles.appointmentTime}>
|
|
{formatDateTime(apt.slot_start)}
|
|
</div>
|
|
{apt.note && (
|
|
<div style={styles.appointmentNote}>
|
|
{apt.note}
|
|
</div>
|
|
)}
|
|
<span style={{
|
|
...styles.statusBadge,
|
|
background: status.bgColor,
|
|
color: status.textColor,
|
|
}}>
|
|
{status.text}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|