"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"; import constants from "../../../../shared/constants.json"; const { slotDurationMinutes, maxAdvanceDays } = constants.booking; type AvailabilityDay = components["schemas"]["AvailabilityDay"]; type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; type TimeSlot = components["schemas"]["TimeSlot"]; // Helper to format date as YYYY-MM-DD function formatDate(d: Date): string { return d.toISOString().split("T")[0]; } // Helper to get next N days starting from tomorrow function getDateRange(): Date[] { const dates: Date[] = []; const today = new Date(); for (let i = 1; i <= maxAdvanceDays; i++) { const d = new Date(today); d.setDate(today.getDate() + i); dates.push(d); } return dates; } // Generate time options for dropdowns (15-min intervals) function generateTimeOptions(): string[] { const options: string[] = []; for (let h = 0; h < 24; h++) { for (let m = 0; m < 60; m += slotDurationMinutes) { const hh = h.toString().padStart(2, "0"); const mm = m.toString().padStart(2, "0"); options.push(`${hh}:${mm}`); } } return options; } const TIME_OPTIONS = generateTimeOptions(); interface EditSlot { start_time: string; end_time: string; } export default function AdminAvailabilityPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.MANAGE_AVAILABILITY, fallbackRedirect: "/", }); const [availability, setAvailability] = useState>(new Map()); const [selectedDate, setSelectedDate] = useState(null); const [editSlots, setEditSlots] = useState([]); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [copySource, setCopySource] = useState(null); const [copyTargets, setCopyTargets] = useState>(new Set()); const [isCopying, setIsCopying] = useState(false); const dates = getDateRange(); const fetchAvailability = useCallback(async () => { if (!dates.length) return; try { const fromDate = formatDate(dates[0]); const toDate = formatDate(dates[dates.length - 1]); const data = await api.get( `/api/admin/availability?from=${fromDate}&to=${toDate}` ); const map = new Map(); for (const day of data.days) { map.set(day.date, day.slots); } setAvailability(map); } catch (err) { console.error("Failed to fetch availability:", err); } }, []); useEffect(() => { if (user && isAuthorized) { fetchAvailability(); } }, [user, isAuthorized, fetchAvailability]); const openEditModal = (date: Date) => { const dateStr = formatDate(date); const existingSlots = availability.get(dateStr) || []; setEditSlots( existingSlots.length > 0 ? existingSlots.map((s) => ({ start_time: s.start_time.slice(0, 5), end_time: s.end_time.slice(0, 5), })) : [{ start_time: "09:00", end_time: "17:00" }] ); setSelectedDate(date); setError(null); }; const closeModal = () => { setSelectedDate(null); setEditSlots([]); setError(null); }; const addSlot = () => { setEditSlots([...editSlots, { start_time: "09:00", end_time: "17:00" }]); }; const removeSlot = (index: number) => { setEditSlots(editSlots.filter((_, i) => i !== index)); }; const updateSlot = (index: number, field: "start_time" | "end_time", value: string) => { const updated = [...editSlots]; updated[index][field] = value; setEditSlots(updated); }; const saveAvailability = async () => { if (!selectedDate) return; setIsSaving(true); setError(null); try { const slots = editSlots.map((s) => ({ start_time: s.start_time + ":00", end_time: s.end_time + ":00", })); await api.put("/api/admin/availability", { date: formatDate(selectedDate), slots, }); await fetchAvailability(); closeModal(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save"); } finally { setIsSaving(false); } }; const clearAvailability = async () => { if (!selectedDate) return; setIsSaving(true); setError(null); try { await api.put("/api/admin/availability", { date: formatDate(selectedDate), slots: [], }); await fetchAvailability(); closeModal(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to clear"); } finally { setIsSaving(false); } }; const startCopyMode = (dateStr: string) => { setCopySource(dateStr); setCopyTargets(new Set()); }; const cancelCopyMode = () => { setCopySource(null); setCopyTargets(new Set()); }; const toggleCopyTarget = (dateStr: string) => { if (dateStr === copySource) return; const newTargets = new Set(copyTargets); if (newTargets.has(dateStr)) { newTargets.delete(dateStr); } else { newTargets.add(dateStr); } setCopyTargets(newTargets); }; const executeCopy = async () => { if (!copySource || copyTargets.size === 0) return; setIsCopying(true); setError(null); try { await api.post("/api/admin/availability/copy", { source_date: copySource, target_dates: Array.from(copyTargets), }); await fetchAvailability(); cancelCopyMode(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to copy"); } finally { setIsCopying(false); } }; const formatDisplayDate = (d: Date): string => { return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); }; const formatSlotTime = (slot: TimeSlot): string => { return `${slot.start_time.slice(0, 5)} - ${slot.end_time.slice(0, 5)}`; }; if (isLoading) { return (
Loading...
); } if (!user || !isAuthorized) { return null; } return (

Availability

Configure your available time slots for the next {maxAdvanceDays} days

{copySource && (
Select days to copy to, then click Copy
)}
{error && !selectedDate && (
{error}
)}
{dates.map((date) => { const dateStr = formatDate(date); const slots = availability.get(dateStr) || []; const hasSlots = slots.length > 0; const isSource = copySource === dateStr; const isTarget = copyTargets.has(dateStr); const isWeekend = date.getDay() === 0 || date.getDay() === 6; return (
{ if (copySource) { toggleCopyTarget(dateStr); } else { openEditModal(date); } }} >
{formatDisplayDate(date)} {hasSlots && !copySource && ( )}
{slots.length === 0 ? ( No availability ) : ( slots.map((slot, i) => ( {formatSlotTime(slot)} )) )}
); })}
{/* Edit Modal */} {selectedDate && (
e.stopPropagation()}>

Edit Availability - {formatDisplayDate(selectedDate)}

{error &&
{error}
}
{editSlots.map((slot, index) => (
))}
)}
); } const pageStyles: Record = { content: { flex: 1, padding: "2rem", overflowY: "auto", }, pageContainer: { maxWidth: "1200px", margin: "0 auto", }, headerRow: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "2rem", flexWrap: "wrap", gap: "1rem", }, pageTitle: { fontFamily: "'Instrument Serif', Georgia, serif", fontSize: "2rem", fontWeight: 400, color: "#fff", margin: 0, }, pageSubtitle: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.5)", marginTop: "0.5rem", fontSize: "0.95rem", }, copyActions: { display: "flex", alignItems: "center", gap: "1rem", }, copyHint: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", color: "rgba(255, 255, 255, 0.6)", }, copyButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", padding: "0.5rem 1rem", background: "rgba(99, 102, 241, 0.3)", color: "#fff", border: "1px solid rgba(99, 102, 241, 0.5)", borderRadius: "8px", cursor: "pointer", }, errorBanner: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.9rem", padding: "1rem", background: "rgba(239, 68, 68, 0.15)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "8px", color: "#f87171", marginBottom: "1rem", }, calendar: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "1rem", }, dayCard: { background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "12px", padding: "1rem", cursor: "pointer", transition: "all 0.2s", }, dayCardActive: { background: "rgba(99, 102, 241, 0.1)", borderColor: "rgba(99, 102, 241, 0.3)", }, dayCardSource: { background: "rgba(34, 197, 94, 0.15)", borderColor: "rgba(34, 197, 94, 0.5)", }, dayCardTarget: { background: "rgba(99, 102, 241, 0.2)", borderColor: "rgba(99, 102, 241, 0.6)", }, dayCardWeekend: { opacity: 0.7, }, dayHeader: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", }, dayName: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", fontWeight: 600, color: "rgba(255, 255, 255, 0.9)", }, copyFromButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1rem", padding: "0.25rem 0.5rem", background: "transparent", color: "rgba(255, 255, 255, 0.5)", border: "none", cursor: "pointer", }, slotList: { display: "flex", flexDirection: "column", gap: "0.25rem", }, noSlots: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "rgba(255, 255, 255, 0.3)", }, slotBadge: { fontFamily: "'DM Mono', monospace", fontSize: "0.7rem", padding: "0.2rem 0.4rem", background: "rgba(99, 102, 241, 0.2)", borderRadius: "4px", color: "rgba(129, 140, 248, 0.9)", }, modalOverlay: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0, 0, 0, 0.7)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000, }, modal: { background: "#1a1a3e", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "16px", padding: "2rem", width: "90%", maxWidth: "400px", boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", }, modalTitle: { fontFamily: "'Instrument Serif', Georgia, serif", fontSize: "1.5rem", fontWeight: 400, color: "#fff", margin: "0 0 1.5rem 0", }, modalError: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", padding: "0.75rem", background: "rgba(239, 68, 68, 0.15)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "8px", color: "#f87171", marginBottom: "1rem", }, slotsEditor: { display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1.5rem", }, slotRow: { display: "flex", alignItems: "center", gap: "0.5rem", }, timeSelect: { fontFamily: "'DM Mono', monospace", fontSize: "0.9rem", padding: "0.5rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "6px", color: "#fff", cursor: "pointer", flex: 1, }, slotDash: { color: "rgba(255, 255, 255, 0.4)", }, removeSlotButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1.2rem", width: "32px", height: "32px", background: "rgba(239, 68, 68, 0.15)", color: "rgba(239, 68, 68, 0.9)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "6px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", }, addSlotButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", padding: "0.5rem", background: "transparent", color: "rgba(99, 102, 241, 0.9)", border: "1px dashed rgba(99, 102, 241, 0.4)", borderRadius: "6px", cursor: "pointer", }, modalActions: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "1rem", }, modalActionsRight: { display: "flex", gap: "0.75rem", }, clearButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", padding: "0.6rem 1rem", background: "rgba(239, 68, 68, 0.15)", color: "rgba(239, 68, 68, 0.9)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "8px", cursor: "pointer", }, cancelButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", padding: "0.6rem 1rem", background: "rgba(255, 255, 255, 0.05)", color: "rgba(255, 255, 255, 0.7)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "8px", cursor: "pointer", }, saveButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.85rem", fontWeight: 500, padding: "0.6rem 1.5rem", background: "rgba(99, 102, 241, 0.3)", color: "#fff", border: "1px solid rgba(99, 102, 241, 0.5)", borderRadius: "8px", cursor: "pointer", }, }; const styles = { ...sharedStyles, ...pageStyles };