bugfix: overlapped appointments now made possible. selection popup added for overlapped banners.

This commit is contained in:
2026-05-17 17:42:59 +03:30
parent e119d02759
commit 8278fd9012
6 changed files with 553 additions and 94 deletions

View File

@@ -0,0 +1,110 @@
'use client';
import { useEffect, useRef } from 'react';
import { DialogCloseButton } from '@/components/ui/common/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>
);
}