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

399 lines
15 KiB
TypeScript
Raw Normal View History

'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import {
SCHEDULE_SLOT_MINUTES,
appointmentWithinWorkingHours,
formatMinuteLabel,
generateHourLabelsInRange,
generateSlotStarts,
isSlotWithinWorkingBlocks,
snapRangeToSlots,
unionDayBlockRange,
} from '@/components/staff/workingHours';
import {
computeAppointmentLaneLayouts,
findOverlapCluster,
lanePositionStyles,
} from '@/components/appointments/appointmentOverlapLayout';
import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { formatMobileForDisplay } from '@/lib/phone';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const HOUR_PX = 80;
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
function layoutBlockInRange(
apt: AppointmentRecord,
day: Date,
rangeStartMinute: number,
rangeEndMinute: number,
): { top: string; height: string } | null {
const dayStart = startOfLocalDay(day);
const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000;
const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000;
const start = new Date(apt.startAt);
const end = new Date(apt.endAt);
const clipStart = Math.max(start.getTime(), rangeStartMs);
const clipEnd = Math.min(end.getTime(), rangeEndMs);
if (clipEnd <= clipStart) {
return null;
}
const rangeMs = rangeEndMs - rangeStartMs;
const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
const height = ((clipEnd - clipStart) / rangeMs) * 100;
return { top: `${top}%`, height: `${height}%` };
}
function appointmentDurationMinutes(apt: AppointmentRecord): number {
const start = new Date(apt.startAt).getTime();
const end = new Date(apt.endAt).getTime();
return Math.max(0, Math.round((end - start) / 60_000));
}
function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
return (durationMin / rangeMinutes) * gridHeight;
}
function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
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[];
appointments: AppointmentRecord[];
treatmentCatalog: TreatmentCatalogEntry[];
canBook: boolean;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
}
export function AppointmentScheduleGrid({
day,
providers,
appointments,
treatmentCatalog,
canBook,
onSlotClick,
onAppointmentClick,
onAppointmentOutsideHours,
}: AppointmentScheduleGridProps) {
const t = useTranslations('appointments');
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
const visibleRange = useMemo(() => {
const activeDayBlocks = providers
.filter((p) => p.hasWorkingHours && p.dayBlocks.length > 0)
.map((p) => p.dayBlocks);
return unionDayBlockRange(activeDayBlocks);
}, [providers]);
const snappedRange = useMemo(() => {
if (!visibleRange) return null;
return snapRangeToSlots(visibleRange.startMinute, visibleRange.endMinute, SCHEDULE_SLOT_MINUTES);
}, [visibleRange]);
const slotStarts = useMemo(() => {
if (!snappedRange) return [];
return generateSlotStarts(
snappedRange.startMinute,
snappedRange.endMinute,
SCHEDULE_SLOT_MINUTES,
);
}, [snappedRange]);
const hourLabels = useMemo(() => {
if (!snappedRange) return [];
return generateHourLabelsInRange(snappedRange.startMinute, snappedRange.endMinute);
}, [snappedRange]);
const gridHeight = slotStarts.length * SLOT_PX;
const rangeMinutes = snappedRange ? snappedRange.endMinute - snappedRange.startMinute : 0;
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,
provider: AppointmentColumnProvider,
providerAppointments: AppointmentRecord[],
anchor: HTMLElement,
) {
if (
provider.hasWorkingHours &&
provider.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks)
) {
onAppointmentOutsideHours?.(apt);
return;
}
const cluster = findOverlapCluster(apt.id, providerAppointments);
if (cluster.length > 1) {
setOverlapPopover({
appointments: cluster,
anchorRect: anchor.getBoundingClientRect(),
});
return;
}
onAppointmentClick?.(apt);
}
if (providers.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">{t('noProviders')}</div>
);
}
if (!snappedRange || slotStarts.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">{t('noWorkingHours')}</div>
);
}
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}
{!p.hasWorkingHours && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
{t('noHoursSet')}
</span>
)}
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
{t('offToday')}
</span>
)}
</div>
))}
</div>
<div className="flex">
<div
className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
style={{ height: gridHeight }}
>
{hourLabels.map((hour) => {
const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
const height = (60 / rangeMinutes) * gridHeight;
return (
<div
key={hour}
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ top, height }}
>
{formatMinuteLabel(hour * 60)}
</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();
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
return (
<div
key={p.userId}
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
>
{slotStarts.map((slotStartMinute, index) => {
const slotActive =
!columnFullyDisabled &&
isSlotWithinWorkingBlocks(
slotStartMinute,
SCHEDULE_SLOT_MINUTES,
p.dayBlocks,
);
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
return (
<button
key={`${p.userId}-${slotStartMinute}`}
type="button"
disabled={slotDisabled}
title={
columnFullyDisabled
? p.hasWorkingHours
? t('slotOffToday')
: t('slotHoursNotConfigured')
: !slotActive
? t('slotOutsideHours')
: slotDisabled
? t('slotCannotCreate')
: t('slotBookAt', {
time: formatMinuteLabel(slotStartMinute),
})
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: index * SLOT_PX, height: SLOT_PX }}
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
/>
);
})}
{providerAppointments.map((apt) => {
const pos = layoutBlockInRange(
apt,
day,
snappedRange.startMinute,
snappedRange.endMinute,
);
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 outsideHours =
p.hasWorkingHours &&
p.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
p.dayBlocks,
);
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const bannerTitle = [
patientName,
outsideHours ? t('outsideHoursBlocked') : null,
clusterSize > 1
? t('overlappingChoose', { count: clusterSize })
: null,
!isUnderOneHour && apt.patient.mobile
? formatMobileForDisplay(apt.patient.mobile)
: null,
]
.filter(Boolean)
.join(' · ');
return (
<button
type="button"
key={apt.id}
onClick={(e) =>
handleAppointmentBannerClick(
apt,
p,
providerAppointments,
e.currentTarget,
)
}
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${
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,
...purposeBannerStyle(apt.purpose, treatmentCatalog),
}}
title={bannerTitle}
>
<span
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
>
{patientName}
</span>
{!isUnderOneHour &&
apt.patient.mobile &&
lane.laneCount === 1 && (
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
{formatMobileForDisplay(apt.patient.mobile)}
</span>
)}
{!isUnderOneHour && clusterSize > 1 && (
<span className="block w-full truncate pointer-events-none text-[9px] leading-tight opacity-75">
{t('overlapping', { count: clusterSize })}
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
{overlapPopover && (
<AppointmentOverlapPopover
appointments={overlapPopover.appointments}
treatmentCatalog={treatmentCatalog}
anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => {
const provider = providers.find((p) => p.userId === apt.providerUserId);
if (
provider &&
provider.hasWorkingHours &&
provider.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
provider.dayBlocks,
)
) {
onAppointmentOutsideHours?.(apt);
setOverlapPopover(null);
return;
}
onAppointmentClick?.(apt);
}}
onClose={() => setOverlapPopover(null)}
/>
)}
</>
);
}