import React from "react"; import { Button } from "./button"; import { DatePicker } from "./date-picker"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./dialog"; import { Input } from "./input"; import { Label } from "./label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select"; import { Separator } from "./separator"; import { Switch } from "./switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; import { cn } from "@/lib/utils"; import { formatDateWithWeekday } from "@/lib/format-date"; import { Plus, X } from "lucide-react"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AppointmentDaySchedule { day: "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; enabled: boolean; /** 24h format — "09:00" */ startTime: string; /** 24h format — "17:00" */ endTime: string; } export interface AppointmentAvailabilityPrefs { meetingDuration: string; schedulingBuffer: string; maxSlotsPerDay: string; /** IANA timezone identifier — e.g. "Australia/Sydney" */ timezone: string; /** * Online meeting platform brokers require clients to use. * "any" means the client can choose between Google Meet and Microsoft Teams. */ defaultMeetingPlatform?: "google-meet" | "microsoft-teams" | "any"; } export interface AppointmentBlockedDate { /** ISO date string — "2026-04-25" */ date: string; /** Human-readable label — "ANZAC Day" */ label?: string; /** Partial day — start time in 24h format "09:00" */ timeStart?: string; /** Partial day — end time in 24h format "17:00" */ timeEnd?: string; } export interface AppointmentAvailabilitySettingsProps { /** * Initial weekly schedule. * @remarks Mount-time initialiser only — prop changes after mount are ignored. * Use the `key` prop to reset the component when async data arrives. */ schedule: AppointmentDaySchedule[]; /** * Saved booking preferences from DB — initialises the Booking Preferences tab. * @remarks Mount-time initialiser only. */ prefs?: AppointmentAvailabilityPrefs; /** * Custom blocked dates added by the user (non-holiday). * Merged with `publicHolidays` in the Time Off tab. * @remarks Mount-time initialiser only. */ blockedDates?: AppointmentBlockedDate[]; /** * Public holiday list from the DB/API. * When provided, replaces the built-in `AU_PUBLIC_HOLIDAYS_2026` fallback. * @remarks Mount-time initialiser only. */ publicHolidays?: AppointmentBlockedDate[]; onSave?: ( schedule: AppointmentDaySchedule[], prefs: AppointmentAvailabilityPrefs ) => void; onBlockedDatesChange?: (dates: AppointmentBlockedDate[]) => void; /** Fired immediately whenever any booking preference value changes (before Save). */ onPrefsChange?: (prefs: AppointmentAvailabilityPrefs) => void; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Fallback holiday list used when the `publicHolidays` prop is not provided. */ const AU_PUBLIC_HOLIDAYS_2026: AppointmentBlockedDate[] = [ { date: "2026-01-01", label: "New Year's Day" }, { date: "2026-01-26", label: "Australia Day" }, { date: "2026-04-03", label: "Good Friday" }, { date: "2026-04-04", label: "Easter Saturday" }, { date: "2026-04-06", label: "Easter Monday" }, { date: "2026-04-25", label: "ANZAC Day" }, { date: "2026-06-08", label: "King's Birthday" }, { date: "2026-12-25", label: "Christmas Day" }, { date: "2026-12-26", label: "Boxing Day" }, ]; export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ { value: "Australia/Sydney", label: "Sydney / Melbourne (AEDT)" }, { value: "Australia/Brisbane", label: "Brisbane (AEST, no DST)" }, { value: "Australia/Adelaide", label: "Adelaide (ACDT)" }, { value: "Australia/Perth", label: "Perth (AWST)" }, { value: "Australia/Darwin", label: "Darwin (ACST, no DST)" }, { value: "Australia/Hobart", label: "Hobart (AEDT)" }, { value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (ICT)" }, { value: "Asia/Singapore", label: "Singapore (SGT)" }, { value: "UTC", label: "UTC" }, ]; export const MEETING_PLATFORM_OPTIONS: { value: "google-meet" | "microsoft-teams" | "any"; label: string; }[] = [ { value: "google-meet", label: "Google Meet" }, { value: "microsoft-teams", label: "Microsoft Teams" }, { value: "any", label: "Let client choose" }, ]; const MEETING_DURATION_OPTIONS: { value: string; label: string }[] = [ { value: "15", label: "15 minutes" }, { value: "30", label: "30 minutes" }, { value: "45", label: "45 minutes" }, { value: "60", label: "60 minutes" }, { value: "90", label: "90 minutes" }, ]; const SCHEDULING_BUFFER_OPTIONS: { value: string; label: string }[] = [ { value: "0", label: "No buffer" }, { value: "5", label: "5 minutes" }, { value: "10", label: "10 minutes" }, { value: "15", label: "15 minutes" }, { value: "30", label: "30 minutes" }, ]; const MAX_SLOTS_OPTIONS: { value: string; label: string }[] = [ { value: "2", label: "2 per day" }, { value: "4", label: "4 per day" }, { value: "6", label: "6 per day" }, { value: "8", label: "8 per day" }, { value: "10", label: "10 per day" }, { value: "unlimited", label: "Unlimited" }, ]; /** Map a Base UI SelectValue `v` to its display label from an options array. */ const selectLabel = (opts: { value: string; label: string }[], v: unknown) => opts.find((o) => o.value === String(v))?.label ?? String(v ?? ""); // 30-min increments from 06:00 to 21:30 const TIME_OPTIONS: { value: string; label: string }[] = (() => { const opts: { value: string; label: string }[] = []; for (let h = 6; h <= 21; h++) { for (const m of [0, 30]) { const hh = String(h).padStart(2, "0"); const mm = String(m).padStart(2, "0"); const hour12 = h === 0 ? 12 : h > 12 ? h - 12 : h; const ampm = h < 12 ? "AM" : "PM"; opts.push({ value: `${hh}:${mm}`, label: `${hour12}:${mm} ${ampm}` }); } } return opts; })(); const timeLabel = (v: string) => TIME_OPTIONS.find((o) => o.value === v)?.label ?? v; // --------------------------------------------------------------------------- // Internal type // --------------------------------------------------------------------------- interface TimeOffEntry { date: string; label?: string; enabled: boolean; isHoliday: boolean; timeStart?: string; timeEnd?: string; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function TimeSelect({ value, onChange, disabled, }: { value: string; onChange: (v: string) => void; disabled?: boolean; }) { return ( ); } /** Shared row layout for the Booking Preferences tab. */ function PrefRow({ label, description, children, }: { label: string; description: string; children: React.ReactNode; }) { return (

