2026-05-06 23:05:37 +03:30
|
|
|
'use client';
|
|
|
|
|
|
2026-05-17 17:42:59 +03:30
|
|
|
import { useMemo, useState } from 'react';
|
2026-06-20 14:51:43 +03:30
|
|
|
import { useTranslations } from 'next-intl';
|
2026-05-06 23:05:37 +03:30
|
|
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
2026-06-10 19:44:09 +03:30
|
|
|
import {
|
|
|
|
|
SCHEDULE_SLOT_MINUTES,
|
|
|
|
|
appointmentWithinWorkingHours,
|
|
|
|
|
formatMinuteLabel,
|
|
|
|
|
generateHourLabelsInRange,
|
|
|
|
|
generateSlotStarts,
|
|
|
|
|
isSlotWithinWorkingBlocks,
|
|
|
|
|
snapRangeToSlots,
|
|
|
|
|
unionDayBlockRange,
|
|
|
|
|
} from '@/components/staff/workingHours';
|
2026-05-17 17:42:59 +03:30
|
|
|
import {
|
|
|
|
|
computeAppointmentLaneLayouts,
|
|
|
|
|
findOverlapCluster,
|
|
|
|
|
lanePositionStyles,
|
2026-05-18 14:08:07 +03:30
|
|
|
} from '@/components/appointments/appointmentOverlapLayout';
|
2026-07-12 21:08:29 +03:30
|
|
|
import { purposeBannerStyle } from '@/components/appointments/appointmentPurposeStyles';
|
2026-05-17 17:42:59 +03:30
|
|
|
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
2026-06-28 14:49:37 +03:30
|
|
|
import { formatMobileForDisplay } from '@/lib/phone';
|
2026-06-10 19:44:09 +03:30
|
|
|
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
2026-07-07 13:13:04 +03:30
|
|
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
2026-05-06 23:05:37 +03:30
|
|
|
|
2026-06-10 20:04:05 +03:30
|
|
|
const HOUR_PX = 80;
|
2026-06-10 19:44:09 +03:30
|
|
|
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
|
2026-05-06 23:05:37 +03:30
|
|
|
|
2026-06-10 19:44:09 +03:30
|
|
|
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;
|
2026-05-06 23:05:37 +03:30
|
|
|
const start = new Date(apt.startAt);
|
|
|
|
|
const end = new Date(apt.endAt);
|
2026-06-10 19:44:09 +03:30
|
|
|
const clipStart = Math.max(start.getTime(), rangeStartMs);
|
|
|
|
|
const clipEnd = Math.min(end.getTime(), rangeEndMs);
|
2026-05-06 23:05:37 +03:30
|
|
|
if (clipEnd <= clipStart) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-06-10 19:44:09 +03:30
|
|
|
const rangeMs = rangeEndMs - rangeStartMs;
|
|
|
|
|
const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
|
|
|
|
|
const height = ((clipEnd - clipStart) / rangeMs) * 100;
|
2026-05-06 23:05:37 +03:30
|
|
|
return { top: `${top}%`, height: `${height}%` };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:14:00 +03:30
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:44:09 +03:30
|
|
|
function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
|
|
|
|
|
return (durationMin / rangeMinutes) * gridHeight;
|
2026-05-17 17:42:59 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:44:09 +03:30
|
|
|
function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
|
|
|
|
|
const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
|
2026-05-17 17:42:59 +03:30
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-06 23:05:37 +03:30
|
|
|
interface AppointmentScheduleGridProps {
|
|
|
|
|
day: Date;
|
|
|
|
|
providers: AppointmentColumnProvider[];
|
|
|
|
|
appointments: AppointmentRecord[];
|
2026-07-07 13:13:04 +03:30
|
|
|
treatmentCatalog: TreatmentCatalogEntry[];
|
2026-05-06 23:05:37 +03:30
|
|
|
canBook: boolean;
|
2026-06-10 19:44:09 +03:30
|
|
|
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
|
2026-05-08 14:25:44 +03:30
|
|
|
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
2026-06-10 19:44:09 +03:30
|
|
|
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
2026-05-06 23:05:37 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AppointmentScheduleGrid({
|
|
|
|
|
day,
|
|
|
|
|
providers,
|
|
|
|
|
appointments,
|
2026-07-07 13:13:04 +03:30
|
|
|
treatmentCatalog,
|
2026-05-06 23:05:37 +03:30
|
|
|
canBook,
|
|
|
|
|
onSlotClick,
|
2026-05-08 14:25:44 +03:30
|
|
|
onAppointmentClick,
|
2026-06-10 19:44:09 +03:30
|
|
|
onAppointmentOutsideHours,
|
2026-05-06 23:05:37 +03:30
|
|
|
}: AppointmentScheduleGridProps) {
|
2026-06-20 14:51:43 +03:30
|
|
|
const t = useTranslations('appointments');
|
2026-05-17 17:42:59 +03:30
|
|
|
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
|
|
|
|
|
|
2026-06-10 19:44:09 +03:30
|
|
|
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;
|
|
|
|
|
|
2026-05-17 17:42:59 +03:30
|
|
|
const laneLayoutsByProvider = useMemo(() => {
|
|
|
|
|
const map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>();
|
|
|
|
|
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,
|
2026-06-10 19:44:09 +03:30
|
|
|
provider: AppointmentColumnProvider,
|
2026-05-17 17:42:59 +03:30
|
|
|
providerAppointments: AppointmentRecord[],
|
|
|
|
|
anchor: HTMLElement,
|
|
|
|
|
) {
|
2026-06-10 19:44:09 +03:30
|
|
|
if (
|
|
|
|
|
provider.hasWorkingHours &&
|
|
|
|
|
provider.dayBlocks.length > 0 &&
|
|
|
|
|
!appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks)
|
|
|
|
|
) {
|
|
|
|
|
onAppointmentOutsideHours?.(apt);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:42:59 +03:30
|
|
|
const cluster = findOverlapCluster(apt.id, providerAppointments);
|
|
|
|
|
if (cluster.length > 1) {
|
|
|
|
|
setOverlapPopover({
|
|
|
|
|
appointments: cluster,
|
|
|
|
|
anchorRect: anchor.getBoundingClientRect(),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-11 22:32:37 +03:30
|
|
|
if (!canBook) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-17 17:42:59 +03:30
|
|
|
onAppointmentClick?.(apt);
|
|
|
|
|
}
|
2026-05-06 23:05:37 +03:30
|
|
|
|
|
|
|
|
if (providers.length === 0) {
|
|
|
|
|
return (
|
2026-06-20 14:51:43 +03:30
|
|
|
<div className="surface-card p-6 text-sm text-text-muted">{t('noProviders')}</div>
|
2026-05-06 23:05:37 +03:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:44:09 +03:30
|
|
|
if (!snappedRange || slotStarts.length === 0) {
|
|
|
|
|
return (
|
2026-06-20 14:51:43 +03:30
|
|
|
<div className="surface-card p-6 text-sm text-text-muted">{t('noWorkingHours')}</div>
|
2026-06-10 19:44:09 +03:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:05:37 +03:30
|
|
|
return (
|
2026-05-17 17:42:59 +03:30
|
|
|
<>
|
|
|
|
|
<div className="surface-card overflow-x-auto">
|
|
|
|
|
<div className="min-w-[640px]">
|
|
|
|
|
<div className="flex border-b border-border">
|
|
|
|
|
<div className="w-14 flex-shrink-0" />
|
2026-05-06 23:05:37 +03:30
|
|
|
{providers.map((p) => (
|
|
|
|
|
<div
|
|
|
|
|
key={p.userId}
|
2026-05-17 17:42:59 +03:30
|
|
|
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
|
2026-05-06 23:05:37 +03:30
|
|
|
>
|
2026-05-17 17:42:59 +03:30
|
|
|
{p.name}
|
2026-06-10 19:44:09 +03:30
|
|
|
{!p.hasWorkingHours && (
|
|
|
|
|
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
|
2026-06-20 14:51:43 +03:30
|
|
|
{t('noHoursSet')}
|
2026-06-10 19:44:09 +03:30
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
|
|
|
|
|
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
|
2026-06-20 14:51:43 +03:30
|
|
|
{t('offToday')}
|
2026-06-10 19:44:09 +03:30
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-06 23:05:37 +03:30
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-17 17:42:59 +03:30
|
|
|
|
|
|
|
|
<div className="flex">
|
2026-06-10 19:44:09 +03:30
|
|
|
<div
|
|
|
|
|
className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
|
|
|
|
|
style={{ height: gridHeight }}
|
|
|
|
|
>
|
|
|
|
|
{hourLabels.map((hour) => {
|
|
|
|
|
const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
|
|
|
|
|
const height = (60 / rangeMinutes) * gridHeight;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={hour}
|
|
|
|
|
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
|
|
|
|
|
style={{ top, height }}
|
|
|
|
|
>
|
|
|
|
|
{formatMinuteLabel(hour * 60)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-17 17:42:59 +03:30
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 flex min-w-0">
|
|
|
|
|
{providers.map((p) => {
|
|
|
|
|
const providerAppointments = appointments.filter(
|
|
|
|
|
(a) => a.providerUserId === p.userId,
|
|
|
|
|
);
|
|
|
|
|
const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map();
|
2026-06-10 19:44:09 +03:30
|
|
|
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
|
2026-05-17 17:42:59 +03:30
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={p.userId}
|
|
|
|
|
className="flex-1 min-w-[130px] border-l border-border relative"
|
|
|
|
|
style={{ height: gridHeight }}
|
|
|
|
|
>
|
2026-06-10 19:44:09 +03:30
|
|
|
{slotStarts.map((slotStartMinute, index) => {
|
|
|
|
|
const slotActive =
|
|
|
|
|
!columnFullyDisabled &&
|
|
|
|
|
isSlotWithinWorkingBlocks(
|
|
|
|
|
slotStartMinute,
|
|
|
|
|
SCHEDULE_SLOT_MINUTES,
|
|
|
|
|
p.dayBlocks,
|
|
|
|
|
);
|
|
|
|
|
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
|
|
|
|
|
|
2026-05-17 17:42:59 +03:30
|
|
|
return (
|
|
|
|
|
<button
|
2026-06-10 19:44:09 +03:30
|
|
|
key={`${p.userId}-${slotStartMinute}`}
|
2026-05-17 17:42:59 +03:30
|
|
|
type="button"
|
|
|
|
|
disabled={slotDisabled}
|
|
|
|
|
title={
|
2026-06-10 19:44:09 +03:30
|
|
|
columnFullyDisabled
|
|
|
|
|
? p.hasWorkingHours
|
2026-06-20 14:51:43 +03:30
|
|
|
? t('slotOffToday')
|
|
|
|
|
: t('slotHoursNotConfigured')
|
2026-06-10 19:44:09 +03:30
|
|
|
: !slotActive
|
2026-06-20 14:51:43 +03:30
|
|
|
? t('slotOutsideHours')
|
2026-06-10 19:44:09 +03:30
|
|
|
: slotDisabled
|
2026-06-20 14:51:43 +03:30
|
|
|
? t('slotCannotCreate')
|
|
|
|
|
: t('slotBookAt', {
|
|
|
|
|
time: formatMinuteLabel(slotStartMinute),
|
|
|
|
|
})
|
2026-05-17 17:42:59 +03:30
|
|
|
}
|
|
|
|
|
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
|
|
|
|
slotDisabled
|
2026-06-10 19:44:09 +03:30
|
|
|
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
2026-05-17 17:42:59 +03:30
|
|
|
: 'hover:bg-primary/8 cursor-pointer'
|
|
|
|
|
}`}
|
2026-06-10 19:44:09 +03:30
|
|
|
style={{ top: index * SLOT_PX, height: SLOT_PX }}
|
|
|
|
|
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
|
2026-05-17 17:42:59 +03:30
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{providerAppointments.map((apt) => {
|
2026-06-10 19:44:09 +03:30
|
|
|
const pos = layoutBlockInRange(
|
|
|
|
|
apt,
|
|
|
|
|
day,
|
|
|
|
|
snappedRange.startMinute,
|
|
|
|
|
snappedRange.endMinute,
|
|
|
|
|
);
|
2026-05-17 17:42:59 +03:30
|
|
|
if (!pos) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const lane = laneLayouts.get(apt.id) ?? { lane: 0, laneCount: 1 };
|
|
|
|
|
const lanePos = lanePositionStyles(lane.lane, lane.laneCount);
|
|
|
|
|
const durationMin = appointmentDurationMinutes(apt);
|
|
|
|
|
const clusterSize = findOverlapCluster(apt.id, providerAppointments).length;
|
|
|
|
|
const isUnderOneHour = durationMin < 60;
|
2026-06-10 19:44:09 +03:30
|
|
|
const outsideHours =
|
|
|
|
|
p.hasWorkingHours &&
|
|
|
|
|
p.dayBlocks.length > 0 &&
|
|
|
|
|
!appointmentWithinWorkingHours(
|
|
|
|
|
new Date(apt.startAt),
|
|
|
|
|
new Date(apt.endAt),
|
|
|
|
|
p.dayBlocks,
|
|
|
|
|
);
|
2026-05-17 17:42:59 +03:30
|
|
|
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
|
|
|
|
|
const bannerTitle = [
|
|
|
|
|
patientName,
|
2026-06-20 14:51:43 +03:30
|
|
|
outsideHours ? t('outsideHoursBlocked') : null,
|
|
|
|
|
clusterSize > 1
|
|
|
|
|
? t('overlappingChoose', { count: clusterSize })
|
|
|
|
|
: null,
|
2026-06-28 14:49:37 +03:30
|
|
|
!isUnderOneHour && apt.patient.mobile
|
|
|
|
|
? formatMobileForDisplay(apt.patient.mobile)
|
|
|
|
|
: null,
|
2026-05-17 17:42:59 +03:30
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' · ');
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
key={apt.id}
|
|
|
|
|
onClick={(e) =>
|
2026-06-10 19:44:09 +03:30
|
|
|
handleAppointmentBannerClick(
|
|
|
|
|
apt,
|
|
|
|
|
p,
|
|
|
|
|
providerAppointments,
|
|
|
|
|
e.currentTarget,
|
|
|
|
|
)
|
2026-05-17 17:42:59 +03:30
|
|
|
}
|
2026-07-12 22:07:11 +03:30
|
|
|
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
|
2026-06-10 19:44:09 +03:30
|
|
|
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
|
|
|
|
} ${
|
2026-05-17 17:42:59 +03:30
|
|
|
isUnderOneHour
|
|
|
|
|
? 'items-center justify-center px-0.5 py-0'
|
2026-07-12 22:07:11 +03:30
|
|
|
: 'flex-col items-center justify-center gap-0.5 px-1 py-0.5'
|
2026-05-17 17:42:59 +03:30
|
|
|
}`}
|
|
|
|
|
style={{
|
|
|
|
|
top: pos.top,
|
|
|
|
|
height: pos.height,
|
|
|
|
|
left: lanePos.left,
|
|
|
|
|
width: lanePos.width,
|
2026-07-07 13:13:04 +03:30
|
|
|
...purposeBannerStyle(apt.purpose, treatmentCatalog),
|
2026-05-17 17:42:59 +03:30
|
|
|
}}
|
|
|
|
|
title={bannerTitle}
|
|
|
|
|
>
|
|
|
|
|
<span
|
2026-07-12 22:07:11 +03:30
|
|
|
className={`block w-full truncate pointer-events-none text-center font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
|
2026-05-17 17:42:59 +03:30
|
|
|
>
|
|
|
|
|
{patientName}
|
|
|
|
|
</span>
|
|
|
|
|
{!isUnderOneHour &&
|
2026-06-28 14:49:37 +03:30
|
|
|
apt.patient.mobile &&
|
2026-05-17 17:42:59 +03:30
|
|
|
lane.laneCount === 1 && (
|
2026-07-12 22:07:11 +03:30
|
|
|
<span className="block w-full truncate pointer-events-none text-center text-[10px] leading-tight opacity-90">
|
2026-06-28 14:49:37 +03:30
|
|
|
{formatMobileForDisplay(apt.patient.mobile)}
|
2026-05-17 17:42:59 +03:30
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{!isUnderOneHour && clusterSize > 1 && (
|
2026-07-12 22:07:11 +03:30
|
|
|
<span className="block w-full truncate pointer-events-none text-center text-[9px] leading-tight opacity-75">
|
2026-06-20 14:51:43 +03:30
|
|
|
{t('overlapping', { count: clusterSize })}
|
2026-05-17 17:42:59 +03:30
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-06 23:05:37 +03:30
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-17 17:42:59 +03:30
|
|
|
|
|
|
|
|
{overlapPopover && (
|
|
|
|
|
<AppointmentOverlapPopover
|
|
|
|
|
appointments={overlapPopover.appointments}
|
2026-07-07 13:13:04 +03:30
|
|
|
treatmentCatalog={treatmentCatalog}
|
2026-05-17 17:42:59 +03:30
|
|
|
anchorRect={overlapPopover.anchorRect}
|
2026-06-10 19:44:09 +03:30
|
|
|
onSelect={(apt) => {
|
2026-07-11 22:32:37 +03:30
|
|
|
if (!canBook) {
|
|
|
|
|
setOverlapPopover(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-10 19:44:09 +03:30
|
|
|
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);
|
|
|
|
|
}}
|
2026-05-17 17:42:59 +03:30
|
|
|
onClose={() => setOverlapPopover(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-05-06 23:05:37 +03:30
|
|
|
);
|
|
|
|
|
}
|