improvement: appointment dialog UX improved.
This commit is contained in:
@@ -61,6 +61,7 @@ export const ErrorCode = {
|
|||||||
NOT_FOUND: 'NOT_FOUND',
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
CONFLICT: 'CONFLICT',
|
CONFLICT: 'CONFLICT',
|
||||||
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
||||||
|
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
|
||||||
BAD_REQUEST: 'BAD_REQUEST',
|
BAD_REQUEST: 'BAD_REQUEST',
|
||||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -104,11 +104,18 @@ export class AppointmentsService {
|
|||||||
patient: {
|
patient: {
|
||||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||||
},
|
},
|
||||||
|
treatment: { select: { id: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ startAt: 'asc' }],
|
orderBy: [{ startAt: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: items };
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map(({ treatment, ...appointment }) => ({
|
||||||
|
...appointment,
|
||||||
|
hasTreatment: Boolean(treatment),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -202,6 +209,18 @@ export class AppointmentsService {
|
|||||||
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
||||||
const purpose = dto.purpose ?? existing.purpose;
|
const purpose = dto.purpose ?? existing.purpose;
|
||||||
|
|
||||||
|
if (dto.patientId && dto.patientId !== existing.patientId) {
|
||||||
|
const linkedTreatment = await this.prisma.treatment.findUnique({
|
||||||
|
where: { appointmentId: id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (linkedTreatment) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot change the patient while a treatment is linked to this appointment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.treatmentCatalog.assertKnownTreatmentType(purpose);
|
this.treatmentCatalog.assertKnownTreatmentType(purpose);
|
||||||
|
|
||||||
await this.ensurePatientInOrg(patientId, organizationId);
|
await this.ensurePatientInOrg(patientId, organizationId);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
import { ErrorCode } from '../../../common/errors';
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
|
function emptyStringToUndefined({ value }: { value: unknown }): unknown {
|
||||||
|
if (typeof value === 'string' && value.trim() === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreatePatientDto {
|
export class CreatePatientDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||||
@@ -18,6 +26,7 @@ export class CreatePatientDto {
|
|||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(emptyStringToUndefined)
|
||||||
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -546,6 +546,9 @@
|
|||||||
"endLabel": "End",
|
"endLabel": "End",
|
||||||
"purposeLabel": "Purpose",
|
"purposeLabel": "Purpose",
|
||||||
"errorSelectPatient": "Select a patient first.",
|
"errorSelectPatient": "Select a patient first.",
|
||||||
|
"patientSearchPlaceholder": "Search patients by name, phone, or email",
|
||||||
|
"patientSearchEmpty": "No patients match this search.",
|
||||||
|
"patientLockedHint": "Patient cannot be changed because a treatment is linked to this appointment.",
|
||||||
"errorEndAfterStart": "End time must be after start time.",
|
"errorEndAfterStart": "End time must be after start time.",
|
||||||
"errorPastSchedule": "Cannot schedule in the past.",
|
"errorPastSchedule": "Cannot schedule in the past.",
|
||||||
"errorPastViewOnly": "Past appointments are view-only.",
|
"errorPastViewOnly": "Past appointments are view-only.",
|
||||||
@@ -901,6 +904,7 @@
|
|||||||
"NOT_FOUND": "The requested item was not found.",
|
"NOT_FOUND": "The requested item was not found.",
|
||||||
"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.",
|
||||||
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,6 +547,9 @@
|
|||||||
"endLabel": "پایان",
|
"endLabel": "پایان",
|
||||||
"purposeLabel": "هدف",
|
"purposeLabel": "هدف",
|
||||||
"errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.",
|
"errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.",
|
||||||
|
"patientSearchPlaceholder": "جستجوی بیمار بر اساس نام، تلفن یا ایمیل",
|
||||||
|
"patientSearchEmpty": "بیماری با این جستجو یافت نشد.",
|
||||||
|
"patientLockedHint": "بهدلیل وجود درمان مرتبط با این نوبت، امکان تغییر بیمار وجود ندارد.",
|
||||||
"errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.",
|
"errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.",
|
||||||
"errorPastSchedule": "نمیتوان در گذشته زمانبندی کرد.",
|
"errorPastSchedule": "نمیتوان در گذشته زمانبندی کرد.",
|
||||||
"errorPastViewOnly": "نوبتهای گذشته فقط قابل مشاهده هستند.",
|
"errorPastViewOnly": "نوبتهای گذشته فقط قابل مشاهده هستند.",
|
||||||
@@ -902,6 +905,7 @@
|
|||||||
"NOT_FOUND": "مورد درخواستی یافت نشد.",
|
"NOT_FOUND": "مورد درخواستی یافت نشد.",
|
||||||
"CONFLICT": "این عمل با دادههای موجود در تضاد است.",
|
"CONFLICT": "این عمل با دادههای موجود در تضاد است.",
|
||||||
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبتهای آینده دارید نمیتوانید مشارکت در درمان را متوقف کنید. ابتدا آنها را لغو یا واگذار کنید.",
|
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبتهای آینده دارید نمیتوانید مشارکت در درمان را متوقف کنید. ابتدا آنها را لغو یا واگذار کنید.",
|
||||||
|
"APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.",
|
||||||
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
||||||
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,6 +547,9 @@
|
|||||||
"endLabel": "Einde",
|
"endLabel": "Einde",
|
||||||
"purposeLabel": "Doel",
|
"purposeLabel": "Doel",
|
||||||
"errorSelectPatient": "Selecteer eerst een patiënt.",
|
"errorSelectPatient": "Selecteer eerst een patiënt.",
|
||||||
|
"patientSearchPlaceholder": "Zoek patiënten op naam, telefoon of e-mail",
|
||||||
|
"patientSearchEmpty": "Geen patiënten gevonden voor deze zoekopdracht.",
|
||||||
|
"patientLockedHint": "Patiënt kan niet worden gewijzigd omdat er een behandeling aan deze afspraak is gekoppeld.",
|
||||||
"errorEndAfterStart": "Eindtijd moet na de starttijd liggen.",
|
"errorEndAfterStart": "Eindtijd moet na de starttijd liggen.",
|
||||||
"errorPastSchedule": "Kan niet in het verleden plannen.",
|
"errorPastSchedule": "Kan niet in het verleden plannen.",
|
||||||
"errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.",
|
"errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.",
|
||||||
@@ -902,6 +905,7 @@
|
|||||||
"NOT_FOUND": "Het gevraagde item is niet gevonden.",
|
"NOT_FOUND": "Het gevraagde item is niet gevonden.",
|
||||||
"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.",
|
||||||
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,22 @@ export function compareLocalDayStart(a: Date, b: Date): number {
|
|||||||
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
|
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
|
||||||
return ta - tb;
|
return ta - tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseTimeInputToMinutes(time: string): number {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
if (!Number.isFinite(h) || !Number.isFinite(m)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToTimeInput(minutes: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(24 * 60 - 1, minutes));
|
||||||
|
const h = Math.floor(clamped / 60);
|
||||||
|
const m = clamped % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustTimeInput(time: string, deltaMinutes: number): string {
|
||||||
|
return minutesToTimeInput(parseTimeInputToMinutes(time) + deltaMinutes);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,286 +1,354 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { useEffect, useId, useState } from 'react';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
|
||||||
import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
|
||||||
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|
||||||
import {
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
DROPDOWN_OPTION_BG,
|
|
||||||
treatmentTypeColor,
|
import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog';
|
||||||
} from '@/components/shared/treatmentTypeDisplay';
|
|
||||||
import type { Patient } from '@/types/patient';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import {
|
|
||||||
combineLocalDateAndTime,
|
import { TimeStepInput } from '@/components/ui/shared/TimeStepInput';
|
||||||
compareLocalDayStart,
|
|
||||||
formatTimeForInput,
|
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||||
isSameLocalCalendarDay,
|
|
||||||
} from '@/components/appointments/appointmentTime';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
interface AppointmentBookingModalProps {
|
import {
|
||||||
open: boolean;
|
|
||||||
scheduleDate: Date;
|
DROPDOWN_OPTION_BG,
|
||||||
patient: Patient | undefined;
|
|
||||||
providerUserId: string | null;
|
treatmentTypeColor,
|
||||||
providerName: string;
|
|
||||||
initialStartMinute: number;
|
} from '@/components/shared/treatmentTypeDisplay';
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (payload: {
|
import type { Patient } from '@/types/patient';
|
||||||
patientId: string;
|
|
||||||
providerUserId: string;
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
startAt: string;
|
|
||||||
endAt: string;
|
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||||
purpose: AppointmentPurpose;
|
|
||||||
}) => Promise<void>;
|
import {
|
||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
|
||||||
editingAppointment?: AppointmentRecord | null;
|
combineLocalDateAndTime,
|
||||||
loading?: boolean;
|
|
||||||
canDelete?: boolean;
|
compareLocalDayStart,
|
||||||
onDelete?: () => void | Promise<void>;
|
|
||||||
deleting?: boolean;
|
formatTimeForInput,
|
||||||
}
|
|
||||||
|
isSameLocalCalendarDay,
|
||||||
export function AppointmentBookingModal({
|
|
||||||
open,
|
} from '@/components/appointments/appointmentTime';
|
||||||
scheduleDate,
|
|
||||||
patient,
|
|
||||||
providerUserId,
|
|
||||||
providerName,
|
const DEFAULT_DURATION_MINUTES = 30;
|
||||||
initialStartMinute,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
treatmentCatalog,
|
interface AppointmentBookingModalProps {
|
||||||
editingAppointment = null,
|
|
||||||
loading = false,
|
open: boolean;
|
||||||
canDelete = false,
|
|
||||||
onDelete,
|
scheduleDate: Date;
|
||||||
deleting = false,
|
|
||||||
}: AppointmentBookingModalProps) {
|
/** Pre-selected patient from the page sidebar (optional). */
|
||||||
const t = useTranslations('appointments');
|
|
||||||
const tCommon = useTranslations('common');
|
initialPatient?: Patient;
|
||||||
const tPatients = useTranslations('patients');
|
|
||||||
|
onPatientChange?: (patient: Patient | undefined) => void;
|
||||||
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
|
|
||||||
|
canAddPatient?: boolean;
|
||||||
const [startTime, setStartTime] = useState('09:00');
|
|
||||||
const [endTime, setEndTime] = useState('10:00');
|
onAddPatient?: () => void;
|
||||||
const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
|
|
||||||
const [error, setError] = useState('');
|
providerUserId: string | null;
|
||||||
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
|
|
||||||
const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
|
providerName: string;
|
||||||
|
|
||||||
useEffect(() => {
|
initialStartMinute: number;
|
||||||
if (!open) {
|
|
||||||
return;
|
onClose: () => void;
|
||||||
}
|
|
||||||
if (editingAppointment) {
|
onSubmit: (payload: {
|
||||||
const start = new Date(editingAppointment.startAt);
|
|
||||||
const end = new Date(editingAppointment.endAt);
|
patientId: string;
|
||||||
setStartTime(formatTimeForInput(start));
|
|
||||||
setEndTime(formatTimeForInput(end));
|
providerUserId: string;
|
||||||
setPurpose(editingAppointment.purpose || defaultPurpose);
|
|
||||||
} else {
|
startAt: string;
|
||||||
const start = new Date(
|
|
||||||
scheduleDate.getFullYear(),
|
endAt: string;
|
||||||
scheduleDate.getMonth(),
|
|
||||||
scheduleDate.getDate(),
|
purpose: AppointmentPurpose;
|
||||||
Math.floor(initialStartMinute / 60),
|
|
||||||
initialStartMinute % 60,
|
}) => Promise<void>;
|
||||||
0,
|
|
||||||
0,
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
);
|
|
||||||
const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1);
|
editingAppointment?: AppointmentRecord | null;
|
||||||
const end = new Date(
|
|
||||||
scheduleDate.getFullYear(),
|
loading?: boolean;
|
||||||
scheduleDate.getMonth(),
|
|
||||||
scheduleDate.getDate(),
|
canDelete?: boolean;
|
||||||
Math.floor(endMinute / 60),
|
|
||||||
endMinute % 60,
|
onDelete?: () => void | Promise<void>;
|
||||||
0,
|
|
||||||
0,
|
deleting?: boolean;
|
||||||
);
|
|
||||||
setStartTime(formatTimeForInput(start));
|
}
|
||||||
setEndTime(formatTimeForInput(end));
|
|
||||||
setPurpose(defaultPurpose);
|
|
||||||
}
|
|
||||||
setError('');
|
function patientFromRecord(
|
||||||
}, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]);
|
|
||||||
|
patient: AppointmentRecord['patient'],
|
||||||
if (!open || !providerUserId) {
|
|
||||||
return null;
|
): Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> {
|
||||||
}
|
|
||||||
|
return {
|
||||||
const inputClass =
|
|
||||||
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
|
id: patient.id,
|
||||||
|
|
||||||
async function handleSubmit() {
|
firstName: patient.firstName,
|
||||||
setError('');
|
|
||||||
if (!providerUserId) {
|
lastName: patient.lastName,
|
||||||
return;
|
|
||||||
}
|
mobile: patient.mobile,
|
||||||
if (!editingAppointment && !patient) {
|
|
||||||
setError(t('errorSelectPatient'));
|
};
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAt = combineLocalDateAndTime(scheduleDate, startTime);
|
|
||||||
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
|
|
||||||
|
export function AppointmentBookingModal({
|
||||||
if (endAt <= startAt) {
|
|
||||||
setError(t('errorEndAfterStart'));
|
open,
|
||||||
return;
|
|
||||||
}
|
scheduleDate,
|
||||||
|
|
||||||
const now = new Date();
|
initialPatient,
|
||||||
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
|
|
||||||
setError(t('errorPastSchedule'));
|
onPatientChange,
|
||||||
return;
|
|
||||||
}
|
canAddPatient = false,
|
||||||
|
|
||||||
const today = new Date();
|
onAddPatient,
|
||||||
if (compareLocalDayStart(scheduleDate, today) < 0) {
|
|
||||||
setError(t('errorPastViewOnly'));
|
providerUserId,
|
||||||
return;
|
|
||||||
}
|
providerName,
|
||||||
|
|
||||||
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
|
initialStartMinute,
|
||||||
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
|
|
||||||
if (!effectivePatientId || !effectiveProviderId) {
|
onClose,
|
||||||
setError(t('errorMissingDetails'));
|
|
||||||
return;
|
onSubmit,
|
||||||
}
|
|
||||||
|
treatmentCatalog,
|
||||||
await onSubmit({
|
|
||||||
patientId: effectivePatientId,
|
editingAppointment = null,
|
||||||
providerUserId: effectiveProviderId,
|
|
||||||
startAt: startAt.toISOString(),
|
loading = false,
|
||||||
endAt: endAt.toISOString(),
|
|
||||||
purpose,
|
canDelete = false,
|
||||||
});
|
|
||||||
}
|
onDelete,
|
||||||
|
|
||||||
return (
|
deleting = false,
|
||||||
<ResponsiveDialogOverlay onBackdropClick={onClose} className="bg-black/55">
|
|
||||||
<ResponsiveDialogPanel
|
}: AppointmentBookingModalProps) {
|
||||||
maxWidthClass="sm:max-w-md"
|
|
||||||
role="dialog"
|
const t = useTranslations('appointments');
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="appointment-modal-title"
|
const tCommon = useTranslations('common');
|
||||||
className="surface-card space-y-4"
|
|
||||||
>
|
const startInputId = useId();
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
const endInputId = useId();
|
||||||
{editingAppointment ? t('editTitle') : t('newTitle')}
|
|
||||||
</h2>
|
|
||||||
<DialogCloseButton onClick={onClose} />
|
|
||||||
</div>
|
const defaultPurpose = treatmentCatalog[0]?.code ?? '';
|
||||||
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
{t('providerLabel')}{' '}
|
|
||||||
<span className="text-text-primary font-medium">{providerName}</span>
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
</p>
|
|
||||||
|
const [endTime, setEndTime] = useState('09:30');
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
const [purpose, setPurpose] = useState<AppointmentPurpose>(defaultPurpose);
|
||||||
{t('patientLabel')}
|
|
||||||
</label>
|
const [selectedPatient, setSelectedPatient] = useState<
|
||||||
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
|
||||||
{editingAppointment
|
Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'> | null
|
||||||
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
|
|
||||||
: patient
|
>(null);
|
||||||
? `${patient.firstName} ${patient.lastName}`
|
|
||||||
: tPatients('emptyValue')}
|
const [error, setError] = useState('');
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open);
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
||||||
{t('startLabel')}
|
|
||||||
</label>
|
const patientLocked = Boolean(editingAppointment?.hasTreatment);
|
||||||
<input
|
|
||||||
type="time"
|
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose);
|
||||||
step={60}
|
|
||||||
className={inputClass}
|
const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex);
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
useEffect(() => {
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
if (!open) {
|
||||||
{t('endLabel')}
|
|
||||||
</label>
|
return;
|
||||||
<input
|
|
||||||
type="time"
|
}
|
||||||
step={60}
|
|
||||||
className={inputClass}
|
setSearch('');
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
if (editingAppointment) {
|
||||||
/>
|
|
||||||
</div>
|
const start = new Date(editingAppointment.startAt);
|
||||||
</div>
|
|
||||||
|
const end = new Date(editingAppointment.endAt);
|
||||||
<Dropdown
|
|
||||||
label={t('purposeLabel')}
|
setStartTime(formatTimeForInput(start));
|
||||||
value={purpose}
|
|
||||||
onChange={(e) => setPurpose(e.target.value)}
|
setEndTime(formatTimeForInput(end));
|
||||||
style={{ color: purposeTextColor }}
|
|
||||||
>
|
setPurpose(editingAppointment.purpose || defaultPurpose);
|
||||||
{treatmentCatalog.map((entry, index) => (
|
|
||||||
<option
|
setSelectedPatient(patientFromRecord(editingAppointment.patient));
|
||||||
key={entry.code}
|
|
||||||
value={entry.code}
|
} else {
|
||||||
style={{ color: treatmentTypeColor(entry.code, index), backgroundColor: DROPDOWN_OPTION_BG }}
|
|
||||||
>
|
const start = new Date(
|
||||||
{entry.label}
|
|
||||||
</option>
|
scheduleDate.getFullYear(),
|
||||||
))}
|
|
||||||
</Dropdown>
|
scheduleDate.getMonth(),
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
scheduleDate.getDate(),
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
Math.floor(initialStartMinute / 60),
|
||||||
{editingAppointment && canDelete && onDelete ? (
|
|
||||||
<Button
|
initialStartMinute % 60,
|
||||||
type="button"
|
|
||||||
variant="danger"
|
0,
|
||||||
onClick={() => void onDelete()}
|
|
||||||
disabled={loading || deleting}
|
0,
|
||||||
isLoading={deleting}
|
|
||||||
fullWidth
|
);
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
const endMinute = Math.min(
|
||||||
{tCommon('delete')}
|
|
||||||
</Button>
|
initialStartMinute + DEFAULT_DURATION_MINUTES,
|
||||||
) : null}
|
|
||||||
<div className="flex flex-col-reverse sm:flex-row gap-2 sm:ml-auto w-full sm:w-auto">
|
24 * 60 - 1,
|
||||||
<Button
|
|
||||||
type="button"
|
);
|
||||||
variant="ghost"
|
|
||||||
onClick={onClose}
|
const end = new Date(
|
||||||
disabled={loading || deleting}
|
|
||||||
fullWidth
|
scheduleDate.getFullYear(),
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
scheduleDate.getMonth(),
|
||||||
{tCommon('cancel')}
|
|
||||||
</Button>
|
scheduleDate.getDate(),
|
||||||
<Button
|
|
||||||
type="button"
|
Math.floor(endMinute / 60),
|
||||||
variant="primary"
|
|
||||||
onClick={() => void handleSubmit()}
|
endMinute % 60,
|
||||||
isLoading={loading}
|
|
||||||
disabled={deleting}
|
0,
|
||||||
fullWidth
|
|
||||||
className="sm:w-auto"
|
0,
|
||||||
>
|
|
||||||
{tCommon('save')}
|
);
|
||||||
</Button>
|
|
||||||
</div>
|
setStartTime(formatTimeForInput(start));
|
||||||
</div>
|
|
||||||
</ResponsiveDialogPanel>
|
setEndTime(formatTimeForInput(end));
|
||||||
</ResponsiveDialogOverlay>
|
|
||||||
);
|
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,
|
||||||
|
|
||||||
|
|||||||
@@ -326,12 +326,12 @@ export function AppointmentScheduleGrid({
|
|||||||
e.currentTarget,
|
e.currentTarget,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
|
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
|
||||||
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
||||||
} ${
|
} ${
|
||||||
isUnderOneHour
|
isUnderOneHour
|
||||||
? 'items-center justify-center px-0.5 py-0'
|
? 'items-center justify-center px-0.5 py-0'
|
||||||
: 'flex-col justify-start gap-0.5 px-1 py-0.5'
|
: 'flex-col items-center justify-center gap-0.5 px-1 py-0.5'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
top: pos.top,
|
top: pos.top,
|
||||||
@@ -343,19 +343,19 @@ export function AppointmentScheduleGrid({
|
|||||||
title={bannerTitle}
|
title={bannerTitle}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
|
className={`block w-full truncate pointer-events-none text-center font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
|
||||||
>
|
>
|
||||||
{patientName}
|
{patientName}
|
||||||
</span>
|
</span>
|
||||||
{!isUnderOneHour &&
|
{!isUnderOneHour &&
|
||||||
apt.patient.mobile &&
|
apt.patient.mobile &&
|
||||||
lane.laneCount === 1 && (
|
lane.laneCount === 1 && (
|
||||||
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
|
<span className="block w-full truncate pointer-events-none text-center text-[10px] leading-tight opacity-90">
|
||||||
{formatMobileForDisplay(apt.patient.mobile)}
|
{formatMobileForDisplay(apt.patient.mobile)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isUnderOneHour && clusterSize > 1 && (
|
{!isUnderOneHour && clusterSize > 1 && (
|
||||||
<span className="block w-full truncate pointer-events-none text-[9px] leading-tight opacity-75">
|
<span className="block w-full truncate pointer-events-none text-center text-[9px] leading-tight opacity-75">
|
||||||
{t('overlapping', { count: clusterSize })}
|
{t('overlapping', { count: clusterSize })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,370 +1,288 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { appointmentsApi } from '@/lib/api/appointments';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { useTranslations } from 'next-intl';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
|
|
||||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
import { appointmentsApi } from '@/lib/api/appointments';
|
||||||
import type { CreatePatientInput, Patient } from '@/types/patient';
|
|
||||||
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
|
||||||
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
|
|
||||||
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
|
||||||
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
|
||||||
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
import type { Patient } from '@/types/patient';
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
||||||
mobile: '',
|
|
||||||
email: '',
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
};
|
|
||||||
|
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
|
||||||
export function AppointmentsPage() {
|
|
||||||
const t = useTranslations('appointments');
|
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
|
||||||
const tErrors = useTranslations('errors');
|
|
||||||
const tPatients = useTranslations('patients');
|
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
||||||
const { currentOrganization } = useAuth();
|
|
||||||
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
|
|
||||||
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
|
||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
|
||||||
const toast = useToast();
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
|
||||||
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
|
||||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
|
||||||
|
export function AppointmentsPage() {
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
||||||
const [savingPatient, setSavingPatient] = useState(false);
|
const t = useTranslations('appointments');
|
||||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const [bookingOpen, setBookingOpen] = useState(false);
|
|
||||||
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
|
const router = useRouter();
|
||||||
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
|
||||||
const [bookingProviderName, setBookingProviderName] = useState('');
|
const { currentOrganization } = useAuth();
|
||||||
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
|
||||||
const [savingAppointment, setSavingAppointment] = useState(false);
|
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
||||||
const [deletingAppointment, setDeletingAppointment] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const canManageAppointments = canEditAppointments(currentOrganization);
|
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
||||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
|
||||||
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||||
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
|
||||||
const isViewingPastDay = useMemo(
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
|
|
||||||
[scheduleDate, todayStart],
|
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
||||||
);
|
|
||||||
const activeEditingAppointment = useMemo(
|
const toast = useToast();
|
||||||
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
|
|
||||||
[appointments, editingAppointmentId],
|
|
||||||
);
|
|
||||||
|
const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery();
|
||||||
const scheduleLoadGen = useRef(0);
|
|
||||||
|
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
||||||
const sortedPatients = useMemo(
|
|
||||||
() =>
|
|
||||||
[...patients].sort((a, b) =>
|
|
||||||
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
),
|
|
||||||
[patients],
|
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
|
||||||
);
|
|
||||||
|
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
||||||
const loadSchedule = useCallback(async () => {
|
|
||||||
if (!currentOrganization?.id) {
|
const [bookingProviderName, setBookingProviderName] = useState('');
|
||||||
return;
|
|
||||||
}
|
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
||||||
const gen = ++scheduleLoadGen.current;
|
|
||||||
setLoadingSchedule(true);
|
const [savingAppointment, setSavingAppointment] = useState(false);
|
||||||
toast.setError('');
|
|
||||||
try {
|
const [deletingAppointment, setDeletingAppointment] = useState(false);
|
||||||
const range = getLocalDayIsoRange(scheduleDate);
|
|
||||||
const [pRes, aRes] = await Promise.all([
|
|
||||||
appointmentsApi.columnProviders(scheduleDate),
|
|
||||||
appointmentsApi.list(range),
|
const canManageAppointments = canEditAppointments(currentOrganization);
|
||||||
]);
|
|
||||||
if (gen !== scheduleLoadGen.current) {
|
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||||
return;
|
|
||||||
}
|
|
||||||
setProviders(pRes.data);
|
|
||||||
setAppointments(aRes.data);
|
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
||||||
} catch (err: unknown) {
|
|
||||||
if (gen !== scheduleLoadGen.current) {
|
const isViewingPastDay = useMemo(
|
||||||
return;
|
|
||||||
}
|
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
|
||||||
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
|
|
||||||
} finally {
|
[scheduleDate, todayStart],
|
||||||
if (gen === scheduleLoadGen.current) {
|
|
||||||
setLoadingSchedule(false);
|
);
|
||||||
}
|
|
||||||
}
|
const activeEditingAppointment = useMemo(
|
||||||
}, [currentOrganization?.id, scheduleDate, t]);
|
|
||||||
|
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
|
||||||
useEffect(() => {
|
|
||||||
void loadSchedule();
|
[appointments, editingAppointmentId],
|
||||||
}, [loadSchedule]);
|
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
void treatmentCatalogApi
|
|
||||||
.list('appointment')
|
|
||||||
.then((r) => setTreatmentCatalog(r.data))
|
const scheduleLoadGen = useRef(0);
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const sortedPatients = useMemo(
|
||||||
const t = setTimeout(() => {
|
|
||||||
void loadPatientsSearch(search);
|
() =>
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(t);
|
[...patients].sort((a, b) =>
|
||||||
}, [search]);
|
|
||||||
|
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
|
||||||
async function loadPatientsSearch(q: string) {
|
|
||||||
if (!currentOrganization) {
|
),
|
||||||
return;
|
|
||||||
}
|
[patients],
|
||||||
setLoadingPatients(true);
|
|
||||||
try {
|
);
|
||||||
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
|
||||||
const items = response.data.items;
|
|
||||||
setPatients(items);
|
|
||||||
if (selectedPatient) {
|
const navigateToAddPatient = useCallback(() => {
|
||||||
const stillThere = items.find((p) => p.id === selectedPatient.id);
|
|
||||||
if (stillThere) {
|
if (!canEditPatients) {
|
||||||
setSelectedPatient(stillThere);
|
|
||||||
}
|
return;
|
||||||
}
|
|
||||||
} catch {
|
}
|
||||||
setPatients([]);
|
|
||||||
} finally {
|
router.push('/patients?action=create');
|
||||||
setLoadingPatients(false);
|
|
||||||
}
|
}, [canEditPatients, router]);
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreatePatient() {
|
|
||||||
setSavingPatient(true);
|
const loadSchedule = useCallback(async () => {
|
||||||
toast.setError('');
|
|
||||||
try {
|
if (!currentOrganization?.id) {
|
||||||
const response = await patientsApi.create(patientForm);
|
|
||||||
setIsCreateOpen(false);
|
return;
|
||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
|
||||||
await loadPatientsSearch(search);
|
}
|
||||||
setSelectedPatient(response.data);
|
|
||||||
if (response.existing) {
|
const gen = ++scheduleLoadGen.current;
|
||||||
toast.showInfo(
|
|
||||||
tPatients('patientAlreadyExists', {
|
setLoadingSchedule(true);
|
||||||
firstName: response.data.firstName,
|
|
||||||
lastName: response.data.lastName,
|
toast.setError('');
|
||||||
}),
|
|
||||||
);
|
try {
|
||||||
} else {
|
|
||||||
toast.showSuccess(
|
const range = getLocalDayIsoRange(scheduleDate);
|
||||||
t('successPatientSaved', {
|
|
||||||
firstName: response.data.firstName,
|
const [pRes, aRes] = await Promise.all([
|
||||||
lastName: response.data.lastName,
|
|
||||||
}),
|
appointmentsApi.columnProviders(scheduleDate),
|
||||||
);
|
|
||||||
}
|
appointmentsApi.list(range),
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
|
]);
|
||||||
} finally {
|
|
||||||
setSavingPatient(false);
|
if (gen !== scheduleLoadGen.current) {
|
||||||
}
|
|
||||||
}
|
return;
|
||||||
|
|
||||||
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
}
|
||||||
if (!canManageAppointments) {
|
|
||||||
return;
|
setProviders(pRes.data ?? []);
|
||||||
}
|
|
||||||
if (isViewingPastDay) {
|
setAppointments(aRes.data ?? []);
|
||||||
toast.showInfo(t('infoPastViewOnly'));
|
|
||||||
return;
|
} catch (err: unknown) {
|
||||||
}
|
|
||||||
if (!selectedPatient) {
|
if (gen !== scheduleLoadGen.current) {
|
||||||
toast.showInfo(t('infoSelectPatient'));
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
setBookingStartMinute(startMinute);
|
}
|
||||||
setBookingProviderId(providerUserId);
|
|
||||||
setBookingProviderName(providerName);
|
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
|
||||||
setEditingAppointmentId(null);
|
|
||||||
setBookingOpen(true);
|
} finally {
|
||||||
}
|
|
||||||
|
if (gen === scheduleLoadGen.current) {
|
||||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
|
||||||
if (!canManageAppointments) {
|
setLoadingSchedule(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (isViewingPastDay) {
|
|
||||||
toast.showInfo(t('infoPastViewOnly'));
|
}
|
||||||
return;
|
|
||||||
}
|
}, [currentOrganization?.id, scheduleDate, t, tErrors, toast]);
|
||||||
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
|
||||||
const start = new Date(appointment.startAt);
|
|
||||||
setBookingStartMinute(start.getHours() * 60 + start.getMinutes());
|
|
||||||
setBookingProviderId(appointment.providerUserId);
|
useEffect(() => {
|
||||||
setBookingProviderName(provider?.name ?? bookingProviderName);
|
|
||||||
setEditingAppointmentId(appointment.id);
|
void loadSchedule();
|
||||||
setBookingOpen(true);
|
|
||||||
}
|
}, [loadSchedule]);
|
||||||
|
|
||||||
function handleAppointmentOutsideHours(appointment: AppointmentRecord) {
|
|
||||||
toast.showError(t('errorOutsideHours'));
|
|
||||||
}
|
useEffect(() => {
|
||||||
|
|
||||||
async function handleSaveAppointment(payload: {
|
void treatmentCatalogApi
|
||||||
patientId: string;
|
|
||||||
providerUserId: string;
|
.list('appointment')
|
||||||
startAt: string;
|
|
||||||
endAt: string;
|
.then((r) => setTreatmentCatalog(r.data ?? []))
|
||||||
purpose: AppointmentPurpose;
|
|
||||||
}) {
|
.catch(() => {});
|
||||||
setSavingAppointment(true);
|
|
||||||
toast.setError('');
|
}, []);
|
||||||
try {
|
|
||||||
if (activeEditingAppointment) {
|
|
||||||
await appointmentsApi.update(activeEditingAppointment.id, payload);
|
|
||||||
} else {
|
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
||||||
await appointmentsApi.create(payload);
|
|
||||||
}
|
if (!canManageAppointments) {
|
||||||
setBookingOpen(false);
|
|
||||||
setEditingAppointmentId(null);
|
return;
|
||||||
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
|
|
||||||
await loadSchedule();
|
}
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.showError(
|
if (isViewingPastDay) {
|
||||||
getUserFacingError(
|
|
||||||
err,
|
toast.showInfo(t('infoPastViewOnly'));
|
||||||
tErrors,
|
|
||||||
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
|
return;
|
||||||
),
|
|
||||||
);
|
}
|
||||||
} finally {
|
|
||||||
setSavingAppointment(false);
|
setBookingStartMinute(startMinute);
|
||||||
}
|
|
||||||
}
|
setBookingProviderId(providerUserId);
|
||||||
|
|
||||||
async function handleDeleteEditingAppointment() {
|
setBookingProviderName(providerName);
|
||||||
if (!activeEditingAppointment) {
|
|
||||||
return;
|
setEditingAppointmentId(null);
|
||||||
}
|
|
||||||
if (!window.confirm(t('confirmRemove'))) {
|
setBookingOpen(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setDeletingAppointment(true);
|
|
||||||
toast.setError('');
|
|
||||||
try {
|
|
||||||
await appointmentsApi.remove(activeEditingAppointment.id);
|
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||||
setBookingOpen(false);
|
|
||||||
setEditingAppointmentId(null);
|
if (!canManageAppointments) {
|
||||||
toast.showSuccess(t('successRemoved'));
|
|
||||||
await loadSchedule();
|
return;
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
|
}
|
||||||
} finally {
|
|
||||||
setDeletingAppointment(false);
|
if (isViewingPastDay) {
|
||||||
}
|
|
||||||
}
|
toast.showInfo(t('infoPastViewOnly'));
|
||||||
|
|
||||||
return (
|
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>
|
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
||||||
</div>
|
|
||||||
|
const start = new Date(appointment.startAt);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
|
||||||
import { formatMobileForDisplay } from '@/lib/phone';
|
|
||||||
import type { Patient } from '@/types/patient';
|
|
||||||
|
|
||||||
interface AppointmentsPatientSearchProps {
|
|
||||||
search: string;
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
patients: Patient[];
|
|
||||||
selectedPatientId?: string;
|
|
||||||
onSelectPatient: (patient: Patient) => void;
|
|
||||||
loading?: boolean;
|
|
||||||
canAddPatient: boolean;
|
|
||||||
onAddPatient: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppointmentsPatientSearch({
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
patients,
|
|
||||||
selectedPatientId,
|
|
||||||
onSelectPatient,
|
|
||||||
loading = false,
|
|
||||||
canAddPatient,
|
|
||||||
onAddPatient,
|
|
||||||
}: AppointmentsPatientSearchProps) {
|
|
||||||
const t = useTranslations('appointments');
|
|
||||||
const tPatients = useTranslations('patients');
|
|
||||||
|
|
||||||
const trimmed = search.trim();
|
|
||||||
const showAddForEmptyResults =
|
|
||||||
trimmed.length > 0 && !loading && patients.length === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="surface-card p-4 space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
placeholder={t('searchPlaceholder')}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showAddForEmptyResults && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!canAddPatient}
|
|
||||||
onClick={onAddPatient}
|
|
||||||
title={!canAddPatient ? t('noPermissionAdd') : undefined}
|
|
||||||
>
|
|
||||||
{tPatients('newPatient')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-72 overflow-y-auto">
|
|
||||||
{loading && <p className="text-sm text-text-muted">{t('searching')}</p>}
|
|
||||||
|
|
||||||
{!loading && trimmed.length === 0 && (
|
|
||||||
<p className="text-sm text-text-muted">{t('searchHint')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{patients.map((patient) => {
|
|
||||||
const isSelected = selectedPatientId === patient.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={patient.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectPatient(patient)}
|
|
||||||
className={`w-full text-left rounded-[var(--radius-sm)] border px-3 py-2 transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-primary-soft border-primary/60'
|
|
||||||
: 'border-border/60 hover:bg-background-card/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-text-primary">
|
|
||||||
{patient.firstName} {patient.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -85,6 +85,29 @@ function CreatePatientFormFields({
|
|||||||
[formData.firstName, formData.lastName, formData.mobile],
|
[formData.firstName, formData.lastName, formData.mobile],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mobileDisplayError = useMemo(() => {
|
||||||
|
if (fieldErrors.mobile) {
|
||||||
|
return fieldErrors.mobile;
|
||||||
|
}
|
||||||
|
const raw = formData.mobile?.trim() ?? '';
|
||||||
|
if (!raw) {
|
||||||
|
if (formData.firstName?.trim() && formData.lastName?.trim()) {
|
||||||
|
return tValidation('mobileRequired');
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isValidMobile(normalizeMobile(formData.mobile) ?? '')) {
|
||||||
|
return tValidation('mobileInvalid');
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [
|
||||||
|
fieldErrors.mobile,
|
||||||
|
formData.mobile,
|
||||||
|
formData.firstName,
|
||||||
|
formData.lastName,
|
||||||
|
tValidation,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-text-muted">{t('requiredFieldsHint')}</p>
|
<p className="text-sm text-text-muted">{t('requiredFieldsHint')}</p>
|
||||||
@@ -125,11 +148,13 @@ function CreatePatientFormFields({
|
|||||||
}}
|
}}
|
||||||
placeholder={t('mobilePlaceholder')}
|
placeholder={t('mobilePlaceholder')}
|
||||||
required
|
required
|
||||||
error={fieldErrors.mobile}
|
error={mobileDisplayError}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('emailOptional')}
|
label={t('emailOptional')}
|
||||||
type="email"
|
type="text"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
value={formData.email || ''}
|
value={formData.email || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange({ email: e.target.value });
|
onChange({ email: e.target.value });
|
||||||
|
|||||||
136
frontend/src/components/ui/patient/PatientSearchCombobox.tsx
Normal file
136
frontend/src/components/ui/patient/PatientSearchCombobox.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
|
import type { Patient } from '@/types/patient';
|
||||||
|
|
||||||
|
interface PatientSearchComboboxProps {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
patients: Patient[];
|
||||||
|
loading?: boolean;
|
||||||
|
selectedPatient?: Patient | null;
|
||||||
|
onSelectPatient: (patient: Patient) => void;
|
||||||
|
/** When false, sidebar uses an external summary card instead. */
|
||||||
|
showInlineSummary?: boolean;
|
||||||
|
canAddPatient?: boolean;
|
||||||
|
onAddPatient?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
idleHint?: string;
|
||||||
|
emptyResultsMessage?: string;
|
||||||
|
noPermissionMessage?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
readOnlyHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientSearchCombobox({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
patients,
|
||||||
|
loading = false,
|
||||||
|
selectedPatient,
|
||||||
|
onSelectPatient,
|
||||||
|
showInlineSummary = false,
|
||||||
|
canAddPatient = false,
|
||||||
|
onAddPatient,
|
||||||
|
placeholder,
|
||||||
|
idleHint,
|
||||||
|
emptyResultsMessage,
|
||||||
|
noPermissionMessage,
|
||||||
|
readOnly = false,
|
||||||
|
readOnlyHint,
|
||||||
|
}: PatientSearchComboboxProps) {
|
||||||
|
const tPatients = useTranslations('patients');
|
||||||
|
const trimmed = search.trim();
|
||||||
|
const showResults = !readOnly && trimmed.length > 0;
|
||||||
|
|
||||||
|
function handleSelect(patient: Patient) {
|
||||||
|
onSelectPatient(patient);
|
||||||
|
onSearchChange('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
||||||
|
{selectedPatient
|
||||||
|
? `${selectedPatient.firstName} ${selectedPatient.lastName}`
|
||||||
|
: tPatients('emptyValue')}
|
||||||
|
</p>
|
||||||
|
{readOnlyHint ? <p className="text-xs text-text-muted">{readOnlyHint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder ?? tPatients('searchPlaceholder')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!trimmed && !selectedPatient && idleHint ? (
|
||||||
|
<p className="text-sm text-text-muted">{idleHint}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showResults ? (
|
||||||
|
<div className="space-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-text-muted px-1 py-2">{tPatients('loadingPatients')}</p>
|
||||||
|
) : patients.length === 0 ? (
|
||||||
|
<div className="space-y-2 px-1 py-1">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{emptyResultsMessage ?? tPatients('noResults')}
|
||||||
|
</p>
|
||||||
|
{canAddPatient && onAddPatient ? (
|
||||||
|
<Button type="button" variant="primary" onClick={onAddPatient} fullWidth>
|
||||||
|
{tPatients('newPatient')}
|
||||||
|
</Button>
|
||||||
|
) : noPermissionMessage ? (
|
||||||
|
<p className="text-xs text-text-muted">{noPermissionMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 space-y-1.5 overflow-y-auto">
|
||||||
|
{patients.map((patient) => (
|
||||||
|
<button
|
||||||
|
key={patient.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(patient)}
|
||||||
|
className="w-full rounded-[var(--radius-sm)] border border-transparent px-2.5 py-2 text-left transition-colors hover:bg-background-card/70"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
{patient.firstName} {patient.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{formatMobileForDisplay(patient.mobile) ||
|
||||||
|
patient.email ||
|
||||||
|
tPatients('noContact')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showInlineSummary && selectedPatient ? (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2.5 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
{selectedPatient.firstName} {selectedPatient.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{formatMobileForDisplay(selectedPatient.mobile) ||
|
||||||
|
selectedPatient.email ||
|
||||||
|
tPatients('noContact')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
@@ -25,6 +27,8 @@ export function PatientsPage() {
|
|||||||
const t = useTranslations('patients');
|
const t = useTranslations('patients');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -55,6 +59,15 @@ export function PatientsPage() {
|
|||||||
void loadPatients('');
|
void loadPatients('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('action') !== 'create' || !canEditPatients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
router.replace('/patients');
|
||||||
|
}, [searchParams, canEditPatients, router]);
|
||||||
|
|
||||||
async function loadPatients(q: string) {
|
async function loadPatients(q: string) {
|
||||||
setLoadingPatients(true);
|
setLoadingPatients(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
|
|||||||
69
frontend/src/components/ui/shared/TimeStepInput.tsx
Normal file
69
frontend/src/components/ui/shared/TimeStepInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { adjustTimeInput } from '@/components/appointments/appointmentTime';
|
||||||
|
|
||||||
|
interface TimeStepInputProps {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
stepMinutes?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'min-w-0 flex-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm text-center tabular-nums focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||||
|
|
||||||
|
export function TimeStepInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
stepMinutes = 5,
|
||||||
|
disabled = false,
|
||||||
|
}: TimeStepInputProps) {
|
||||||
|
const step = (delta: number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(adjustTimeInput(value, delta * stepMinutes));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-text-secondary mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-stretch gap-1.5">
|
||||||
|
<div className="flex flex-col justify-center gap-0.5 shrink-0 self-stretch py-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${label} +${stepMinutes}m`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => step(1)}
|
||||||
|
className="flex flex-1 min-h-[1.25rem] items-center justify-center rounded-[var(--radius-sm)] border border-border bg-background-secondary/90 px-1.5 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 icon-flat" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${label} -${stepMinutes}m`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
className="flex flex-1 min-h-[1.25rem] items-center justify-center rounded-[var(--radius-sm)] border border-border bg-background-secondary/90 px-1.5 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 icon-flat" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="time"
|
||||||
|
step={stepMinutes * 60}
|
||||||
|
className={inputClassName}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ export const patientsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create: async (data: CreatePatientInput): Promise<CreatePatientResponse> => {
|
create: async (data: CreatePatientInput): Promise<CreatePatientResponse> => {
|
||||||
const response = await apiClient.post('/patients', data);
|
const { email, ...rest } = data;
|
||||||
|
const body = {
|
||||||
|
...rest,
|
||||||
|
...(email?.trim() ? { email: email.trim() } : {}),
|
||||||
|
};
|
||||||
|
const response = await apiClient.post('/patients', body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
45
frontend/src/lib/hooks/usePatientSearchQuery.ts
Normal file
45
frontend/src/lib/hooks/usePatientSearchQuery.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import type { Patient } from '@/types/patient';
|
||||||
|
|
||||||
|
/** Debounced patient search — only queries when `search` is non-empty. */
|
||||||
|
export function usePatientSearchQuery(enabled = true) {
|
||||||
|
const { currentOrganization } = useAuth();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !currentOrganization) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = search.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setPatients([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await patientsApi.list({ q: trimmed, page: 1, limit: 25 });
|
||||||
|
setPatients(response.data.items ?? []);
|
||||||
|
} catch {
|
||||||
|
setPatients([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [search, enabled, currentOrganization]);
|
||||||
|
|
||||||
|
return { search, setSearch, patients, loading };
|
||||||
|
}
|
||||||
@@ -23,4 +23,6 @@ export interface AppointmentRecord {
|
|||||||
endAt: string;
|
endAt: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'>;
|
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'>;
|
||||||
|
/** True when a treatment record is linked to this appointment. */
|
||||||
|
hasTreatment?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user