{label}

{description}

{children}
); } function AddTimeOffDialog({ open, onOpenChange, onAdd, }: { open: boolean; onOpenChange: (v: boolean) => void; onAdd: (entry: AppointmentBlockedDate) => void; }) { const [label, setLabel] = React.useState(""); const [date, setDate] = React.useState(undefined); const [includeTime, setIncludeTime] = React.useState(false); const [timeStart, setTimeStart] = React.useState("09:00"); const [timeEnd, setTimeEnd] = React.useState("17:00"); const reset = () => { setLabel(""); setDate(undefined); setIncludeTime(false); setTimeStart("09:00"); setTimeEnd("17:00"); }; const handleAdd = () => { if (!date) return; const isoDate = [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0"), ].join("-"); onAdd({ date: isoDate, label: label.trim() || undefined, timeStart: includeTime ? timeStart : undefined, timeEnd: includeTime ? timeEnd : undefined, }); reset(); onOpenChange(false); }; const handleCancel = () => { reset(); onOpenChange(false); }; return ( Add time off Block a date when you are unavailable. Clients cannot book on this date.
setLabel(e.target.value)} />
{includeTime && (
to
)}
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function AppointmentAvailabilitySettings({ schedule: initialSchedule, prefs: prefsProp, blockedDates: blockedDatesProp, publicHolidays: publicHolidaysProp, onSave, onBlockedDatesChange, onPrefsChange, }: AppointmentAvailabilitySettingsProps) { const [schedule, setSchedule] = React.useState(initialSchedule); const [meetingDuration, setMeetingDuration] = React.useState( prefsProp?.meetingDuration ?? "30" ); const [schedulingBuffer, setSchedulingBuffer] = React.useState( prefsProp?.schedulingBuffer ?? "0" ); const [maxSlotsPerDay, setMaxSlotsPerDay] = React.useState( prefsProp?.maxSlotsPerDay ?? "8" ); const [timezone, setTimezone] = React.useState( prefsProp?.timezone ?? "Australia/Sydney" ); const [defaultMeetingPlatform, setDefaultMeetingPlatform] = React.useState< "google-meet" | "microsoft-teams" | "any" >(prefsProp?.defaultMeetingPlatform ?? "any"); const [timeOffEntries, setTimeOffEntries] = React.useState( () => { const holidays = publicHolidaysProp ?? AU_PUBLIC_HOLIDAYS_2026; const holidayDates = new Set(holidays.map((h) => h.date)); const entries: TimeOffEntry[] = holidays.map((h) => ({ ...h, enabled: true, isHoliday: true, })); blockedDatesProp?.forEach((b) => { if (!holidayDates.has(b.date)) { entries.push({ date: b.date, label: b.label, enabled: true, isHoliday: false, }); } }); return entries.sort((a, b) => a.date.localeCompare(b.date)); } ); const [addMoreOpen, setAddMoreOpen] = React.useState(false); // --------------------------------------------------------------------------- // Auto-save effects // // Each effect uses its own mount-guard ref. A shared ref would break because // React runs effects in declaration order within the same flush — the first // effect would flip the ref to `true`, causing later effects to skip their // own guard and fire onSave/onBlockedDatesChange on mount. // --------------------------------------------------------------------------- const currentPrefs = React.useMemo( () => ({ meetingDuration, schedulingBuffer, maxSlotsPerDay, timezone, defaultMeetingPlatform, }), [ meetingDuration, schedulingBuffer, maxSlotsPerDay, timezone, defaultMeetingPlatform, ] ); const saveGuard = React.useRef(false); const timeOffGuard = React.useRef(false); const prefsChangeGuard = React.useRef(false); React.useEffect(() => { if (!saveGuard.current) { saveGuard.current = true; return; } onSave?.(schedule, currentPrefs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [schedule, currentPrefs]); React.useEffect(() => { if (!prefsChangeGuard.current) { prefsChangeGuard.current = true; return; } onPrefsChange?.(currentPrefs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPrefs]); React.useEffect(() => { if (!timeOffGuard.current) { timeOffGuard.current = true; return; } const blocked = timeOffEntries .filter((e) => e.enabled) .map(({ date, label, timeStart, timeEnd }) => ({ date, label, timeStart, timeEnd, })); onBlockedDatesChange?.(blocked); // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeOffEntries]); // --------------------------------------------------------------------------- // Weekly schedule handlers // --------------------------------------------------------------------------- const toggleDay = (index: number) => { setSchedule((prev) => prev.map((d, i) => (i === index ? { ...d, enabled: !d.enabled } : d)) ); }; const updateTime = ( index: number, field: "startTime" | "endTime", value: string ) => { setSchedule((prev) => prev.map((d, i) => (i === index ? { ...d, [field]: value } : d)) ); }; // --------------------------------------------------------------------------- // Time Off handlers // --------------------------------------------------------------------------- const toggleTimeOff = (date: string, enabled: boolean) => { setTimeOffEntries((prev) => prev.map((e) => (e.date === date ? { ...e, enabled } : e)) ); }; const removeCustomDate = (date: string) => { setTimeOffEntries((prev) => prev.filter((e) => e.date !== date)); }; const handleAddMore = (entry: AppointmentBlockedDate) => { if (timeOffEntries.some((e) => e.date === entry.date)) return; setTimeOffEntries((prev) => [ ...prev, { date: entry.date, label: entry.label, enabled: true, isHoliday: false, timeStart: entry.timeStart, timeEnd: entry.timeEnd, }, ].sort((a, b) => a.date.localeCompare(b.date)) ); }; return (
Weekly Availability Booking Preferences Time Off
{/* Tab 1: Weekly Availability */}
{schedule.map((day, index) => (
toggleDay(index)} />
{day.enabled ? (
updateTime(index, "startTime", v)} /> to updateTime(index, "endTime", v)} />
) : (

Unavailable

)}
))}
{/* Tab 2: Booking Preferences */}
{/* Tab 3: Time Off */}

Toggle dates when you are unavailable. Clients cannot book on switched-on dates.

{timeOffEntries.map((entry) => { const formattedDate = formatDateWithWeekday(entry.date); const hasTimeRange = entry.timeStart && entry.timeEnd; return (
toggleTimeOff(entry.date, v)} />

{entry.label ? formattedDate : null} {hasTimeRange && ( <> {entry.label ? " · " : ""} {timeLabel(entry.timeStart!)} –{" "} {timeLabel(entry.timeEnd!)} )}

{!entry.isHoliday && ( )}
); })}
); }