'use client'; import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { SCHEDULE_SLOT_MINUTES, appointmentWithinWorkingHours, formatMinuteLabel, generateHourLabelsInRange, generateSlotStarts, isSlotWithinWorkingBlocks, snapRangeToSlots, unionDayBlockRange, } from '@/components/staff/workingHours'; import { computeAppointmentLaneLayouts, findOverlapCluster, lanePositionStyles, } from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; const HOUR_PX = 80; const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; function layoutBlockInRange( apt: AppointmentRecord, day: Date, rangeStartMinute: number, rangeEndMinute: number, ): { top: string; height: string } | null { const dayStart = startOfLocalDay(day); const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000; const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000; const start = new Date(apt.startAt); const end = new Date(apt.endAt); const clipStart = Math.max(start.getTime(), rangeStartMs); const clipEnd = Math.min(end.getTime(), rangeEndMs); if (clipEnd <= clipStart) { return null; } const rangeMs = rangeEndMs - rangeStartMs; const top = ((clipStart - rangeStartMs) / rangeMs) * 100; const height = ((clipEnd - clipStart) / rangeMs) * 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, rangeMinutes: number, gridHeight: number): number { return (durationMin / rangeMinutes) * gridHeight; } function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string { const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight); 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: (startMinute: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void; } export function AppointmentScheduleGrid({ day, providers, appointments, canBook, onSlotClick, onAppointmentClick, onAppointmentOutsideHours, }: AppointmentScheduleGridProps) { const t = useTranslations('appointments'); const [overlapPopover, setOverlapPopover] = useState(null); const visibleRange = useMemo(() => { const activeDayBlocks = providers .filter((p) => p.hasWorkingHours && p.dayBlocks.length > 0) .map((p) => p.dayBlocks); return unionDayBlockRange(activeDayBlocks); }, [providers]); const snappedRange = useMemo(() => { if (!visibleRange) return null; return snapRangeToSlots(visibleRange.startMinute, visibleRange.endMinute, SCHEDULE_SLOT_MINUTES); }, [visibleRange]); const slotStarts = useMemo(() => { if (!snappedRange) return []; return generateSlotStarts( snappedRange.startMinute, snappedRange.endMinute, SCHEDULE_SLOT_MINUTES, ); }, [snappedRange]); const hourLabels = useMemo(() => { if (!snappedRange) return []; return generateHourLabelsInRange(snappedRange.startMinute, snappedRange.endMinute); }, [snappedRange]); const gridHeight = slotStarts.length * SLOT_PX; const rangeMinutes = snappedRange ? snappedRange.endMinute - snappedRange.startMinute : 0; 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, provider: AppointmentColumnProvider, providerAppointments: AppointmentRecord[], anchor: HTMLElement, ) { if ( provider.hasWorkingHours && provider.dayBlocks.length > 0 && !appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks) ) { onAppointmentOutsideHours?.(apt); return; } const cluster = findOverlapCluster(apt.id, providerAppointments); if (cluster.length > 1) { setOverlapPopover({ appointments: cluster, anchorRect: anchor.getBoundingClientRect(), }); return; } onAppointmentClick?.(apt); } if (providers.length === 0) { return (
{t('noProviders')}
); } if (!snappedRange || slotStarts.length === 0) { return (
{t('noWorkingHours')}
); } return ( <>
{providers.map((p) => (
{p.name} {!p.hasWorkingHours && ( {t('noHoursSet')} )} {p.hasWorkingHours && p.dayBlocks.length === 0 && ( {t('offToday')} )}
))}
{hourLabels.map((hour) => { const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight; const height = (60 / rangeMinutes) * gridHeight; return (
{formatMinuteLabel(hour * 60)}
); })}
{providers.map((p) => { const providerAppointments = appointments.filter( (a) => a.providerUserId === p.userId, ); const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0; return (
{slotStarts.map((slotStartMinute, index) => { const slotActive = !columnFullyDisabled && isSlotWithinWorkingBlocks( slotStartMinute, SCHEDULE_SLOT_MINUTES, p.dayBlocks, ); const slotDisabled = !canBook || columnFullyDisabled || !slotActive; return ( ); })}
); })}
{overlapPopover && ( { const provider = providers.find((p) => p.userId === apt.providerUserId); if ( provider && provider.hasWorkingHours && provider.dayBlocks.length > 0 && !appointmentWithinWorkingHours( new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks, ) ) { onAppointmentOutsideHours?.(apt); setOverlapPopover(null); return; } onAppointmentClick?.(apt); }} onClose={() => setOverlapPopover(null)} /> )} ); }