'use client'; import { useMemo, useState } from 'react'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { formatHourLabel } from '@/lib/appointmentTime'; import { computeAppointmentLaneLayouts, findOverlapCluster, lanePositionStyles, } from '@/lib/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; const HOUR_PX = 40; const HOURS = Array.from({ length: 24 }, (_, i) => i); function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null { const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0); const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0); const start = new Date(apt.startAt); const end = new Date(apt.endAt); const ms = dayEnd.getTime() - dayStart.getTime(); const clipStart = Math.max(start.getTime(), dayStart.getTime()); const clipEnd = Math.min(end.getTime(), dayEnd.getTime()); if (clipEnd <= clipStart) { return null; } const top = ((clipStart - dayStart.getTime()) / ms) * 100; const height = ((clipEnd - clipStart) / ms) * 100; return { top: `${top}%`, height: `${height}%` }; } function appointmentDurationMinutes(apt: AppointmentRecord): number { const start = new Date(apt.startAt).getTime(); const end = new Date(apt.endAt).getTime(); return Math.max(0, Math.round((end - start) / 60_000)); } function appointmentBannerHeightPx(durationMin: number): number { return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX; } function shortBannerNameClass(durationMin: number): string { const heightPx = appointmentBannerHeightPx(durationMin); if (heightPx < 18) { return 'text-[8px] leading-none'; } if (durationMin < 60) { return 'text-[9px] leading-none'; } return 'text-[11px] leading-tight'; } type OverlapPopoverState = { appointments: AppointmentRecord[]; anchorRect: DOMRect; }; interface AppointmentScheduleGridProps { day: Date; providers: AppointmentColumnProvider[]; appointments: AppointmentRecord[]; canBook: boolean; onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; } export function AppointmentScheduleGrid({ day, providers, appointments, canBook, onSlotClick, onAppointmentClick, }: AppointmentScheduleGridProps) { const gridHeight = HOURS.length * HOUR_PX; const [overlapPopover, setOverlapPopover] = useState(null); const laneLayoutsByProvider = useMemo(() => { const map = new Map>(); for (const provider of providers) { const providerApts = appointments.filter((a) => a.providerUserId === provider.userId); map.set(provider.userId, computeAppointmentLaneLayouts(providerApts)); } return map; }, [appointments, providers]); function handleAppointmentBannerClick( apt: AppointmentRecord, providerAppointments: AppointmentRecord[], anchor: HTMLElement, ) { const cluster = findOverlapCluster(apt.id, providerAppointments); if (cluster.length > 1) { setOverlapPopover({ appointments: cluster, anchorRect: anchor.getBoundingClientRect(), }); return; } onAppointmentClick?.(apt); } if (providers.length === 0) { return (
No providers available. Add staff with treatment edit access to see columns here.
); } return ( <>
{providers.map((p) => (
{p.name}
))}
{HOURS.map((h) => (
{formatHourLabel(h)}
))}
{providers.map((p) => { const providerAppointments = appointments.filter( (a) => a.providerUserId === p.userId, ); const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); return (
{HOURS.map((h) => { const slotDisabled = !canBook; return ( ); })}
); })}
{overlapPopover && ( onAppointmentClick?.(apt)} onClose={() => setOverlapPopover(null)} /> )} ); }