feature: users with treatment edit permission should now have working hours defined. the appointment grid is now being drawn based on the doctor's working hours.
This commit is contained in:
@@ -20,7 +20,7 @@ interface AppointmentBookingModalProps {
|
||||
patient: Patient | undefined;
|
||||
providerUserId: string | null;
|
||||
providerName: string;
|
||||
initialHour: number;
|
||||
initialStartMinute: number;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
patientId: string;
|
||||
@@ -42,7 +42,7 @@ export function AppointmentBookingModal({
|
||||
patient,
|
||||
providerUserId,
|
||||
providerName,
|
||||
initialHour,
|
||||
initialStartMinute,
|
||||
onClose,
|
||||
onSubmit,
|
||||
editingAppointment = null,
|
||||
@@ -81,17 +81,18 @@ export function AppointmentBookingModal({
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
scheduleDate.getDate(),
|
||||
initialHour,
|
||||
0,
|
||||
Math.floor(initialStartMinute / 60),
|
||||
initialStartMinute % 60,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1);
|
||||
const end = new Date(
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
scheduleDate.getDate(),
|
||||
initialHour < 23 ? initialHour + 1 : 23,
|
||||
initialHour < 23 ? 0 : 59,
|
||||
Math.floor(endMinute / 60),
|
||||
endMinute % 60,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
@@ -100,7 +101,7 @@ export function AppointmentBookingModal({
|
||||
setPurpose('consultation');
|
||||
}
|
||||
setError('');
|
||||
}, [open, scheduleDate, initialHour, editingAppointment]);
|
||||
}, [open, scheduleDate, initialStartMinute, editingAppointment]);
|
||||
|
||||
if (!open || !providerUserId) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||
import { formatHourLabel } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
appointmentWithinWorkingHours,
|
||||
formatMinuteLabel,
|
||||
generateHourLabelsInRange,
|
||||
generateSlotStarts,
|
||||
isSlotWithinWorkingBlocks,
|
||||
snapRangeToSlots,
|
||||
unionDayBlockRange,
|
||||
} from '@/components/staff/workingHours';
|
||||
import {
|
||||
computeAppointmentLaneLayouts,
|
||||
findOverlapCluster,
|
||||
@@ -10,23 +19,30 @@ import {
|
||||
} from '@/components/appointments/appointmentOverlapLayout';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
|
||||
const HOUR_PX = 40;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
|
||||
|
||||
function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null {
|
||||
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0);
|
||||
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0);
|
||||
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 ms = dayEnd.getTime() - dayStart.getTime();
|
||||
const clipStart = Math.max(start.getTime(), dayStart.getTime());
|
||||
const clipEnd = Math.min(end.getTime(), dayEnd.getTime());
|
||||
const clipStart = Math.max(start.getTime(), rangeStartMs);
|
||||
const clipEnd = Math.min(end.getTime(), rangeEndMs);
|
||||
if (clipEnd <= clipStart) {
|
||||
return null;
|
||||
}
|
||||
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
|
||||
const height = ((clipEnd - clipStart) / ms) * 100;
|
||||
const rangeMs = rangeEndMs - rangeStartMs;
|
||||
const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
|
||||
const height = ((clipEnd - clipStart) / rangeMs) * 100;
|
||||
return { top: `${top}%`, height: `${height}%` };
|
||||
}
|
||||
|
||||
@@ -36,12 +52,12 @@ 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 appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
|
||||
return (durationMin / rangeMinutes) * gridHeight;
|
||||
}
|
||||
|
||||
function shortBannerNameClass(durationMin: number): string {
|
||||
const heightPx = appointmentBannerHeightPx(durationMin);
|
||||
function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
|
||||
const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
|
||||
if (heightPx < 18) {
|
||||
return 'text-[8px] leading-none';
|
||||
}
|
||||
@@ -61,8 +77,9 @@ interface AppointmentScheduleGridProps {
|
||||
providers: AppointmentColumnProvider[];
|
||||
appointments: AppointmentRecord[];
|
||||
canBook: boolean;
|
||||
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
||||
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
|
||||
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
||||
}
|
||||
|
||||
export function AppointmentScheduleGrid({
|
||||
@@ -72,10 +89,39 @@ export function AppointmentScheduleGrid({
|
||||
canBook,
|
||||
onSlotClick,
|
||||
onAppointmentClick,
|
||||
onAppointmentOutsideHours,
|
||||
}: AppointmentScheduleGridProps) {
|
||||
const gridHeight = HOURS.length * HOUR_PX;
|
||||
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) {
|
||||
@@ -87,9 +133,19 @@ export function AppointmentScheduleGrid({
|
||||
|
||||
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({
|
||||
@@ -109,6 +165,15 @@ export function AppointmentScheduleGrid({
|
||||
);
|
||||
}
|
||||
|
||||
if (!snappedRange || slotStarts.length === 0) {
|
||||
return (
|
||||
<div className="surface-card p-6 text-sm text-text-muted">
|
||||
No working hours are configured for this day. Set provider working hours in Staff
|
||||
management.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="surface-card overflow-x-auto">
|
||||
@@ -121,21 +186,38 @@ export function AppointmentScheduleGrid({
|
||||
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">
|
||||
No hours set
|
||||
</span>
|
||||
)}
|
||||
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
|
||||
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
|
||||
Off today
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
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">
|
||||
@@ -144,6 +226,7 @@ export function AppointmentScheduleGrid({
|
||||
(a) => a.providerUserId === p.userId,
|
||||
);
|
||||
const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map();
|
||||
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,31 +234,50 @@ export function AppointmentScheduleGrid({
|
||||
className="flex-1 min-w-[130px] border-l border-border relative"
|
||||
style={{ height: gridHeight }}
|
||||
>
|
||||
{HOURS.map((h) => {
|
||||
const slotDisabled = !canBook;
|
||||
{slotStarts.map((slotStartMinute, index) => {
|
||||
const slotActive =
|
||||
!columnFullyDisabled &&
|
||||
isSlotWithinWorkingBlocks(
|
||||
slotStartMinute,
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
p.dayBlocks,
|
||||
);
|
||||
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={h}
|
||||
key={`${p.userId}-${slotStartMinute}`}
|
||||
type="button"
|
||||
disabled={slotDisabled}
|
||||
title={
|
||||
slotDisabled
|
||||
? 'You cannot create appointments'
|
||||
: `Book ${formatHourLabel(h)}`
|
||||
columnFullyDisabled
|
||||
? p.hasWorkingHours
|
||||
? 'Provider is off today'
|
||||
: 'Working hours not configured'
|
||||
: !slotActive
|
||||
? 'Outside working hours'
|
||||
: slotDisabled
|
||||
? 'You cannot create appointments'
|
||||
: `Book ${formatMinuteLabel(slotStartMinute)}`
|
||||
}
|
||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||
slotDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
||||
: 'hover:bg-primary/8 cursor-pointer'
|
||||
}`}
|
||||
style={{ top: h * HOUR_PX, height: HOUR_PX }}
|
||||
onClick={() => onSlotClick(h, p.userId, p.name)}
|
||||
style={{ top: index * SLOT_PX, height: SLOT_PX }}
|
||||
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{providerAppointments.map((apt) => {
|
||||
const pos = layoutBlock(apt, day);
|
||||
const pos = layoutBlockInRange(
|
||||
apt,
|
||||
day,
|
||||
snappedRange.startMinute,
|
||||
snappedRange.endMinute,
|
||||
);
|
||||
if (!pos) {
|
||||
return null;
|
||||
}
|
||||
@@ -184,9 +286,18 @@ export function AppointmentScheduleGrid({
|
||||
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 ? 'Outside working hours — editing blocked' : null,
|
||||
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
|
||||
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
|
||||
]
|
||||
@@ -198,9 +309,16 @@ export function AppointmentScheduleGrid({
|
||||
type="button"
|
||||
key={apt.id}
|
||||
onClick={(e) =>
|
||||
handleAppointmentBannerClick(apt, providerAppointments, e.currentTarget)
|
||||
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 ${purposeStyle(apt.purpose)} 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'
|
||||
@@ -214,7 +332,7 @@ export function AppointmentScheduleGrid({
|
||||
title={bannerTitle}
|
||||
>
|
||||
<span
|
||||
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin)}`}
|
||||
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
|
||||
>
|
||||
{patientName}
|
||||
</span>
|
||||
@@ -245,7 +363,24 @@ export function AppointmentScheduleGrid({
|
||||
<AppointmentOverlapPopover
|
||||
appointments={overlapPopover.appointments}
|
||||
anchorRect={overlapPopover.anchorRect}
|
||||
onSelect={(apt) => onAppointmentClick?.(apt)}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user