diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index 6732028..8c32934 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AppointmentsService } from './appointments.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; +import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @ApiTags('appointments') @ApiBearerAuth('JWT-auth') @@ -39,6 +51,17 @@ export class AppointmentsController { 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') @ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' }) remove( diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index c36d5e6..2f9e48d 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from '../../../prisma/prisma.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; +import { UpdateAppointmentDto } from './dto/update-appointment.dto'; const MS_PER_DAY = 86_400_000; @@ -109,20 +110,6 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, 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({ data: { organizationId, @@ -142,6 +129,63 @@ export class AppointmentsService { 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) { await this.assertCanEditAppointments(actorUserId, organizationId); diff --git a/backend/src/modules/appointments/dto/update-appointment.dto.ts b/backend/src/modules/appointments/dto/update-appointment.dto.ts new file mode 100644 index 0000000..e743fb4 --- /dev/null +++ b/backend/src/modules/appointments/dto/update-appointment.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateAppointmentDto } from './create-appointment.dto'; + +export class UpdateAppointmentDto extends PartialType(CreateAppointmentDto) {} diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx new file mode 100644 index 0000000..01d7ec5 --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -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(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 ( +
+
+
+

+ Overlapping appointments ({sorted.length}) +

+ +
+
    + {sorted.map((apt) => { + const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL; + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index e64a14c..13f2912 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -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(null); + + const laneLayoutsByProvider = useMemo(() => { + const map = new Map>(); + 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 ( -
-
-
-
- {providers.map((p) => ( -
- {p.name} -
- ))} -
- -
-
- {HOURS.map((h) => ( -
- {formatHourLabel(h)} -
- ))} -
- -
+ <> +
+
+
+
{providers.map((p) => (
- {HOURS.map((h) => { - const slotDisabled = !canBook; - return ( - - ); - })} + {p.name}
))}
+ +
+
+ {HOURS.map((h) => ( +
+ {formatHourLabel(h)} +
+ ))} +
+ +
+ {providers.map((p) => { + const providerAppointments = appointments.filter( + (a) => a.providerUserId === p.userId, + ); + const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); + + return ( +
+ {HOURS.map((h) => { + const slotDisabled = !canBook; + return ( + + ); + })} +
+ ); + })} +
+
-
+ + {overlapPopover && ( + onAppointmentClick?.(apt)} + onClose={() => setOverlapPopover(null)} + /> + )} + ); } diff --git a/frontend/src/lib/appointmentOverlapLayout.ts b/frontend/src/lib/appointmentOverlapLayout.ts new file mode 100644 index 0000000..ae1efa0 --- /dev/null +++ b/frontend/src/lib/appointmentOverlapLayout.ts @@ -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([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 { + const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end); + const laneEndTimes: number[] = []; + const laneById = new Map(); + + 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(); + 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 { + const timed = appointments.map(toTimedInterval); + if (timed.length === 0) { + return new Map(); + } + + const layouts = new Map(); + 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}%`, + }; +}