improvement: treatment exits contoll adde to appoinment remove action.

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

View File

@@ -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."
}

View File

@@ -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": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
}

View File

@@ -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."
}

View File

@@ -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<void>;
treatmentCatalog: TreatmentCatalogEntry[];
editingAppointment?: AppointmentRecord | null;
loading?: boolean;
canDelete?: boolean;
onDelete?: () => void | Promise<void>;
deleting?: boolean;
}
function patientFromRecord(
patient: AppointmentRecord['patient'],
): Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> {
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<AppointmentPurpose>(defaultPurpose);
const [selectedPatient, setSelectedPatient] = useState<
Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> | 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,
'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<void>;
treatmentCatalog: TreatmentCatalogEntry[];
editingAppointment?: AppointmentRecord | null;
loading?: boolean;
canDelete?: boolean;
onDelete?: () => void | Promise<void>;
deleting?: boolean;
}
function patientFromRecord(
patient: AppointmentRecord['patient'],
): Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> {
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<AppointmentPurpose>(defaultPurpose);
const [selectedPatient, setSelectedPatient] = useState<
Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> | 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 (
<ResponsiveDialogOverlay onBackdropClick={onClose} className="bg-black/55">
<ResponsiveDialogPanel
maxWidthClass="sm:max-w-md"
role="dialog"
aria-modal="true"
aria-labelledby="appointment-modal-title"
className="surface-card space-y-4"
>
<div className="flex items-start justify-between gap-2">
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
{editingAppointment ? t('editTitle') : t('newTitle')}
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<p className="text-sm text-text-secondary">
{t('providerLabel')}{' '}
<span className="text-text-primary font-medium">{providerName}</span>
</p>
<div className="space-y-2">
<label className="block text-sm font-medium text-text-secondary">
{t('patientLabel')}
</label>
<PatientSearchCombobox
search={search}
onSearchChange={setSearch}
patients={patients}
loading={loadingPatients}
selectedPatient={selectedForDisplay}
onSelectPatient={selectPatient}
showInlineSummary
readOnly={patientLocked}
readOnlyHint={patientLocked ? t('patientLockedHint') : undefined}
canAddPatient={canAddPatient}
onAddPatient={onAddPatient}
placeholder={t('patientSearchPlaceholder')}
emptyResultsMessage={t('patientSearchEmpty')}
noPermissionMessage={t('noPermissionAdd')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TimeStepInput
id={startInputId}
label={t('startLabel')}
value={startTime}
onChange={setStartTime}
/>
<TimeStepInput
id={endInputId}
label={t('endLabel')}
value={endTime}
onChange={setEndTime}
/>
</div>
<Dropdown
label={t('purposeLabel')}
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
style={{ color: purposeTextColor }}
>
{treatmentCatalog.map((entry, index) => (
<option
key={entry.code}
value={entry.code}
style={{ color: treatmentTypeColor(entry.code, index), backgroundColor: DROPDOWN_OPTION_BG }}
>
{entry.label}
</option>
))}
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
{editingAppointment?.hasTreatment ? (
<p className="text-sm text-text-secondary">{t('deleteBlockedHint')}</p>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
{editingAppointment && canDelete && onDelete ? (
<Button
type="button"
variant="danger"
onClick={() => void onDelete()}
disabled={loading || deleting}
isLoading={deleting}
fullWidth
className="sm:w-auto"
>
{tCommon('delete')}
</Button>
) : null}
<div className="flex flex-col-reverse sm:flex-row gap-2 sm:ml-auto w-full sm:w-auto">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={loading || deleting}
fullWidth
className="sm:w-auto"
>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="primary"
onClick={() => void handleSubmit()}
isLoading={loading}
disabled={deleting}
fullWidth
className="sm:w-auto"
>
{tCommon('save')}
</Button>
</div>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
);
}

View File

@@ -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<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast();
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery();
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
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 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);
'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<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const toast = useToast();
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery();
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
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 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 (
<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>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
<div className="surface-card p-4">
<PatientSearchCombobox
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
loading={loadingPatients}
selectedPatient={selectedPatient}
onSelectPatient={setSelectedPatient}
canAddPatient={canEditPatients}
onAddPatient={navigateToAddPatient}
placeholder={t('searchPlaceholder')}
idleHint={t('searchHint')}
emptyResultsMessage={t('patientSearchEmpty')}
noPermissionMessage={t('noPermissionAdd')}
/>
</div>
<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}
initialPatient={selectedPatient}
onPatientChange={setSelectedPatient}
canAddPatient={canEditPatients}
onAddPatient={navigateToAddPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialStartMinute={bookingStartMinute}
treatmentCatalog={treatmentCatalog}
editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);
setEditingAppointmentId(null);
}}
onSubmit={handleSaveAppointment}
loading={savingAppointment}
canDelete={
canManageAppointments &&
!isViewingPastDay &&
!!activeEditingAppointment &&
!activeEditingAppointment.hasTreatment
}
onDelete={() => void handleDeleteEditingAppointment()}
deleting={deletingAppointment}
/>
</div>
);
}