2026-05-17 17:42:59 +03:30
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef } from 'react';
|
2026-05-18 12:31:21 +03:30
|
|
|
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
2026-05-17 17:42:59 +03:30
|
|
|
|
import {
|
|
|
|
|
|
APPOINTMENT_PURPOSE_LABEL,
|
|
|
|
|
|
purposeStyle,
|
|
|
|
|
|
} from '@/components/ui/appointments/appointmentPurposeStyles';
|
|
|
|
|
|
import type { AppointmentRecord } from '@/types/appointment';
|
|
|
|
|
|
|
|
|
|
|
|
type AppointmentOverlapPopoverProps = {
|
|
|
|
|
|
appointments: AppointmentRecord[];
|
|
|
|
|
|
anchorRect: DOMRect;
|
|
|
|
|
|
onSelect: (appointment: AppointmentRecord) => void;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function formatTimeRange(apt: AppointmentRecord): string {
|
|
|
|
|
|
const start = new Date(apt.startAt);
|
|
|
|
|
|
const end = new Date(apt.endAt);
|
|
|
|
|
|
const opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
|
|
|
|
|
|
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function AppointmentOverlapPopover({
|
|
|
|
|
|
appointments,
|
|
|
|
|
|
anchorRect,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: AppointmentOverlapPopoverProps) {
|
|
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
function onPointerDown(event: MouseEvent) {
|
|
|
|
|
|
if (!panelRef.current?.contains(event.target as Node)) {
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
|
|
|
|
document.addEventListener('keydown', onKeyDown);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
|
|
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
|
|
|
|
|
|
const sorted = [...appointments].sort(
|
|
|
|
|
|
(a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const viewportPadding = 12;
|
|
|
|
|
|
const panelWidth = Math.min(320, window.innerWidth - viewportPadding * 2);
|
|
|
|
|
|
let top = anchorRect.bottom + 8;
|
|
|
|
|
|
let left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
|
|
|
|
|
|
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - panelWidth - viewportPadding));
|
|
|
|
|
|
const estimatedHeight = 56 + sorted.length * 52;
|
|
|
|
|
|
if (top + estimatedHeight > window.innerHeight - viewportPadding) {
|
|
|
|
|
|
top = Math.max(viewportPadding, anchorRect.top - estimatedHeight - 8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 z-[65] pointer-events-none" aria-hidden>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="overlap-popover-title"
|
|
|
|
|
|
className="pointer-events-auto fixed rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-xl"
|
|
|
|
|
|
style={{ top, left, width: panelWidth }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
|
|
|
|
<h3 id="overlap-popover-title" className="text-sm font-semibold text-text-primary pr-2">
|
|
|
|
|
|
Overlapping appointments ({sorted.length})
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<DialogCloseButton onClick={onClose} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul className="space-y-1.5 max-h-[min(16rem,50vh)] overflow-y-auto">
|
|
|
|
|
|
{sorted.map((apt) => {
|
|
|
|
|
|
const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={apt.id}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onSelect(apt);
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left transition-colors hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${purposeStyle(apt.purpose)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="text-xs font-medium truncate">
|
|
|
|
|
|
{apt.patient.firstName} {apt.patient.lastName}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p>
|
|
|
|
|
|
<p className="text-[10px] opacity-80 truncate">
|
|
|
|
|
|
{APPOINTMENT_PURPOSE_LABEL[purpose] ?? apt.purpose}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|