feature: a minimal implementation of the appointment feature done.
This commit is contained in:
@@ -1,10 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { appointmentsApi } from '@/lib/api/appointments';
|
||||
import { patientsApi } from '@/lib/api/patients';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { canEditAppointments, hasPermission } from '@/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/common/ScheduleDayPicker';
|
||||
import type { AppointmentPurpose } from '@/types/appointment';
|
||||
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
||||
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { currentOrganization } = useAuth();
|
||||
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
||||
|
||||
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
||||
const [scheduleError, setScheduleError] = useState('');
|
||||
|
||||
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 [bookingHour, setBookingHour] = useState(9);
|
||||
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
||||
const [bookingProviderName, setBookingProviderName] = useState('');
|
||||
const [savingAppointment, setSavingAppointment] = useState(false);
|
||||
|
||||
const [toastError, setToastError] = useState('');
|
||||
const [toastSuccess, setToastSuccess] = useState('');
|
||||
const [toastInfo, setToastInfo] = useState('');
|
||||
|
||||
const canManageAppointments = canEditAppointments(currentOrganization);
|
||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||
|
||||
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
||||
|
||||
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);
|
||||
setScheduleError('');
|
||||
try {
|
||||
const range = getLocalDayIsoRange(scheduleDate);
|
||||
const [pRes, aRes] = await Promise.all([
|
||||
appointmentsApi.columnProviders(),
|
||||
appointmentsApi.list(range),
|
||||
]);
|
||||
if (gen !== scheduleLoadGen.current) {
|
||||
return;
|
||||
}
|
||||
setProviders(pRes.data);
|
||||
setAppointments(aRes.data);
|
||||
} catch (err: unknown) {
|
||||
if (gen !== scheduleLoadGen.current) {
|
||||
return;
|
||||
}
|
||||
setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.'));
|
||||
} finally {
|
||||
if (gen === scheduleLoadGen.current) {
|
||||
setLoadingSchedule(false);
|
||||
}
|
||||
}
|
||||
}, [currentOrganization?.id, scheduleDate]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSchedule();
|
||||
}, [loadSchedule]);
|
||||
|
||||
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);
|
||||
setToastError('');
|
||||
setToastSuccess('');
|
||||
try {
|
||||
const response = await patientsApi.create(patientForm);
|
||||
setIsCreateOpen(false);
|
||||
setPatientForm(EMPTY_PATIENT_FORM);
|
||||
await loadPatientsSearch(search);
|
||||
setSelectedPatient(response.data);
|
||||
setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: 'Failed to save patient.';
|
||||
setToastError(message);
|
||||
} finally {
|
||||
setSavingPatient(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
||||
if (!selectedPatient) {
|
||||
setToastSuccess('');
|
||||
setToastError('');
|
||||
setToastInfo('Select a patient before booking.');
|
||||
return;
|
||||
}
|
||||
setBookingHour(hour);
|
||||
setBookingProviderId(providerUserId);
|
||||
setBookingProviderName(providerName);
|
||||
setBookingOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveAppointment(payload: {
|
||||
patientId: string;
|
||||
providerUserId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
purpose: AppointmentPurpose;
|
||||
}) {
|
||||
setSavingAppointment(true);
|
||||
setToastError('');
|
||||
setToastSuccess('');
|
||||
setToastInfo('');
|
||||
try {
|
||||
await appointmentsApi.create(payload);
|
||||
setBookingOpen(false);
|
||||
setToastSuccess('Appointment saved.');
|
||||
await loadSchedule();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: 'Could not save appointment.';
|
||||
setToastError(message);
|
||||
} finally {
|
||||
setSavingAppointment(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAppointment(id: string) {
|
||||
if (!window.confirm('Remove this appointment?')) {
|
||||
return;
|
||||
}
|
||||
setToastError('');
|
||||
setToastSuccess('');
|
||||
setToastInfo('');
|
||||
try {
|
||||
await appointmentsApi.remove(id);
|
||||
setToastSuccess('Appointment removed.');
|
||||
await loadSchedule();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: 'Could not delete appointment.';
|
||||
setToastError(message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastSuccess) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setToastSuccess(''), 3200);
|
||||
return () => clearTimeout(id);
|
||||
}, [toastSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastError) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setToastError(''), 4000);
|
||||
return () => clearTimeout(id);
|
||||
}, [toastError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastInfo) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setToastInfo(''), 4000);
|
||||
return () => clearTimeout(id);
|
||||
}, [toastInfo]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Appointments module is coming soon.
|
||||
</p>
|
||||
<div className="relative space-y-6 pb-24">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-1 space-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Search a patient, pick a date, then click a time slot under a provider to book.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AppointmentsPatientSearch
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
patients={sortedPatients}
|
||||
selectedPatientId={selectedPatient?.id}
|
||||
onSelectPatient={setSelectedPatient}
|
||||
loading={loadingPatients}
|
||||
canAddPatient={canEditPatients}
|
||||
onAddPatient={() => {
|
||||
if (!canEditPatients) {
|
||||
return;
|
||||
}
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
/>
|
||||
<PatientSummaryCard patient={selectedPatient} />
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 space-y-4">
|
||||
<AppointmentScheduleLegend />
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
||||
<ScheduleDayPicker
|
||||
value={scheduleDate}
|
||||
minDate={todayStart}
|
||||
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
|
||||
/>
|
||||
{loadingSchedule && (
|
||||
<p className="text-sm text-text-muted pb-2">Loading schedule…</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scheduleError && (
|
||||
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
{scheduleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AppointmentScheduleGrid
|
||||
day={scheduleDate}
|
||||
providers={providers}
|
||||
appointments={appointments}
|
||||
canBook={canManageAppointments}
|
||||
canDelete={canManageAppointments}
|
||||
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
|
||||
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppointmentBookingModal
|
||||
open={bookingOpen}
|
||||
scheduleDate={scheduleDate}
|
||||
patient={selectedPatient}
|
||||
providerUserId={bookingProviderId}
|
||||
providerName={bookingProviderName}
|
||||
initialHour={bookingHour}
|
||||
onClose={() => setBookingOpen(false)}
|
||||
onSubmit={handleSaveAppointment}
|
||||
loading={savingAppointment}
|
||||
/>
|
||||
|
||||
{isCreateOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55">
|
||||
<CreatePatientModal
|
||||
isOpen={isCreateOpen}
|
||||
formData={patientForm}
|
||||
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
||||
onSubmit={() => void handleCreatePatient()}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
loading={savingPatient}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(toastError || toastSuccess || toastInfo) && (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-[70] flex justify-center pointer-events-none">
|
||||
<div className="pointer-events-auto w-full max-w-lg space-y-2">
|
||||
{toastError && (
|
||||
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
|
||||
{toastError}
|
||||
</div>
|
||||
)}
|
||||
{toastInfo && (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-500/45 bg-amber-500/10 px-3 py-2 text-sm text-amber-100 shadow-lg">
|
||||
{toastInfo}
|
||||
</div>
|
||||
)}
|
||||
{toastSuccess && (
|
||||
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
|
||||
{toastSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Sidebar from '@/components/ui/common/Sidebar';
|
||||
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
|
||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
firstAccessibleDashboardPath,
|
||||
getRequiredReadPermissionForPath,
|
||||
hasPermission,
|
||||
@@ -32,8 +33,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
}
|
||||
|
||||
const required = getRequiredReadPermissionForPath(pathname);
|
||||
if (required && !hasPermission(currentOrganization, required)) {
|
||||
router.replace(firstAccessibleDashboardPath(currentOrganization));
|
||||
if (required) {
|
||||
const allowed =
|
||||
hasPermission(currentOrganization, required) ||
|
||||
(required === 'TAB_APPOINTMENTS_READ' &&
|
||||
canAccessAppointmentsSection(currentOrganization));
|
||||
if (!allowed) {
|
||||
router.replace(firstAccessibleDashboardPath(currentOrganization));
|
||||
}
|
||||
}
|
||||
}, [isAuthReady, user, currentOrganization, router, pathname]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user