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>
);
}

View File

@@ -1,8 +1,15 @@
'use client';
import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime';
import {
computeAppointmentLaneLayouts,
findOverlapCluster,
lanePositionStyles,
} from '@/lib/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -29,6 +36,26 @@ function appointmentDurationMinutes(apt: AppointmentRecord): number {
return Math.max(0, Math.round((end - start) / 60_000));
}
function appointmentBannerHeightPx(durationMin: number): number {
return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX;
}
function shortBannerNameClass(durationMin: number): string {
const heightPx = appointmentBannerHeightPx(durationMin);
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;
};
interface AppointmentScheduleGridProps {
day: Date;
providers: AppointmentColumnProvider[];
@@ -47,6 +74,32 @@ export function AppointmentScheduleGrid({
onAppointmentClick,
}: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX;
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
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,
providerAppointments: AppointmentRecord[],
anchor: HTMLElement,
) {
const cluster = findOverlapCluster(apt.id, providerAppointments);
if (cluster.length > 1) {
setOverlapPopover({
appointments: cluster,
anchorRect: anchor.getBoundingClientRect(),
});
return;
}
onAppointmentClick?.(apt);
}
if (providers.length === 0) {
return (
@@ -57,93 +110,145 @@ export function AppointmentScheduleGrid({
}
return (
<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" />
{providers.map((p) => (
<div
key={p.userId}
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
>
{p.name}
</div>
))}
</div>
<div className="flex">
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
{HOURS.map((h) => (
<div
key={h}
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ height: HOUR_PX }}
>
{formatHourLabel(h)}
</div>
))}
</div>
<div className="flex-1 flex min-w-0">
<>
<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" />
{providers.map((p) => (
<div
key={p.userId}
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
>
{HOURS.map((h) => {
const slotDisabled = !canBook;
return (
<button
key={h}
type="button"
disabled={slotDisabled}
title={
slotDisabled ? 'You cannot create appointments' : `Book ${formatHourLabel(h)}`
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: h * HOUR_PX, height: HOUR_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)}
/>
);
})}
{appointments
.filter((a) => a.providerUserId === p.userId)
.map((apt) => {
const pos = layoutBlock(apt, day);
if (!pos) {
return null;
}
const durationMin = appointmentDurationMinutes(apt);
return (
<button
type="button"
key={apt.id}
onClick={() => onAppointmentClick?.(apt)}
className={`absolute left-0.5 right-0.5 min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex flex-col justify-start px-1.5 py-0.5 text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35`}
style={{ top: pos.top, height: pos.height }}
>
<p className="text-[11px] font-medium leading-tight truncate pointer-events-none">
{apt.patient.firstName} {apt.patient.lastName}
</p>
{durationMin >= 30 && apt.patient.phone && (
<p className="text-[10px] opacity-90 truncate pointer-events-none">
{apt.patient.phone}
</p>
)}
</button>
);
})}
{p.name}
</div>
))}
</div>
<div className="flex">
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
{HOURS.map((h) => (
<div
key={h}
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ height: HOUR_PX }}
>
{formatHourLabel(h)}
</div>
))}
</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();
return (
<div
key={p.userId}
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
>
{HOURS.map((h) => {
const slotDisabled = !canBook;
return (
<button
key={h}
type="button"
disabled={slotDisabled}
title={
slotDisabled
? 'You cannot create appointments'
: `Book ${formatHourLabel(h)}`
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: h * HOUR_PX, height: HOUR_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)}
/>
);
})}
{providerAppointments.map((apt) => {
const pos = layoutBlock(apt, day);
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;
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const bannerTitle = [
patientName,
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
]
.filter(Boolean)
.join(' · ');
return (
<button
type="button"
key={apt.id}
onClick={(e) =>
handleAppointmentBannerClick(apt, providerAppointments, e.currentTarget)
}
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
isUnderOneHour
? 'items-center justify-center px-0.5 py-0'
: 'flex-col justify-start gap-0.5 px-1 py-0.5'
}`}
style={{
top: pos.top,
height: pos.height,
left: lanePos.left,
width: lanePos.width,
}}
title={bannerTitle}
>
<span
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin)}`}
>
{patientName}
</span>
{!isUnderOneHour &&
apt.patient.phone &&
lane.laneCount === 1 && (
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
{apt.patient.phone}
</span>
)}
{!isUnderOneHour && clusterSize > 1 && (
<span className="block w-full truncate pointer-events-none text-[9px] leading-tight opacity-75">
{clusterSize} overlapping
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
{overlapPopover && (
<AppointmentOverlapPopover
appointments={overlapPopover.appointments}
anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => onAppointmentClick?.(apt)}
onClose={() => setOverlapPopover(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,173 @@
import type { AppointmentRecord } from '@/types/appointment';
export type AppointmentTimedInterval = {
id: string;
start: number;
end: number;
};
export type AppointmentLaneLayout = {
lane: number;
/** Max concurrent overlaps in this appointment's cluster (column count). */
laneCount: number;
};
function intervalsOverlap(a: AppointmentTimedInterval, b: AppointmentTimedInterval): boolean {
return a.start < b.end && b.start < a.end;
}
export function toTimedInterval(apt: AppointmentRecord): AppointmentTimedInterval {
return {
id: apt.id,
start: new Date(apt.startAt).getTime(),
end: new Date(apt.endAt).getTime(),
};
}
/** Connected overlap component containing `appointmentId`. */
export function findOverlapCluster(
appointmentId: string,
appointments: AppointmentRecord[],
): AppointmentRecord[] {
const byId = new Map(appointments.map((a) => [a.id, a]));
if (!byId.has(appointmentId)) {
return [];
}
const timed = appointments.map(toTimedInterval);
const clusterIds = new Set<string>([appointmentId]);
let changed = true;
while (changed) {
changed = false;
for (const interval of timed) {
if (clusterIds.has(interval.id)) {
continue;
}
for (const memberId of clusterIds) {
const member = timed.find((t) => t.id === memberId);
if (member && intervalsOverlap(interval, member)) {
clusterIds.add(interval.id);
changed = true;
break;
}
}
}
}
return appointments.filter((a) => clusterIds.has(a.id));
}
function maxConcurrentCount(intervals: AppointmentTimedInterval[]): number {
if (intervals.length === 0) {
return 0;
}
type Point = { time: number; delta: number };
const points: Point[] = [];
for (const interval of intervals) {
points.push({ time: interval.start, delta: 1 });
points.push({ time: interval.end, delta: -1 });
}
points.sort((a, b) => a.time - b.time || a.delta - b.delta);
let current = 0;
let max = 0;
for (const point of points) {
current += point.delta;
max = Math.max(max, current);
}
return max;
}
function assignGreedyLanes(intervals: AppointmentTimedInterval[]): Map<string, number> {
const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
const laneEndTimes: number[] = [];
const laneById = new Map<string, number>();
for (const interval of sorted) {
let lane = laneEndTimes.findIndex((end) => end <= interval.start);
if (lane === -1) {
lane = laneEndTimes.length;
laneEndTimes.push(interval.end);
} else {
laneEndTimes[lane] = interval.end;
}
laneById.set(interval.id, lane);
}
return laneById;
}
function buildClusters(intervals: AppointmentTimedInterval[]): AppointmentTimedInterval[][] {
const visited = new Set<string>();
const clusters: AppointmentTimedInterval[][] = [];
for (const seed of intervals) {
if (visited.has(seed.id)) {
continue;
}
const cluster: AppointmentTimedInterval[] = [];
const queue = [seed];
visited.add(seed.id);
while (queue.length > 0) {
const current = queue.pop()!;
cluster.push(current);
for (const other of intervals) {
if (!visited.has(other.id) && intervalsOverlap(current, other)) {
visited.add(other.id);
queue.push(other);
}
}
}
clusters.push(cluster);
}
return clusters;
}
/**
* Assigns side-by-side lanes per provider column (Google Calendar style).
*/
export function computeAppointmentLaneLayouts(
appointments: AppointmentRecord[],
): Map<string, AppointmentLaneLayout> {
const timed = appointments.map(toTimedInterval);
if (timed.length === 0) {
return new Map();
}
const layouts = new Map<string, AppointmentLaneLayout>();
const clusters = buildClusters(timed);
for (const cluster of clusters) {
const laneCount = Math.max(1, maxConcurrentCount(cluster));
const greedyLanes = assignGreedyLanes(cluster);
const usedLaneIndices = [...new Set(cluster.map((c) => greedyLanes.get(c.id) ?? 0))].sort(
(a, b) => a - b,
);
const remap = new Map(usedLaneIndices.map((lane, index) => [lane, index]));
for (const interval of cluster) {
const rawLane = greedyLanes.get(interval.id) ?? 0;
layouts.set(interval.id, {
lane: remap.get(rawLane) ?? 0,
laneCount,
});
}
}
return layouts;
}
export function lanePositionStyles(lane: number, laneCount: number): {
left: string;
width: string;
} {
const gapPct = 1;
const widthPct = (100 - gapPct * (laneCount + 1)) / laneCount;
return {
left: `calc(${gapPct}% + ${lane} * (${widthPct}% + ${gapPct}%))`,
width: `${widthPct}%`,
};
}