111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef } from 'react';
|
||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||
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>
|
||
);
|
||
}
|