arbret/frontend/app/appointments/page.tsx

353 lines
11 KiB
TypeScript
Raw Normal View History

"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";
import { formatDateTime } from "../utils/date";
import { getStatusDisplay } from "../utils/appointment";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
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&apos;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, true);
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, true);
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>
);
}