290 lines
11 KiB
TypeScript
290 lines
11 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 you", color: "#dc3545" };
|
|
case "cancelled_by_admin":
|
|
return { text: "Cancelled by admin", color: "#dc3545" };
|
|
default:
|
|
return { text: status, color: "#666" };
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<div style={sharedStyles.pageContainer}>
|
|
<Header currentPage="appointments" />
|
|
<main style={sharedStyles.mainContent}>
|
|
<p>Loading...</p>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={sharedStyles.pageContainer}>
|
|
<Header currentPage="appointments" />
|
|
<main style={sharedStyles.mainContent}>
|
|
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
|
|
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
|
View and manage your booked appointments
|
|
</p>
|
|
|
|
{error && (
|
|
<div style={{
|
|
background: "#f8d7da",
|
|
border: "1px solid #f5c6cb",
|
|
color: "#721c24",
|
|
padding: "1rem",
|
|
borderRadius: "8px",
|
|
marginBottom: "1rem",
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{isLoadingAppointments ? (
|
|
<p>Loading appointments...</p>
|
|
) : appointments.length === 0 ? (
|
|
<div style={{
|
|
textAlign: "center",
|
|
padding: "2rem",
|
|
color: "#666",
|
|
}}>
|
|
<p>You don't have any appointments yet.</p>
|
|
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Upcoming Appointments */}
|
|
{upcomingAppointments.length > 0 && (
|
|
<div style={{ marginBottom: "2rem" }}>
|
|
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
|
|
Upcoming ({upcomingAppointments.length})
|
|
</h2>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
{upcomingAppointments.map((apt) => {
|
|
const status = getStatusDisplay(apt.status);
|
|
return (
|
|
<div
|
|
key={apt.id}
|
|
style={{
|
|
border: "1px solid #ddd",
|
|
borderRadius: "8px",
|
|
padding: "1rem",
|
|
background: "#fff",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
<div>
|
|
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
|
{formatDateTime(apt.slot_start)}
|
|
</div>
|
|
{apt.note && (
|
|
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Past/Cancelled Appointments */}
|
|
{pastOrCancelledAppointments.length > 0 && (
|
|
<div>
|
|
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
|
|
Past & Cancelled ({pastOrCancelledAppointments.length})
|
|
</h2>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
{pastOrCancelledAppointments.map((apt) => {
|
|
const status = getStatusDisplay(apt.status);
|
|
return (
|
|
<div
|
|
key={apt.id}
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: "8px",
|
|
padding: "1rem",
|
|
background: "#fafafa",
|
|
opacity: 0.8,
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
|
{formatDateTime(apt.slot_start)}
|
|
</div>
|
|
{apt.note && (
|
|
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
|
{apt.note}
|
|
</div>
|
|
)}
|
|
<span style={{
|
|
color: status.color,
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
}}>
|
|
{status.text}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|