200 lines
5.8 KiB
TypeScript
200 lines
5.8 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { X } from 'lucide-react';
|
||
|
|
import { Button } from '@/components/ui/common/Button';
|
||
|
|
import { Dropdown } from '@/components/ui/common/Dropdown';
|
||
|
|
import { APPOINTMENT_PURPOSES, type AppointmentPurpose } from '@/types/appointment';
|
||
|
|
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||
|
|
import type { Patient } from '@/types/patient';
|
||
|
|
import {
|
||
|
|
combineLocalDateAndTime,
|
||
|
|
formatTimeForInput,
|
||
|
|
isSameLocalCalendarDay,
|
||
|
|
} from '@/lib/appointmentTime';
|
||
|
|
|
||
|
|
interface AppointmentBookingModalProps {
|
||
|
|
open: boolean;
|
||
|
|
scheduleDate: Date;
|
||
|
|
patient: Patient | undefined;
|
||
|
|
providerUserId: string | null;
|
||
|
|
providerName: string;
|
||
|
|
initialHour: number;
|
||
|
|
onClose: () => void;
|
||
|
|
onSubmit: (payload: {
|
||
|
|
patientId: string;
|
||
|
|
providerUserId: string;
|
||
|
|
startAt: string;
|
||
|
|
endAt: string;
|
||
|
|
purpose: AppointmentPurpose;
|
||
|
|
}) => Promise<void>;
|
||
|
|
loading?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function AppointmentBookingModal({
|
||
|
|
open,
|
||
|
|
scheduleDate,
|
||
|
|
patient,
|
||
|
|
providerUserId,
|
||
|
|
providerName,
|
||
|
|
initialHour,
|
||
|
|
onClose,
|
||
|
|
onSubmit,
|
||
|
|
loading = false,
|
||
|
|
}: AppointmentBookingModalProps) {
|
||
|
|
const [startTime, setStartTime] = useState('09:00');
|
||
|
|
const [endTime, setEndTime] = useState('10:00');
|
||
|
|
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const start = new Date(
|
||
|
|
scheduleDate.getFullYear(),
|
||
|
|
scheduleDate.getMonth(),
|
||
|
|
scheduleDate.getDate(),
|
||
|
|
initialHour,
|
||
|
|
0,
|
||
|
|
0,
|
||
|
|
0,
|
||
|
|
);
|
||
|
|
const end = new Date(
|
||
|
|
scheduleDate.getFullYear(),
|
||
|
|
scheduleDate.getMonth(),
|
||
|
|
scheduleDate.getDate(),
|
||
|
|
initialHour < 23 ? initialHour + 1 : 23,
|
||
|
|
initialHour < 23 ? 0 : 59,
|
||
|
|
0,
|
||
|
|
0,
|
||
|
|
);
|
||
|
|
setStartTime(formatTimeForInput(start));
|
||
|
|
setEndTime(formatTimeForInput(end));
|
||
|
|
setPurpose('consultation');
|
||
|
|
setError('');
|
||
|
|
}, [open, scheduleDate, initialHour]);
|
||
|
|
|
||
|
|
if (!open || !providerUserId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const inputClass =
|
||
|
|
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||
|
|
|
||
|
|
async function handleSubmit() {
|
||
|
|
setError('');
|
||
|
|
if (!providerUserId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!patient) {
|
||
|
|
setError('Select a patient first.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const startAt = combineLocalDateAndTime(scheduleDate, startTime);
|
||
|
|
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
|
||
|
|
|
||
|
|
if (endAt <= startAt) {
|
||
|
|
setError('End time must be after start time.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = new Date();
|
||
|
|
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
|
||
|
|
setError('Cannot schedule in the past.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await onSubmit({
|
||
|
|
patientId: patient.id,
|
||
|
|
providerUserId,
|
||
|
|
startAt: startAt.toISOString(),
|
||
|
|
endAt: endAt.toISOString(),
|
||
|
|
purpose,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
|
||
|
|
<div
|
||
|
|
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-labelledby="appointment-modal-title"
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between gap-2">
|
||
|
|
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
||
|
|
New appointment
|
||
|
|
</h2>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
className="rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||
|
|
aria-label="Close"
|
||
|
|
>
|
||
|
|
<X className="h-5 w-5 icon-flat" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p className="text-sm text-text-secondary">
|
||
|
|
Provider: <span className="text-text-primary font-medium">{providerName}</span>
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
|
||
|
|
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
||
|
|
{patient ? `${patient.firstName} ${patient.lastName}` : '—'}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
|
||
|
|
<input
|
||
|
|
type="time"
|
||
|
|
step={60}
|
||
|
|
className={inputClass}
|
||
|
|
value={startTime}
|
||
|
|
onChange={(e) => setStartTime(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
|
||
|
|
<input
|
||
|
|
type="time"
|
||
|
|
step={60}
|
||
|
|
className={inputClass}
|
||
|
|
value={endTime}
|
||
|
|
onChange={(e) => setEndTime(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dropdown
|
||
|
|
label="Purpose"
|
||
|
|
value={purpose}
|
||
|
|
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
|
||
|
|
>
|
||
|
|
{APPOINTMENT_PURPOSES.map((p) => (
|
||
|
|
<option key={p} value={p}>
|
||
|
|
{APPOINTMENT_PURPOSE_LABEL[p]}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Dropdown>
|
||
|
|
|
||
|
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||
|
|
|
||
|
|
<div className="flex gap-2 justify-end">
|
||
|
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button type="button" variant="primary" onClick={() => void handleSubmit()} isLoading={loading}>
|
||
|
|
Save
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|