2026-05-06 23:05:37 +03:30
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
|
|
|
|
import { formatHourLabel } from '@/lib/appointmentTime';
|
2026-05-17 17:14:00 +03:30
|
|
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
2026-05-06 23:05:37 +03:30
|
|
|
|
|
|
|
|
const HOUR_PX = 40;
|
|
|
|
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
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());
|
|
|
|
|
if (clipEnd <= clipStart) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
|
|
|
|
|
const height = ((clipEnd - clipStart) / ms) * 100;
|
|
|
|
|
return { top: `${top}%`, height: `${height}%` };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:14:00 +03:30
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:05:37 +03:30
|
|
|
interface AppointmentScheduleGridProps {
|
|
|
|
|
day: Date;
|
|
|
|
|
providers: AppointmentColumnProvider[];
|
|
|
|
|
appointments: AppointmentRecord[];
|
|
|
|
|
canBook: boolean;
|
|
|
|
|
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
2026-05-08 14:25:44 +03:30
|
|
|
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
2026-05-06 23:05:37 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AppointmentScheduleGrid({
|
|
|
|
|
day,
|
|
|
|
|
providers,
|
|
|
|
|
appointments,
|
|
|
|
|
canBook,
|
|
|
|
|
onSlotClick,
|
2026-05-08 14:25:44 +03:30
|
|
|
onAppointmentClick,
|
2026-05-06 23:05:37 +03:30
|
|
|
}: AppointmentScheduleGridProps) {
|
|
|
|
|
const gridHeight = HOURS.length * HOUR_PX;
|
|
|
|
|
|
|
|
|
|
if (providers.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="surface-card p-6 text-sm text-text-muted">
|
|
|
|
|
No providers available. Add staff with treatment edit access to see columns here.
|
|
|
|
|
</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}
|
|
|
|
|
</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) => (
|
|
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{appointments
|
|
|
|
|
.filter((a) => a.providerUserId === p.userId)
|
|
|
|
|
.map((apt) => {
|
|
|
|
|
const pos = layoutBlock(apt, day);
|
|
|
|
|
if (!pos) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-17 17:14:00 +03:30
|
|
|
const durationMin = appointmentDurationMinutes(apt);
|
2026-05-06 23:05:37 +03:30
|
|
|
return (
|
2026-05-08 14:25:44 +03:30
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-06 23:05:37 +03:30
|
|
|
key={apt.id}
|
2026-05-08 14:25:44 +03:30
|
|
|
onClick={() => onAppointmentClick?.(apt)}
|
2026-05-17 17:14:00 +03:30
|
|
|
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 }}
|
2026-05-06 23:05:37 +03:30
|
|
|
>
|
2026-05-17 17:14:00 +03:30
|
|
|
<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}
|
2026-05-06 23:05:37 +03:30
|
|
|
</p>
|
|
|
|
|
)}
|
2026-05-08 14:25:44 +03:30
|
|
|
</button>
|
2026-05-06 23:05:37 +03:30
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|