arbret/frontend/app/admin/appointments/page.tsx
counterweight 6ff3c0a133
Extract duplicate date formatting functions to shared utilities
- Created frontend/app/utils/date.ts with formatDate, formatTime, formatDateTime, formatDisplayDate
- Created frontend/e2e/helpers/date.ts with formatDateLocal, getTomorrowDateStr
- Updated all frontend pages and e2e tests to use shared utilities
- Removed duplicate date formatting code from 6 files
2025-12-21 17:48:17 +01:00

354 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";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
type PaginatedAppointments = components["schemas"]["PaginatedResponse_AppointmentResponse_"];
// 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 {
// Fetch with large per_page to get all appointments for now
const data = await api.get<PaginatedAppointments>("/api/admin/appointments?per_page=100");
setAppointments(data.records);
} 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}>
&quot;{apt.note}&quot;
</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>
);
}