improvement: treatment exits contoll adde to appoinment remove action.

This commit is contained in:
2026-07-16 22:35:02 +03:30
parent e8e00e2911
commit 334b7841ae
7 changed files with 666 additions and 646 deletions

View File

@@ -63,6 +63,7 @@ export const ErrorCode = {
CONFLICT: 'CONFLICT', CONFLICT: 'CONFLICT',
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
BAD_REQUEST: 'BAD_REQUEST', BAD_REQUEST: 'BAD_REQUEST',
INTERNAL_ERROR: 'INTERNAL_ERROR', INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const; } as const;

View File

@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpStatus,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -16,6 +17,7 @@ import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { hasEffectivePermission } from '../../common/membership-permissions'; import { hasEffectivePermission } from '../../common/membership-permissions';
import { AppException, ErrorCode } from '../../common/errors';
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
@@ -215,9 +217,7 @@ export class AppointmentsService {
select: { id: true }, select: { id: true },
}); });
if (linkedTreatment) { if (linkedTreatment) {
throw new BadRequestException( throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT);
'Cannot change the patient while a treatment is linked to this appointment',
);
} }
} }
@@ -256,13 +256,17 @@ export class AppointmentsService {
const existing = await this.prisma.appointment.findFirst({ const existing = await this.prisma.appointment.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { id: true }, select: { id: true, treatment: { select: { id: true } } },
}); });
if (!existing) { if (!existing) {
throw new NotFoundException('Appointment not found'); throw new NotFoundException('Appointment not found');
} }
if (existing.treatment) {
throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT);
}
await this.prisma.appointment.delete({ await this.prisma.appointment.delete({
where: { id }, where: { id },
}); });

View File

@@ -582,6 +582,7 @@
"confirmRemove": "Remove this appointment?", "confirmRemove": "Remove this appointment?",
"successRemoved": "Appointment removed.", "successRemoved": "Appointment removed.",
"errorDelete": "Could not delete appointment.", "errorDelete": "Could not delete appointment.",
"deleteBlockedHint": "This appointment cannot be deleted because a treatment is linked to it.",
"errorLoadSchedule": "Failed to load schedule.", "errorLoadSchedule": "Failed to load schedule.",
"successPatientSaved": "Patient {firstName} {lastName} was saved.", "successPatientSaved": "Patient {firstName} {lastName} was saved.",
"searchPlaceholder": "Search existing patients", "searchPlaceholder": "Search existing patients",
@@ -1013,6 +1014,7 @@
"CONFLICT": "This action conflicts with existing data.", "CONFLICT": "This action conflicts with existing data.",
"CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.",
"APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.",
"APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.",
"BAD_REQUEST": "The request could not be processed.", "BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later." "INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
} }

View File

@@ -583,6 +583,7 @@
"confirmRemove": "این نوبت حذف شود؟", "confirmRemove": "این نوبت حذف شود؟",
"successRemoved": "نوبت حذف شد.", "successRemoved": "نوبت حذف شد.",
"errorDelete": "حذف نوبت امکان‌پذیر نبود.", "errorDelete": "حذف نوبت امکان‌پذیر نبود.",
"deleteBlockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.",
"errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.",
"successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.",
"searchPlaceholder": "جستجوی بیماران موجود", "searchPlaceholder": "جستجوی بیماران موجود",
@@ -1014,6 +1015,7 @@
"CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT": "این عمل با داده‌های موجود در تضاد است.",
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.",
"APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.",
"APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.", "BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
} }

View File

@@ -582,6 +582,7 @@
"confirmRemove": "Deze afspraak verwijderen?", "confirmRemove": "Deze afspraak verwijderen?",
"successRemoved": "Afspraak verwijderd.", "successRemoved": "Afspraak verwijderd.",
"errorDelete": "Kon afspraak niet verwijderen.", "errorDelete": "Kon afspraak niet verwijderen.",
"deleteBlockedHint": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.",
"errorLoadSchedule": "Rooster laden mislukt.", "errorLoadSchedule": "Rooster laden mislukt.",
"successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.",
"searchPlaceholder": "Bestaande patiënten zoeken", "searchPlaceholder": "Bestaande patiënten zoeken",
@@ -1013,6 +1014,7 @@
"CONFLICT": "Deze actie conflicteert met bestaande gegevens.", "CONFLICT": "Deze actie conflicteert met bestaande gegevens.",
"CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.",
"APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.",
"APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
} }

View File

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

View File

