- Install prettier - Configure .prettierrc.json and .prettierignore - Add npm scripts: format, format:check - Add Makefile target: format-frontend - Format all frontend files
333 lines
11 KiB
TypeScript
333 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";
|
|
import { formatDateTime } from "../utils/date";
|
|
import { getStatusDisplay } from "../utils/appointment";
|
|
import { sharedStyles } from "../styles/shared";
|
|
|
|
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
|
|
|
const pageStyles: 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",
|
|
},
|
|
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",
|
|
},
|
|
};
|
|
|
|
const styles = { ...sharedStyles, ...pageStyles };
|
|
|
|
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) {
|
|
setError(err instanceof Error ? err.message : "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, 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>
|
|
);
|
|
}
|