bugfix/demo-bugs-fixed #18
@@ -1,9 +1,21 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AppointmentsService } from './appointments.service';
|
import { AppointmentsService } from './appointments.service';
|
||||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||||
|
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
||||||
|
|
||||||
@ApiTags('appointments')
|
@ApiTags('appointments')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -39,6 +51,17 @@ export class AppointmentsController {
|
|||||||
return this.appointmentsService.create(dto, organizationId, req.user.id);
|
return this.appointmentsService.create(dto, organizationId, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAppointmentDto,
|
||||||
|
@Req() req: { user: { id: string; organizationId?: string } },
|
||||||
|
) {
|
||||||
|
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.appointmentsService.update(id, dto, organizationId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
|
@ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
|
||||||
remove(
|
remove(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||||
|
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
@@ -109,20 +110,6 @@ export class AppointmentsService {
|
|||||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||||
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
||||||
|
|
||||||
const overlap = await this.prisma.appointment.findFirst({
|
|
||||||
where: {
|
|
||||||
organizationId,
|
|
||||||
providerUserId: dto.providerUserId,
|
|
||||||
startAt: { lt: endAt },
|
|
||||||
endAt: { gt: startAt },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (overlap) {
|
|
||||||
throw new BadRequestException('This time slot overlaps an existing appointment for that provider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const appointment = await this.prisma.appointment.create({
|
const appointment = await this.prisma.appointment.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -142,6 +129,63 @@ export class AppointmentsService {
|
|||||||
return { success: true, data: appointment };
|
return { success: true, data: appointment };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateAppointmentDto,
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertCanEditAppointments(actorUserId, organizationId);
|
||||||
|
|
||||||
|
const existing = await this.prisma.appointment.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Appointment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
|
||||||
|
const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
|
||||||
|
|
||||||
|
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||||
|
throw new BadRequestException('Invalid start or end time');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endAt <= startAt) {
|
||||||
|
throw new BadRequestException('End time must be after start time');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||||
|
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
const patientId = dto.patientId ?? existing.patientId;
|
||||||
|
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
||||||
|
const purpose = dto.purpose ?? existing.purpose;
|
||||||
|
|
||||||
|
await this.ensurePatientInOrg(patientId, organizationId);
|
||||||
|
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
|
||||||
|
|
||||||
|
const appointment = await this.prisma.appointment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
patientId,
|
||||||
|
providerUserId,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
purpose,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
patient: {
|
||||||
|
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: appointment };
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string, organizationId: string, actorUserId: string) {
|
async remove(id: string, organizationId: string, actorUserId: string) {
|
||||||
await this.assertCanEditAppointments(actorUserId, organizationId);
|
await this.assertCanEditAppointments(actorUserId, organizationId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateAppointmentDto } from './create-appointment.dto';
|
||||||
|
|
||||||
|
export class UpdateAppointmentDto extends PartialType(CreateAppointmentDto) {}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||||
import { formatHourLabel } from '@/lib/appointmentTime';
|
import { formatHourLabel } from '@/lib/appointmentTime';
|
||||||
|
import {
|
||||||
|
computeAppointmentLaneLayouts,
|
||||||
|
findOverlapCluster,
|
||||||
|
lanePositionStyles,
|
||||||
|
} from '@/lib/appointmentOverlapLayout';
|
||||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
|
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||||
|
|
||||||
const HOUR_PX = 40;
|
const HOUR_PX = 40;
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
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));
|
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 {
|
interface AppointmentScheduleGridProps {
|
||||||
day: Date;
|
day: Date;
|
||||||
providers: AppointmentColumnProvider[];
|
providers: AppointmentColumnProvider[];
|
||||||
@@ -47,6 +74,32 @@ export function AppointmentScheduleGrid({
|
|||||||
onAppointmentClick,
|
onAppointmentClick,
|
||||||
}: AppointmentScheduleGridProps) {
|
}: AppointmentScheduleGridProps) {
|
||||||
const gridHeight = HOURS.length * HOUR_PX;
|
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) {
|
if (providers.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -57,93 +110,145 @@ export function AppointmentScheduleGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-card overflow-x-auto">
|
<>
|
||||||
<div className="min-w-[640px]">
|
<div className="surface-card overflow-x-auto">
|
||||||
<div className="flex border-b border-border">
|
<div className="min-w-[640px]">
|
||||||
<div className="w-14 flex-shrink-0" />
|
<div className="flex border-b border-border">
|
||||||
{providers.map((p) => (
|
<div className="w-14 flex-shrink-0" />
|
||||||
<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">
|
|
||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.userId}
|
key={p.userId}
|
||||||
className="flex-1 min-w-[130px] border-l border-border relative"
|
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
|
||||||
style={{ height: gridHeight }}
|
|
||||||
>
|
>
|
||||||
{HOURS.map((h) => {
|
{p.name}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
|
||||||
|
{overlapPopover && (
|
||||||
|
<AppointmentOverlapPopover
|
||||||
|
appointments={overlapPopover.appointments}
|
||||||
|
anchorRect={overlapPopover.anchorRect}
|
||||||
|
onSelect={(apt) => onAppointmentClick?.(apt)}
|
||||||
|
onClose={() => setOverlapPopover(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
173
frontend/src/lib/appointmentOverlapLayout.ts
Normal file
173
frontend/src/lib/appointmentOverlapLayout.ts
Normal 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}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user