From 334b7841ae9986632841949f128a8987f244404e Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 16 Jul 2026 22:35:02 +0330 Subject: [PATCH] improvement: treatment exits contoll adde to appoinment remove action. --- backend/src/common/errors/error-codes.ts | 1 + .../appointments/appointments.service.ts | 12 +- frontend/messages/en.json | 2 + frontend/messages/fa.json | 2 + frontend/messages/nl.json | 2 + .../appointments/AppointmentBookingModal.tsx | 712 +++++++++--------- .../ui/appointments/AppointmentsPage.tsx | 581 +++++++------- 7 files changed, 666 insertions(+), 646 deletions(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 51932fa..e1c6608 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -63,6 +63,7 @@ export const ErrorCode = { CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', + APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 7af2525..0a4755f 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; @@ -16,6 +17,7 @@ import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { hasEffectivePermission } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; const MS_PER_DAY = 86_400_000; @@ -215,9 +217,7 @@ export class AppointmentsService { select: { id: true }, }); if (linkedTreatment) { - throw new BadRequestException( - 'Cannot change the patient while a treatment is linked to this appointment', - ); + throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT); } } @@ -256,13 +256,17 @@ export class AppointmentsService { const existing = await this.prisma.appointment.findFirst({ where: { id, organizationId }, - select: { id: true }, + select: { id: true, treatment: { select: { id: true } } }, }); if (!existing) { throw new NotFoundException('Appointment not found'); } + if (existing.treatment) { + throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT); + } + await this.prisma.appointment.delete({ where: { id }, }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b248bab..ae24ff6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -582,6 +582,7 @@ "confirmRemove": "Remove this appointment?", "successRemoved": "Appointment removed.", "errorDelete": "Could not delete appointment.", + "deleteBlockedHint": "This appointment cannot be deleted because a treatment is linked to it.", "errorLoadSchedule": "Failed to load schedule.", "successPatientSaved": "Patient {firstName} {lastName} was saved.", "searchPlaceholder": "Search existing patients", @@ -1013,6 +1014,7 @@ "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.", "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.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c3ee1ca..0f0e74b 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -583,6 +583,7 @@ "confirmRemove": "این نوبت حذف شود؟", "successRemoved": "نوبت حذف شد.", "errorDelete": "حذف نوبت امکان‌پذیر نبود.", + "deleteBlockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", "searchPlaceholder": "جستجوی بیماران موجود", @@ -1014,6 +1015,7 @@ "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", + "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index fa4556f..d3f94cd 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -582,6 +582,7 @@ "confirmRemove": "Deze afspraak verwijderen?", "successRemoved": "Afspraak verwijderd.", "errorDelete": "Kon afspraak niet verwijderen.", + "deleteBlockedHint": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "errorLoadSchedule": "Rooster laden mislukt.", "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", "searchPlaceholder": "Bestaande patiënten zoeken", @@ -1013,6 +1014,7 @@ "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.", "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.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 27d4fcd..b36111d 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -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; - treatmentCatalog: TreatmentCatalogEntry[]; - editingAppointment?: AppointmentRecord | null; - loading?: boolean; - canDelete?: boolean; - onDelete?: () => void | Promise; - deleting?: boolean; -} - -function patientFromRecord( - patient: AppointmentRecord['patient'], -): Pick { - 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(defaultPurpose); - const [selectedPatient, setSelectedPatient] = useState< - Pick | 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 ( - - -
-

- {editingAppointment ? t('editTitle') : t('newTitle')} -

- -
- -

- {t('providerLabel')}{' '} - {providerName} -

- -
- - - -
- -
- - -
- - setPurpose(e.target.value)} - style={{ color: purposeTextColor }} - > - {treatmentCatalog.map((entry, index) => ( - - ))} - - - {error &&

{error}

} - -
- {editingAppointment && canDelete && onDelete ? ( - - ) : null} -
- - -
-
-
-
- ); -} +'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; + treatmentCatalog: TreatmentCatalogEntry[]; + editingAppointment?: AppointmentRecord | null; + loading?: boolean; + canDelete?: boolean; + onDelete?: () => void | Promise; + deleting?: boolean; +} + +function patientFromRecord( + patient: AppointmentRecord['patient'], +): Pick { + 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(defaultPurpose); + const [selectedPatient, setSelectedPatient] = useState< + Pick | 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 ( + + +
+

+ {editingAppointment ? t('editTitle') : t('newTitle')} +

