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

276 lines
8.5 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import {
DROPDOWN_OPTION_BG,
treatmentTypeColor,
} from '@/components/ui/treatment/treatmentTypeDisplay';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
compareLocalDayStart,
formatTimeForInput,
isSameLocalCalendarDay,
} from '@/components/appointments/appointmentTime';
interface AppointmentBookingModalProps {
open: boolean;
scheduleDate: Date;
patient: Patient | undefined;
providerUserId: string | null;
providerName: string;
initialStartMinute: number;
onClose: () => void;
onSubmit: (payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) => Promise<void>;
treatmentCatalog: TreatmentCatalogEntry[];
editingAppointment?: AppointmentRecord | null;
loading?: boolean;
canDelete?: boolean;
onDelete?: () => void | Promise<void>;
deleting?: boolean;
}
export function AppointmentBookingModal({
open,
scheduleDate,
patient,
providerUserId,
providerName,
initialStartMinute,
onClose,
onSubmit,
treatmentCatalog,
editingAppointment = null,
loading = false,
canDelete = false,
onDelete,
deleting = false,
}: AppointmentBookingModalProps) {
const t = useTranslations('appointments');
const tCommon = useTranslations('common');
const tPatients = useTranslations('patients');
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
const [error, setError] = useState('');
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
useEffect(() => {
if (!open) {
return;
}
if (editingAppointment) {
const start = new Date(editingAppointment.startAt);
const end = new Date(editingAppointment.endAt);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose(editingAppointment.purpose || defaultPurpose);
} else {
const start = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
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(),
Math.floor(endMinute / 60),
endMinute % 60,
0,
0,
);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose(defaultPurpose);
}
setError('');
}, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]);
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 (!editingAppointment && !patient) {
setError(t('errorSelectPatient'));
return;
}
const startAt = combineLocalDateAndTime(scheduleDate, startTime);
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
if (endAt <= startAt) {
setError(t('errorEndAfterStart'));
return;
}
const now = new Date();
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
setError(t('errorPastSchedule'));
return;
}
const today = new Date();
if (compareLocalDayStart(scheduleDate, today) < 0) {
setError(t('errorPastViewOnly'));
return;
}
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
if (!effectivePatientId || !effectiveProviderId) {
setError(t('errorMissingDetails'));
return;
}
await onSubmit({
patientId: effectivePatientId,
providerUserId: effectiveProviderId,
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">
{editingAppointment ? t('editTitle') : t('newTitle')}
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<p className="text-sm text-text-secondary">
{t('providerLabel')}{' '}
<span className="text-text-primary font-medium">{providerName}</span>
</p>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
{t('patientLabel')}
</label>
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{editingAppointment
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
: patient
? `${patient.firstName} ${patient.lastName}`
: tPatients('emptyValue')}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
{t('startLabel')}
</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">
{t('endLabel')}
</label>
<input
type="time"
step={60}
className={inputClass}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<Dropdown
label={t('purposeLabel')}
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
2026-05-07 14:20:50 +03:30
style={{ color: purposeTextColor }}
>
{treatmentCatalog.map((entry, index) => (
<option
key={entry.code}
value={entry.code}
style={{ color: treatmentTypeColor(entry.code, index), backgroundColor: DROPDOWN_OPTION_BG }}
>
{entry.label}
</option>
))}
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex flex-wrap items-center gap-2 justify-between">
{editingAppointment && canDelete && onDelete ? (
<Button
type="button"
variant="danger"
onClick={() => void onDelete()}
disabled={loading || deleting}
isLoading={deleting}
>
{tCommon('delete')}
</Button>
) : (
<span />
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading || deleting}>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="primary"
onClick={() => void handleSubmit()}
isLoading={loading}
disabled={deleting}
>
{tCommon('save')}
</Button>
</div>
</div>
</div>
</div>
);
}