@@ -1,288 +1,293 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from '@/i18n/navigation';
import { appointmentsApi } from '@/lib/api/appointments';
import { useTranslations } from 'next-intl'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import { appointmentsApi } from '@/lib/api/appointments'; import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import type { Patient } from '@/types/patient';
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
import { useAuth } from '@/lib/hooks/useAuth'; import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { useToast } from '@/lib/hooks/useToast';
import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; import type { AppointmentPurpose } from '@/types/appointment';
import { getUserFacingError } from '@/components/shared/formatApiError';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { Patient } from '@/types/patient'; export function AppointmentsPage() {
const t = useTranslations('appointments');
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; const tErrors = useTranslations('errors');
const router = useRouter();
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast();
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery();
import { useToast } from '@/lib/hooks/useToast'; const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
import type { AppointmentPurpose } from '@/types/appointment'; const [bookingOpen, setBookingOpen] = useState(false);
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
import { getUserFacingError } from '@/components/shared/formatApiError'; const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState('');
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
const [savingAppointment, setSavingAppointment] = useState(false);
const [deletingAppointment, setDeletingAppointment] = useState(false);
export function AppointmentsPage() { const canManageAppointments = canEditAppointments(currentOrganization);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const t = useTranslations('appointments');
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const tErrors = useTranslations('errors'); const isViewingPastDay = useMemo(
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
const router = useRouter(); [scheduleDate, todayStart],
);
const { currentOrganization } = useAuth(); const activeEditingAppointment = useMemo(
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); [appointments, editingAppointmentId],
);
const scheduleLoadGen = useRef(0);
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
const sortedPatients = useMemo(
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]); () =>
[...patients].sort((a, b) =>
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]); `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
const [loadingSchedule, setLoadingSchedule] = useState(false); [patients],
);
const toast = useToast();
const navigateToAddPatient = useCallback(() => {
if (!canEditPatients) {
return;
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); }
router.push('/patients?action=create');
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); }, [canEditPatients, router]);
const loadSchedule = useCallback(async () => {
if (!currentOrganization?.id) {
const [bookingOpen, setBookingOpen] = useState(false); return;
}
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); const gen = ++scheduleLoadGen.current;
setLoadingSchedule(true);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null); toast.setError('');
try {
const [bookingProviderName, setBookingProviderName] = useState(''); const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null); appointmentsApi.columnProviders(scheduleDate),
appointmentsApi.list(range),
const [savingAppointment, setSavingAppointment] = useState(false); ]);
if (gen !== scheduleLoadGen.current) {
const [deletingAppointment, setDeletingAppointment] = useState(false); return;
}
setProviders(pRes.data ?? []);
setAppointments(aRes.data ?? []);
const canManageAppointments = canEditAppointments(currentOrganization); } catch (err: unknown) {
if (gen !== scheduleLoadGen.current) {
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); return;
}
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
} finally {
const todayStart = useMemo(() => startOfLocalDay(new Date()), []); if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
const isViewingPastDay = useMemo( }
}
() => compareLocalDayStart(scheduleDate, todayStart) < 0, }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]);
[scheduleDate, todayStart], useEffect(() => {
void loadSchedule();
); }, [loadSchedule]);
const activeEditingAppointment = useMemo( useEffect(() => {
void treatmentCatalogApi
() => appointments.find((a) => a.id === editingAppointmentId) ?? null, .list('appointment')
.then((r) => setTreatmentCatalog(r.data ?? []))
[appointments, editingAppointmentId], .catch(() => {});
}, []);
);
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (!canManageAppointments) {
return;
const scheduleLoadGen = useRef(0); }
if (isViewingPastDay) {
toast.showInfo(t('infoPastViewOnly'));
return;
const sortedPatients = useMemo( }
setBookingStartMinute(startMinute);
() => setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
[...patients].sort((a, b) => setEditingAppointmentId(null);
setBookingOpen(true);
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), }
), function handleAppointmentClick(appointment: AppointmentRecord) {
if (!canManageAppointments) {
[patients], return;
}
); if (isViewingPastDay) {
toast.showInfo(t('infoPastViewOnly'));
return;
}
const navigateToAddPatient = useCallback(() => { const provider = providers.find((p) => p.userId === appointment.providerUserId);
const start = new Date(appointment.startAt);
if (!canEditPatients) { setBookingStartMinute(start.getHours() * 60 + start.getMinutes());
setBookingProviderId(appointment.providerUserId);
return; setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id);
} setBookingOpen(true);
}
router.push('/patients?action=create');
function handleAppointmentOutsideHours(appointment: AppointmentRecord) {
}, [canEditPatients, router]); toast.showError(t('errorOutsideHours'));
}
async function handleSaveAppointment(payload: {
const loadSchedule = useCallback(async () => { patientId: string;
providerUserId: string;
if (!currentOrganization?.id) { startAt: string;
endAt: string;
return; purpose: AppointmentPurpose;
}) {
} setSavingAppointment(true);
toast.setError('');
const gen = ++scheduleLoadGen.current; try {
if (activeEditingAppointment) {
setLoadingSchedule(true); await appointmentsApi.update(activeEditingAppointment.id, payload);
} else {
toast.setError(''); await appointmentsApi.create(payload);
}
try { setBookingOpen(false);
setEditingAppointmentId(null);
const range = getLocalDayIsoRange(scheduleDate); toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
await loadSchedule();
const [pRes, aRes] = await Promise.all([ } catch (err: unknown) {
toast.showError(
appointmentsApi.columnProviders(scheduleDate), getUserFacingError(
err,
appointmentsApi.list(range), tErrors,
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
]); ),
);
if (gen !== scheduleLoadGen.current) { } finally {
setSavingAppointment(false);
return; }
}
}
async function handleDeleteEditingAppointment() {
setProviders(pRes.data ?? []); if (!activeEditingAppointment) {
return;
setAppointments(aRes.data ?? []); }
if (!window.confirm(t('confirmRemove'))) {
} catch (err: unknown) { return;
}
if (gen !== scheduleLoadGen.current) { setDeletingAppointment(true);
toast.setError('');
return; try {
await appointmentsApi.remove(activeEditingAppointment.id);
} setBookingOpen(false);
setEditingAppointmentId(null);
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); toast.showSuccess(t('successRemoved'));
await loadSchedule();
} finally { } catch (err: unknown) {
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
if (gen === scheduleLoadGen.current) { } finally {
setDeletingAppointment(false);
setLoadingSchedule(false); }
}
}
return (
} <div className="space-y-6">
<div className="flex flex-col gap-1">
}, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); <h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</div>
useEffect(() => { <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
void loadSchedule(); <div className="surface-card p-4">
<PatientSearchCombobox
}, [loadSchedule]); search={search}
onSearchChange={setSearch}
patients={sortedPatients}
loading={loadingPatients}
useEffect(() => { selectedPatient={selectedPatient}
onSelectPatient={setSelectedPatient}
void treatmentCatalogApi canAddPatient={canEditPatients}
onAddPatient={navigateToAddPatient}
.list('appointment') placeholder={t('searchPlaceholder')}
idleHint={t('searchHint')}
.then((r) => setTreatmentCatalog(r.data ?? [])) emptyResultsMessage={t('patientSearchEmpty')}
noPermissionMessage={t('noPermissionAdd')}
.catch(() => {}); />
</div>
}, []); <PatientSummaryCard patient={selectedPatient} />
</div>
<div className="xl:col-span-2 space-y-4">
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { <AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
if (!canManageAppointments) { <div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
return; value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
} />
{loadingSchedule && (
if (isViewingPastDay) { <p className="text-sm text-text-muted pb-2">{t('loadingSchedule')}</p>
)}
toast.showInfo(t('infoPastViewOnly')); </div>
return; <AppointmentScheduleGrid
day={scheduleDate}
} providers={providers}
appointments={appointments}
setBookingStartMinute(startMinute); treatmentCatalog={treatmentCatalog}
canBook={canManageAppointments && !isViewingPastDay}
setBookingProviderId(providerUserId); onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
setBookingProviderName(providerName); onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
/>
setEditingAppointmentId(null); </div>
</div>
setBookingOpen(true);
<AppointmentBookingModal
} open={bookingOpen}
scheduleDate={scheduleDate}
initialPatient={selectedPatient}
onPatientChange={setSelectedPatient}
function handleAppointmentClick(appointment: AppointmentRecord) { canAddPatient={canEditPatients}
onAddPatient={navigateToAddPatient}
if (!canManageAppointments) { providerUserId={bookingProviderId}
providerName={bookingProviderName}
return; initialStartMinute={bookingStartMinute}
treatmentCatalog={treatmentCatalog}
} editingAppointment={activeEditingAppointment}
onClose={() => {
if (isViewingPastDay) { setBookingOpen(false);
setEditingAppointmentId(null);
toast.showInfo(t('infoPastViewOnly')); }}
onSubmit={handleSaveAppointment}
return; loading={savingAppointment}
canDelete={
} canManageAppointments &&
!isViewingPastDay &&
const provider = providers.find((p) => p.userId === appointment.providerUserId); !!activeEditingAppointment &&
!activeEditingAppointment.hasTreatment
const start = new Date(appointment.startAt); }
onDelete={() => void handleDeleteEditingAppointment()}
deleting={deletingAppointment}
/>
</div>
);
}