+ +
+ +

+ {t('providerLabel')}{' '} + {providerName} +

+ +
+ + + +
+ +
+ + +
+ + setPurpose(e.target.value)} + style={{ color: purposeTextColor }} + > + {treatmentCatalog.map((entry, index) => ( + + ))} + + + {error &&

{error}

} + + {editingAppointment?.hasTreatment ? ( +

{t('deleteBlockedHint')}

+ ) : null} + +
+ {editingAppointment && canDelete && onDelete ? ( + + ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index 89e9886..17b72ce 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -1,288 +1,293 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useRouter } from '@/i18n/navigation'; -import { appointmentsApi } from '@/lib/api/appointments'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { Patient } from '@/types/patient'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; - -export function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const router = useRouter(); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - const toast = useToast(); - - const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); - const [selectedPatient, setSelectedPatient] = useState(); - - const [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const navigateToAddPatient = useCallback(() => { - if (!canEditPatients) { - return; - } - router.push('/patients?action=create'); - }, [canEditPatients, router]); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data ?? []); - setAppointments(aRes.data ?? []); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data ?? [])) - .catch(() => {}); - }, []); - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - const start = new Date(appointment.startAt); - setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); - setBookingProviderId(appointment.providerUserId); - setBookingProviderName(provider?.name ?? bookingProviderName); - setEditingAppointmentId(appointment.id); - setBookingOpen(true); - } - - function handleAppointmentOutsideHours(appointment: AppointmentRecord) { - toast.showError(t('errorOutsideHours')); - } - - async function handleSaveAppointment(payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
-
- -
- -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> -
- ); -} +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; +import { appointmentsApi } from '@/lib/api/appointments'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; +import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import type { Patient } from '@/types/patient'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; +import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; +import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { useToast } from '@/lib/hooks/useToast'; +import type { AppointmentPurpose } from '@/types/appointment'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; + +export function AppointmentsPage() { + const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); + const router = useRouter(); + const { currentOrganization } = useAuth(); + const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); + + const [providers, setProviders] = useState([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + const toast = useToast(); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); + const [selectedPatient, setSelectedPatient] = useState(); + + const [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); + const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); + + const canManageAppointments = canEditAppointments(currentOrganization); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); + + const scheduleLoadGen = useRef(0); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + const navigateToAddPatient = useCallback(() => { + if (!canEditPatients) { + return; + } + router.push('/patients?action=create'); + }, [canEditPatients, router]); + + const loadSchedule = useCallback(async () => { + if (!currentOrganization?.id) { + return; + } + const gen = ++scheduleLoadGen.current; + setLoadingSchedule(true); + toast.setError(''); + try { + const range = getLocalDayIsoRange(scheduleDate); + const [pRes, aRes] = await Promise.all([ + appointmentsApi.columnProviders(scheduleDate), + appointmentsApi.list(range), + ]); + if (gen !== scheduleLoadGen.current) { + return; + } + setProviders(pRes.data ?? []); + setAppointments(aRes.data ?? []); + } catch (err: unknown) { + if (gen !== scheduleLoadGen.current) { + return; + } + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); + } finally { + if (gen === scheduleLoadGen.current) { + setLoadingSchedule(false); + } + } + }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); + + useEffect(() => { + void loadSchedule(); + }, [loadSchedule]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data ?? [])) + .catch(() => {}); + }, []); + + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + setBookingStartMinute(startMinute); + setBookingProviderId(providerUserId); + setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + const start = new Date(appointment.startAt); + setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); + setBookingProviderId(appointment.providerUserId); + setBookingProviderName(provider?.name ?? bookingProviderName); + setEditingAppointmentId(appointment.id); + setBookingOpen(true); + } + + function handleAppointmentOutsideHours(appointment: AppointmentRecord) { + toast.showError(t('errorOutsideHours')); + } + + async function handleSaveAppointment(payload: { + patientId: string; + providerUserId: string; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) { + setSavingAppointment(true); + toast.setError(''); + try { + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); + } finally { + setSavingAppointment(false); + } + } + + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } + if (!window.confirm(t('confirmRemove'))) { + return; + } + setDeletingAppointment(true); + toast.setError(''); + try { + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(t('successRemoved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); + } finally { + setDeletingAppointment(false); + } + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+
+ +
+ +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={ + canManageAppointments && + !isViewingPastDay && + !!activeEditingAppointment && + !activeEditingAppointment.hasTreatment + } + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> +
+ ); +}