"use client"; import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; import constants from "../../../../shared/constants.json"; import { formatDate, formatDisplayDate, getDateRange, formatTimeString, isWeekend, } from "../../utils/date"; import { layoutStyles, typographyStyles, bannerStyles, buttonStyles, modalStyles, } from "../../styles/shared"; const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange; type _AvailabilityDay = components["schemas"]["AvailabilityDay"]; type TimeSlot = components["schemas"]["TimeSlot"]; // Generate time options for dropdowns (15-min intervals) // Moved outside component since slotDurationMinutes is a constant function generateTimeOptions(slotDurationMinutes: number): 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(slotDurationMinutes); 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(minAdvanceDays, maxAdvanceDays); const fetchAvailability = useCallback(async () => { const dateRange = getDateRange(minAdvanceDays, maxAdvanceDays); if (!dateRange.length) return; try { const fromDate = formatDate(dateRange[0]); const toDate = formatDate(dateRange[dateRange.length - 1]); const data = await adminApi.getAvailability(fromDate, 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: formatTimeString(s.start_time), end_time: formatTimeString(s.end_time), })) : [{ 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 adminApi.updateAvailability({ 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 adminApi.updateAvailability({ 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 adminApi.copyAvailability({ 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 formatSlotTime = (slot: TimeSlot): string => { return `${formatTimeString(slot.start_time)} - ${formatTimeString(slot.end_time)}`; }; 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 isWeekendDate = isWeekend(date); 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) => (
→
))}
)}
); } // Page-specific styles const styles: Record = { pageContainer: { maxWidth: "1200px", margin: "0 auto", }, headerRow: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "2rem", flexWrap: "wrap", gap: "1rem", }, 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)", }, 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)", border: "1px solid rgba(99, 102, 241, 0.3)", }, dayCardSource: { background: "rgba(34, 197, 94, 0.15)", border: "1px solid rgba(34, 197, 94, 0.5)", }, dayCardTarget: { background: "rgba(99, 102, 241, 0.2)", border: "1px solid 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)", }, 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", }, 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", }, };