"use client"; import { useEffect, useState, useTransition } from "react"; import { getAvailableDatesAction, getAvailableTimeSlotsAction, } from "@/features/booking/actions/availability"; import { cancelBookingAction, createBookingAction, rescheduleBookingAction, } from "@/features/booking/actions/booking"; import { checkInAction, completeAppointmentAction, listAppointmentsAction, markNoShowAction, startAppointmentAction, } from "@/features/booking/actions/appointment"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { MonthCalendar } from "@/components/ui/month-calendar"; import { Select } from "@/components/ui/select"; import { StatusPill } from "@/components/ui/status-pill"; import type { AppointmentStatus } from "@/types/domain"; interface Service { id: string; name: string; durationMinutes: number; price: string; } interface Staff { id: string; name: string; } interface TimeSlot { startMinutes: number; startAt: string; endAt: string; staffOptions: string[]; } /** * Local calendar-date components, not `.toISOString().slice(0, 10)` — that * converts to UTC first, which silently shifts the date back a day for * anyone in a timezone ahead of UTC (e.g. the business's own Europe/London * during BST). `date` here is always local midnight from MonthCalendar's * own `new Date(year, month, day)`, so reading the local parts back out is * the correct inverse. */ function toYMD(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } const EMPTY_SET: Set = new Set(); const EMPTY_SLOTS: TimeSlot[] = []; const STATUS_MAP: Record = { PENDING: "pending_approval", CONFIRMED: "confirmed", CHECKED_IN: "arrived", IN_PROGRESS: "in_progress", COMPLETED: "completed", CANCELLED: "cancelled", NO_SHOW: "no_show", }; export function SchedulingTestClient(props: { business: { id: string; name: string; timezone: string }; services: Service[]; staff: Staff[]; customerId: string; }) { const { business, services, staff, customerId } = props; const [isPending, startTransition] = useTransition(); const [selectedServiceIds, setSelectedServiceIds] = useState(services[0] ? [services[0].id] : []); const [selectedStaffId, setSelectedStaffId] = useState("any"); const [month, setMonth] = useState(() => new Date()); const [availableDates, setAvailableDates] = useState>(new Set()); const [selectedDate, setSelectedDate] = useState(); const [slots, setSlots] = useState([]); const [notes, setNotes] = useState(""); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [appointments, setAppointments] = useState([]); const [message, setMessage] = useState(null); const refreshAppointments = () => { listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments); }; useEffect(() => { refreshAppointments(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Available dates for the visible month. When nothing is selected there's // nothing to fetch — `effectiveAvailableDates` below derives the empty // case at render time rather than this effect resetting state // synchronously (which cascades an extra render for no reason). useEffect(() => { if (selectedServiceIds.length === 0) return; const from = new Date(month.getFullYear(), month.getMonth(), 1); const to = new Date(month.getFullYear(), month.getMonth() + 1, 0); getAvailableDatesAction({ businessId: business.id, serviceIds: selectedServiceIds, staffId: selectedStaffId === "any" ? undefined : selectedStaffId, fromDate: toYMD(from), toDate: toYMD(to), }).then((res) => setAvailableDates(new Set(res.dates))); }, [business.id, month, selectedServiceIds, selectedStaffId]); const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates; // Time slots for the selected date — same derive-at-render-time pattern. useEffect(() => { if (!selectedDate || selectedServiceIds.length === 0) return; getAvailableTimeSlotsAction({ businessId: business.id, serviceIds: selectedServiceIds, staffId: selectedStaffId === "any" ? undefined : selectedStaffId, date: toYMD(selectedDate), }).then((res) => setSlots(res.slots)); }, [business.id, selectedDate, selectedServiceIds, selectedStaffId]); const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots; const toggleService = (id: string) => { setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id])); }; const book = (slot: TimeSlot) => { startTransition(async () => { const result = await createBookingAction({ businessId: business.id, customerId, serviceIds: selectedServiceIds, staffId: selectedStaffId === "any" ? undefined : selectedStaffId, startAt: slot.startAt, notes: notes || undefined, }); if (result.success) { setMessage(`Booked: ${result.appointment.id}`); setNotes(""); setSelectedDate(undefined); refreshAppointments(); } else { setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`); } }); }; const cancel = (appointmentId: string) => { startTransition(async () => { const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" }); setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`); refreshAppointments(); }); }; const reschedule = (appointmentId: string) => { const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):"); if (!input) return; startTransition(async () => { const result = await rescheduleBookingAction({ appointmentId, startAt: input }); setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`); refreshAppointments(); }); }; const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => { startTransition(async () => { const result = await fn(appointmentId); setMessage(result.success ? "Status updated" : "Transition rejected"); refreshAppointments(); }); }; return (

Scheduling Engine — Dev Test

{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md

{message && (
{message}
)}

1. Services

{services.map((s) => ( toggleService(s.id)} /> ))}

2. Barber

3. Date

!effectiveAvailableDates.has(toYMD(date))} />

Selectable dates are ones the engine reports as having openings.

{selectedDate && (

4. Time

{effectiveSlots.length === 0 ? (

No available slots this day for this selection.

) : (
{effectiveSlots.map((slot) => ( ))}
)} setNotes(e.target.value)} placeholder="Notes (optional)" className="w-full rounded-lg border border-line px-3 py-2 text-sm" />
)}

Appointments

{appointments.length === 0 &&

None yet.

} {appointments.map((a) => (
{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}

{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}

))}
); }