improvement: treatment exits contoll adde to appoinment remove action.
This commit is contained in:
@@ -1,354 +1,358 @@
|
||||
'use client';
|
||||
|
||||
|
||||
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
|
||||
import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog';
|
||||
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
|
||||
import { TimeStepInput } from '@/components/ui/shared/TimeStepInput';
|
||||
|
||||
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
import {
|
||||
|
||||
DROPDOWN_OPTION_BG,
|
||||
|
||||
treatmentTypeColor,
|
||||
|
||||
} from '@/components/shared/treatmentTypeDisplay';
|
||||
|
||||
import type { Patient } from '@/types/patient';
|
||||
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
|
||||
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||
|
||||
import {
|
||||
|
||||
combineLocalDateAndTime,
|
||||
|
||||
compareLocalDayStart,
|
||||
|
||||
formatTimeForInput,
|
||||
|
||||
isSameLocalCalendarDay,
|
||||
|
||||
} from '@/components/appointments/appointmentTime';
|
||||
|
||||
|
||||
|
||||
const DEFAULT_DURATION_MINUTES = 30;
|
||||
|
||||
|
||||
|
||||
interface AppointmentBookingModalProps {
|
||||
|
||||
open: boolean;
|
||||
|
||||
scheduleDate: Date;
|
||||
|
||||
/** Pre-selected patient from the page sidebar (optional). */
|
||||
|
||||
initialPatient?: Patient;
|
||||
|
||||
onPatientChange?: (patient: Patient | undefined) => void;
|
||||
|
||||
canAddPatient?: boolean;
|
||||
|
||||
onAddPatient?: () => void;
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function patientFromRecord(
|
||||
|
||||
patient: AppointmentRecord['patient'],
|
||||
|
||||
): Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> {
|
||||
|
||||
return {
|
||||
|
||||
id: patient.id,
|
||||
|
||||
firstName: patient.firstName,
|
||||
|
||||
lastName: patient.lastName,
|
||||
|
||||
mobile: patient.mobile,
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function AppointmentBookingModal({
|
||||
|
||||
open,
|
||||
|
||||
scheduleDate,
|
||||
|
||||
initialPatient,
|
||||
|
||||
onPatientChange,
|
||||
|
||||
canAddPatient = false,
|
||||
|
||||
onAddPatient,
|
||||
|
||||
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 startInputId = useId();
|
||||
|
||||
const endInputId = useId();
|
||||
|
||||
|
||||
|
||||
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
|
||||
|
||||
|
||||
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
|
||||
const [endTime, setEndTime] = useState('09:30');
|
||||
|
||||
const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
|
||||
|
||||
const [selectedPatient, setSelectedPatient] = useState<
|
||||
|
||||
Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> | null
|
||||
|
||||
>(null);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
|
||||
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open);
|
||||
|
||||
|
||||
|
||||
const patientLocked = Boolean(editingAppointment?.hasTreatment);
|
||||
|
||||
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
|
||||
|
||||
const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!open) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setSearch('');
|
||||
|
||||
if (editingAppointment) {
|
||||
|
||||
const start = new Date(editingAppointment.startAt);
|
||||
|
||||
const end = new Date(editingAppointment.endAt);
|
||||
|
||||
setStartTime(formatTimeForInput(start));
|
||||
|
||||
setEndTime(formatTimeForInput(end));
|
||||
|
||||
setPurpose(editingAppointment.purpose || defaultPurpose);
|
||||
|
||||
setSelectedPatient(patientFromRecord(editingAppointment.patient));
|
||||
|
||||
} else {
|
||||
|
||||
const start = new Date(
|
||||
|
||||
scheduleDate.getFullYear(),
|
||||
|
||||
scheduleDate.getMonth(),
|
||||
|
||||
scheduleDate.getDate(),
|
||||
|
||||
Math.floor(initialStartMinute / 60),
|
||||
|
||||
initialStartMinute % 60,
|
||||
|
||||
0,
|
||||
|
||||
0,
|
||||
|
||||
);
|
||||
|
||||
const endMinute = Math.min(
|
||||
|
||||
initialStartMinute + DEFAULT_DURATION_MINUTES,
|
||||
|
||||
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);
|
||||
|
||||
setSelectedPatient(
|
||||
|
||||
initialPatient
|
||||
|
||||
? {
|
||||
|
||||
id: initialPatient.id,
|
||||
|
||||
firstName: initialPatient.firstName,
|
||||
|
||||
lastName: initialPatient.lastName,
|
||||
|
||||
mobile: initialPatient.mobile,
|
||||
|
||||
}
|
||||
|
||||
: null,
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
}, [
|
||||
|
||||
open,
|
||||
|
||||
scheduleDate,
|
||||
|
||||
initialStartMinute,
|
||||
|
||||
editingAppointment?.id,
|
||||
|
||||
defaultPurpose,
|
||||
|
||||
initialPatient?.id,
|
||||
|
||||
setSearch,
|
||||
|
||||
]);
|
||||
|
||||
|
||||
|
||||
if (!open || !providerUserId) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function selectPatient(patient: Patient) {
|
||||
|
||||
if (patientLocked) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const next = {
|
||||
|
||||
id: patient.id,
|
||||
|
||||
firstName: patient.firstName,
|
||||
|
||||
lastName: patient.lastName,
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { TimeStepInput } from '@/components/ui/shared/TimeStepInput';
|
||||
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import {
|
||||
DROPDOWN_OPTION_BG,
|
||||
treatmentTypeColor,
|
||||
} from '@/components/shared/treatmentTypeDisplay';
|
||||
import type { Patient } from '@/types/patient';
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||
import {
|
||||
combineLocalDateAndTime,
|
||||
compareLocalDayStart,
|
||||
formatTimeForInput,
|
||||
isSameLocalCalendarDay,
|
||||
} from '@/components/appointments/appointmentTime';
|
||||
|
||||
const DEFAULT_DURATION_MINUTES = 30;
|
||||
|
||||
interface AppointmentBookingModalProps {
|
||||
open: boolean;
|
||||
scheduleDate: Date;
|
||||
/** Pre-selected patient from the page sidebar (optional). */
|
||||
initialPatient?: Patient;
|
||||
onPatientChange?: (patient: Patient | undefined) => void;
|
||||
canAddPatient?: boolean;
|
||||
onAddPatient?: () => void;
|
||||
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;
|
||||
}
|
||||
|
||||
function patientFromRecord(
|
||||
patient: AppointmentRecord['patient'],
|
||||
): Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> {
|
||||
return {
|
||||
id: patient.id,
|
||||
firstName: patient.firstName,
|
||||
lastName: patient.lastName,
|
||||
mobile: patient.mobile,
|
||||
};
|
||||
}
|
||||
|
||||
export function AppointmentBookingModal({
|
||||
open,
|
||||
scheduleDate,
|
||||
initialPatient,
|
||||
onPatientChange,
|
||||
canAddPatient = false,
|
||||
onAddPatient,
|
||||
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 startInputId = useId();
|
||||
const endInputId = useId();
|
||||
|
||||
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
|
||||
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('09:30');
|
||||
const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
|
||||
const [selectedPatient, setSelectedPatient] = useState<
|
||||
Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> | null
|
||||
>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open);
|
||||
|
||||
const patientLocked = Boolean(editingAppointment?.hasTreatment);
|
||||
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
|
||||
const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setSearch('');
|
||||
if (editingAppointment) {
|
||||
const start = new Date(editingAppointment.startAt);
|
||||
const end = new Date(editingAppointment.endAt);
|
||||
setStartTime(formatTimeForInput(start));
|
||||
setEndTime(formatTimeForInput(end));
|
||||
setPurpose(editingAppointment.purpose || defaultPurpose);
|
||||
setSelectedPatient(patientFromRecord(editingAppointment.patient));
|
||||
} else {
|
||||
const start = new Date(
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
scheduleDate.getDate(),
|
||||
Math.floor(initialStartMinute / 60),
|
||||
initialStartMinute % 60,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const endMinute = Math.min(
|
||||
initialStartMinute + DEFAULT_DURATION_MINUTES,
|
||||
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);
|
||||
setSelectedPatient(
|
||||
initialPatient
|
||||
? {
|
||||
id: initialPatient.id,
|
||||
firstName: initialPatient.firstName,
|
||||
lastName: initialPatient.lastName,
|
||||
mobile: initialPatient.mobile,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
setError('');
|
||||
}, [
|
||||
open,
|
||||
scheduleDate,
|
||||
initialStartMinute,
|
||||
editingAppointment?.id,
|
||||
defaultPurpose,
|
||||
initialPatient?.id,
|
||||
setSearch,
|
||||
]);
|
||||
|
||||
if (!open || !providerUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectPatient(patient: Patient) {
|
||||
if (patientLocked) {
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
id: patient.id,
|
||||
firstName: patient.firstName,
|
||||
lastName: patient.lastName,
|
||||
mobile: patient.mobile,
|
||||
};
|
||||
setSelectedPatient(next);
|
||||
onPatientChange?.(patient);
|
||||
if (error === t('errorSelectPatient')) {
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setError('');
|
||||
if (!providerUserId) {
|
||||
return;
|
||||
}
|
||||
if (!selectedPatient?.id) {
|
||||
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;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
patientId: selectedPatient.id,
|
||||
providerUserId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
purpose,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedForDisplay = selectedPatient
|
||||
? ({
|
||||
...selectedPatient,
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
} satisfies Patient)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ResponsiveDialogOverlay onBackdropClick={onClose} className="bg-black/55">
|
||||
<ResponsiveDialogPanel
|
||||
maxWidthClass="sm:max-w-md"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="appointment-modal-title"
|
||||
className="surface-card space-y-4"
|
||||
>
|
||||
<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 className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
{t('patientLabel')}
|
||||
</label>
|
||||
|
||||
<PatientSearchCombobox
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
patients={patients}
|
||||
loading={loadingPatients}
|
||||
selectedPatient={selectedForDisplay}
|
||||
onSelectPatient={selectPatient}
|
||||
showInlineSummary
|
||||
readOnly={patientLocked}
|
||||
readOnlyHint={patientLocked ? t('patientLockedHint') : undefined}
|
||||
canAddPatient={canAddPatient}
|
||||
onAddPatient={onAddPatient}
|
||||
placeholder={t('patientSearchPlaceholder')}
|
||||
emptyResultsMessage={t('patientSearchEmpty')}
|
||||
noPermissionMessage={t('noPermissionAdd')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<TimeStepInput
|
||||
id={startInputId}
|
||||
label={t('startLabel')}
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
<TimeStepInput
|
||||
id={endInputId}
|
||||
label={t('endLabel')}
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
label={t('purposeLabel')}
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
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>}
|
||||
|
||||
{editingAppointment?.hasTreatment ? (
|
||||
<p className="text-sm text-text-secondary">{t('deleteBlockedHint')}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
{editingAppointment && canDelete && onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => void onDelete()}
|
||||
disabled={loading || deleting}
|
||||
isLoading={deleting}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-2 sm:ml-auto w-full sm:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={loading || deleting}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => void handleSubmit()}
|
||||
isLoading={loading}
|
||||
disabled={deleting}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveDialogPanel>
|
||||
</ResponsiveDialogOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user