|
|
|
|
@@ -0,0 +1,373 @@
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
|
import { appointmentsApi } from '@/lib/api/appointments';
|
|
|
|
|
import { patientsApi } from '@/lib/api/patients';
|
|
|
|
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
|
|
|
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|
|
|
|
import { useAuth } from '@/lib/hooks/useAuth';
|
|
|
|
|
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
|
|
|
|
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
|
|
|
|
import type { CreatePatientInput, Patient } from '@/types/patient';
|
|
|
|
|
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
|
|
|
|
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
|
|
|
|
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
|
|
|
|
|
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
|
|
|
|
|
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
|
|
|
|
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
|
|
|
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
|
|
|
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
|
|
|
|
firstName: '',
|
|
|
|
|
lastName: '',
|
|
|
|
|
mobile: '',
|
|
|
|
|
email: '',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function AppointmentsPage() {
|
|
|
|
|
const t = useTranslations('appointments');
|
|
|
|
|
const tErrors = useTranslations('errors');
|
|
|
|
|
const tPatients = useTranslations('patients');
|
|
|
|
|
const { currentOrganization } = useAuth();
|
|
|
|
|
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
|
|
|
|
|
|
|
|
|
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
|
|
|
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
|
|
|
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
|
|
|
|
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [patients, setPatients] = useState<Patient[]>([]);
|
|
|
|
|
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
|
|
|
|
const [loadingPatients, setLoadingPatients] = useState(false);
|
|
|
|
|
|
|
|
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
|
|
|
const [savingPatient, setSavingPatient] = useState(false);
|
|
|
|
|
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
|
|
|
|
|
|
|
|
|
const [bookingOpen, setBookingOpen] = useState(false);
|
|
|
|
|
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
|
|
|
|
|
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
|
|
|
|
const [bookingProviderName, setBookingProviderName] = useState('');
|
|
|
|
|
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(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 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]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void loadSchedule();
|
|
|
|
|
}, [loadSchedule]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void treatmentCatalogApi
|
|
|
|
|
.list('appointment')
|
|
|
|
|
.then((r) => setTreatmentCatalog(r.data))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const t = setTimeout(() => {
|
|
|
|
|
void loadPatientsSearch(search);
|
|
|
|
|
}, 300);
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [search]);
|
|
|
|
|
|
|
|
|
|
async function loadPatientsSearch(q: string) {
|
|
|
|
|
if (!currentOrganization) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoadingPatients(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
|
|
|
|
const items = response.data.items;
|
|
|
|
|
setPatients(items);
|
|
|
|
|
if (selectedPatient) {
|
|
|
|
|
const stillThere = items.find((p) => p.id === selectedPatient.id);
|
|
|
|
|
if (stillThere) {
|
|
|
|
|
setSelectedPatient(stillThere);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setPatients([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingPatients(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCreatePatient() {
|
|
|
|
|
setSavingPatient(true);
|
|
|
|
|
toast.setError('');
|
|
|
|
|
try {
|
|
|
|
|
const response = await patientsApi.create(patientForm);
|
|
|
|
|
setIsCreateOpen(false);
|
|
|
|
|
setPatientForm(EMPTY_PATIENT_FORM);
|
|
|
|
|
await loadPatientsSearch(search);
|
|
|
|
|
setSelectedPatient(response.data);
|
|
|
|
|
if (response.existing) {
|
|
|
|
|
toast.showInfo(
|
|
|
|
|
tPatients('patientAlreadyExists', {
|
|
|
|
|
firstName: response.data.firstName,
|
|
|
|
|
lastName: response.data.lastName,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
toast.showSuccess(
|
|
|
|
|
t('successPatientSaved', {
|
|
|
|
|
firstName: response.data.firstName,
|
|
|
|
|
lastName: response.data.lastName,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
|
|
|
|
|
} finally {
|
|
|
|
|
setSavingPatient(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
|
|
|
|
if (!canManageAppointments) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isViewingPastDay) {
|
|
|
|
|
toast.showInfo(t('infoPastViewOnly'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!selectedPatient) {
|
|
|
|
|
toast.showInfo(t('infoSelectPatient'));
|
|
|
|
|
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 (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<ToastStack {...toast.messages} />
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
|
|
|
|
<div className="xl:col-span-1 space-y-4">
|
|
|
|
|
<AppointmentsPatientSearch
|
|
|
|
|
search={search}
|
|
|
|
|
onSearchChange={setSearch}
|
|
|
|
|
patients={sortedPatients}
|
|
|
|
|
selectedPatientId={selectedPatient?.id}
|
|
|
|
|
onSelectPatient={setSelectedPatient}
|
|
|
|
|
loading={loadingPatients}
|
|
|
|
|
canAddPatient={canEditPatients}
|
|
|
|
|
onAddPatient={() => {
|
|
|
|
|
if (!canEditPatients) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setPatientForm(EMPTY_PATIENT_FORM);
|
|
|
|
|
setIsCreateOpen(true);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<PatientSummaryCard patient={selectedPatient} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="xl:col-span-2 space-y-4">
|
|
|
|
|
<AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
|
|
|
|
<ScheduleDayPicker
|
|
|
|
|
value={scheduleDate}
|
|
|
|
|
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
|
|
|
|
|
/>
|
|
|
|
|
{loadingSchedule && (
|
|
|
|
|
<p className="text-sm text-text-muted pb-2">{t('loadingSchedule')}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AppointmentScheduleGrid
|
|
|
|
|
day={scheduleDate}
|
|
|
|
|
providers={providers}
|
|
|
|
|
appointments={appointments}
|
|
|
|
|
treatmentCatalog={treatmentCatalog}
|
|
|
|
|
canBook={canManageAppointments && !isViewingPastDay}
|
|
|
|
|
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
|
|
|
|
|
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
|
|
|
|
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AppointmentBookingModal
|
|
|
|
|
open={bookingOpen}
|
|
|
|
|
scheduleDate={scheduleDate}
|
|
|
|
|
patient={selectedPatient}
|
|
|
|
|
providerUserId={bookingProviderId}
|
|
|
|
|
providerName={bookingProviderName}
|
|
|
|
|
initialStartMinute={bookingStartMinute}
|
|
|
|
|
treatmentCatalog={treatmentCatalog}
|
|
|
|
|
editingAppointment={activeEditingAppointment}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setBookingOpen(false);
|
|
|
|
|
setEditingAppointmentId(null);
|
|
|
|
|
}}
|
|
|
|
|
onSubmit={handleSaveAppointment}
|
|
|
|
|
loading={savingAppointment}
|
|
|
|
|
canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment}
|
|
|
|
|
onDelete={() => void handleDeleteEditingAppointment()}
|
|
|
|
|
deleting={deletingAppointment}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<CreatePatientModal
|
|
|
|
|
variant="dialog"
|
|
|
|
|
isOpen={isCreateOpen}
|
|
|
|
|
formData={patientForm}
|
|
|
|
|
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
|
|
|
|
onSubmit={() => void handleCreatePatient()}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setIsCreateOpen(false);
|
|
|
|
|
setPatientForm(EMPTY_PATIENT_FORM);
|
|
|
|
|
}}
|
|
|
|
|
loading={savingPatient}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|