Files
dyolink/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx

111 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}