improvement/v1-demo-improvements. #62
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user