diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b3ed3fc..d1413d5 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -61,6 +61,7 @@ export const ErrorCode = { NOT_FOUND: 'NOT_FOUND', CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', + APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 5b3e3c8..7af2525 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -104,11 +104,18 @@ export class AppointmentsService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true }, }, + treatment: { select: { id: true } }, }, orderBy: [{ startAt: 'asc' }], }); - return { success: true, data: items }; + return { + success: true, + data: items.map(({ treatment, ...appointment }) => ({ + ...appointment, + hasTreatment: Boolean(treatment), + })), + }; } async create( @@ -202,6 +209,18 @@ export class AppointmentsService { const providerUserId = dto.providerUserId ?? existing.providerUserId; 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); await this.ensurePatientInOrg(patientId, organizationId); diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index 21a2ae7..ec14ba0 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -1,6 +1,14 @@ +import { Transform } from 'class-transformer'; import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { ErrorCode } from '../../../common/errors'; +function emptyStringToUndefined({ value }: { value: unknown }): unknown { + if (typeof value === 'string' && value.trim() === '') { + return undefined; + } + return value; +} + export class CreatePatientDto { @IsString() @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @@ -18,6 +26,7 @@ export class CreatePatientDto { mobile: string; @IsOptional() + @Transform(emptyStringToUndefined) @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email?: string; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cd85421..c9bd7c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -546,6 +546,9 @@ "endLabel": "End", "purposeLabel": "Purpose", "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.", "errorPastSchedule": "Cannot schedule in the past.", "errorPastViewOnly": "Past appointments are view-only.", @@ -901,6 +904,7 @@ "NOT_FOUND": "The requested item was not found.", "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.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index aa79c80..2d81f21 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -547,6 +547,9 @@ "endLabel": "پایان", "purposeLabel": "هدف", "errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.", + "patientSearchPlaceholder": "جستجوی بیمار بر اساس نام، تلفن یا ایمیل", + "patientSearchEmpty": "بیماری با این جستجو یافت نشد.", + "patientLockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان تغییر بیمار وجود ندارد.", "errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.", "errorPastSchedule": "نمی‌توان در گذشته زمان‌بندی کرد.", "errorPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", @@ -902,6 +905,7 @@ "NOT_FOUND": "مورد درخواستی یافت نشد.", "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", + "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index e5759bd..a4d98ed 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -547,6 +547,9 @@ "endLabel": "Einde", "purposeLabel": "Doel", "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.", "errorPastSchedule": "Kan niet in het verleden plannen.", "errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", @@ -902,6 +905,7 @@ "NOT_FOUND": "Het gevraagde item is niet gevonden.", "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.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/appointments/appointmentTime.ts b/frontend/src/components/appointments/appointmentTime.ts index 19abbe7..5010c6f 100644 --- a/frontend/src/components/appointments/appointmentTime.ts +++ b/frontend/src/components/appointments/appointmentTime.ts @@ -61,3 +61,22 @@ export function compareLocalDayStart(a: Date, b: Date): number { const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime(); 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); +} diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index d122b7a..27d4fcd 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,286 +1,354 @@ -'use client'; - -import { useEffect, 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 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 { - combineLocalDateAndTime, - compareLocalDayStart, - formatTimeForInput, - isSameLocalCalendarDay, -} from '@/components/appointments/appointmentTime'; - -interface AppointmentBookingModalProps { - open: boolean; - scheduleDate: Date; - patient: Patient | undefined; - providerUserId: string | null; - providerName: string; - initialStartMinute: number; - onClose: () => void; - onSubmit: (payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) => Promise; - treatmentCatalog: TreatmentCatalogEntry[]; - editingAppointment?: AppointmentRecord | null; - loading?: boolean; - canDelete?: boolean; - onDelete?: () => void | Promise; - deleting?: boolean; -} - -export function AppointmentBookingModal({ - open, - scheduleDate, - patient, - 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 tPatients = useTranslations('patients'); - - const defaultPurpose = treatmentCatalog[0]?.code ?? ''; - - const [startTime, setStartTime] = useState('09:00'); - const [endTime, setEndTime] = useState('10:00'); - const [purpose, setPurpose] = useState(defaultPurpose); - const [error, setError] = useState(''); - const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); - const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); - - useEffect(() => { - if (!open) { - return; - } - if (editingAppointment) { - const start = new Date(editingAppointment.startAt); - const end = new Date(editingAppointment.endAt); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose(editingAppointment.purpose || defaultPurpose); - } else { - const start = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - Math.floor(initialStartMinute / 60), - initialStartMinute % 60, - 0, - 0, - ); - const endMinute = Math.min(initialStartMinute + 60, 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); - } - setError(''); - }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]); - - if (!open || !providerUserId) { - return null; - } - - 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'; - - async function handleSubmit() { - setError(''); - if (!providerUserId) { - return; - } - if (!editingAppointment && !patient) { - 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; - } - - const effectivePatientId = editingAppointment?.patientId ?? patient?.id; - const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId; - if (!effectivePatientId || !effectiveProviderId) { - setError(t('errorMissingDetails')); - return; - } - - await onSubmit({ - patientId: effectivePatientId, - providerUserId: effectiveProviderId, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - purpose, - }); - } - - return ( - - -
-

- {editingAppointment ? t('editTitle') : t('newTitle')} -

- -
- -

- {t('providerLabel')}{' '} - {providerName} -

- -
- -

- {editingAppointment - ? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}` - : patient - ? `${patient.firstName} ${patient.lastName}` - : tPatients('emptyValue')} -

-
- -
-
- - setStartTime(e.target.value)} - /> -
-
- - setEndTime(e.target.value)} - /> -
-
- - setPurpose(e.target.value)} - style={{ color: purposeTextColor }} - > - {treatmentCatalog.map((entry, index) => ( - - ))} - - - {error &&

{error}

} - -
- {editingAppointment && canDelete && onDelete ? ( - - ) : null} -
- - -
-
-
-
- ); -} +'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; + treatmentCatalog: TreatmentCatalogEntry[]; + editingAppointment?: AppointmentRecord | null; + loading?: boolean; + canDelete?: boolean; + onDelete?: () => void | Promise; + deleting?: boolean; +} + +function patientFromRecord( + patient: AppointmentRecord['patient'], +): Pick { + 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(defaultPurpose); + const [selectedPatient, setSelectedPatient] = useState< + Pick | 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 ( + + +
+

+ {editingAppointment ? t('editTitle') : t('newTitle')} +

+ +
+ +

+ {t('providerLabel')}{' '} + {providerName} +

+ +
+ + + +
+ +
+ + +
+ + setPurpose(e.target.value)} + style={{ color: purposeTextColor }} + > + {treatmentCatalog.map((entry, index) => ( + + ))} + + + {error &&

{error}

} + +
+ {editingAppointment && canDelete && onDelete ? ( + + ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index becbe99..ba3b7cd 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -326,12 +326,12 @@ export function AppointmentScheduleGrid({ 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' : '' } ${ isUnderOneHour ? '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={{ top: pos.top, @@ -343,19 +343,19 @@ export function AppointmentScheduleGrid({ title={bannerTitle} > {patientName} {!isUnderOneHour && apt.patient.mobile && lane.laneCount === 1 && ( - + {formatMobileForDisplay(apt.patient.mobile)} )} {!isUnderOneHour && clusterSize > 1 && ( - + {t('overlapping', { count: clusterSize })} )} diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index e138f53..89e9886 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -1,370 +1,288 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { appointmentsApi } from '@/lib/api/appointments'; -import { patientsApi } from '@/lib/api/patients'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { CreatePatientInput, Patient } from '@/types/patient'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const tPatients = useTranslations('patients'); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - const toast = useToast(); - - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - - const [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data); - setAppointments(aRes.data); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data)) - .catch(() => {}); - }, []); - - useEffect(() => { - const t = setTimeout(() => { - void loadPatientsSearch(search); - }, 300); - return () => clearTimeout(t); - }, [search]); - - async function loadPatientsSearch(q: string) { - if (!currentOrganization) { - return; - } - setLoadingPatients(true); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - if (selectedPatient) { - const stillThere = items.find((p) => p.id === selectedPatient.id); - if (stillThere) { - setSelectedPatient(stillThere); - } - } - } catch { - setPatients([]); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatientsSearch(search); - setSelectedPatient(response.data); - if (response.existing) { - toast.showInfo( - tPatients('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - if (!selectedPatient) { - toast.showInfo(t('infoSelectPatient')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - const start = new Date(appointment.startAt); - setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); - setBookingProviderId(appointment.providerUserId); - setBookingProviderName(provider?.name ?? bookingProviderName); - setEditingAppointmentId(appointment.id); - setBookingOpen(true); - } - - function handleAppointmentOutsideHours(appointment: AppointmentRecord) { - toast.showError(t('errorOutsideHours')); - } - - async function handleSaveAppointment(payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
- { - if (!canEditPatients) { - return; - } - setPatientForm(EMPTY_PATIENT_FORM); - setIsCreateOpen(true); - }} - /> - -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> - - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - -
- ); -} +'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([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + const toast = useToast(); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); + const [selectedPatient, setSelectedPatient] = useState(); + + const [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+
+ +
+ +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> +
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx deleted file mode 100644 index a238353..0000000 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ /dev/null @@ -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 ( -
-
-
- onSearchChange(e.target.value)} - icon={} - /> -
- {showAddForEmptyResults && ( - - )} -
- -
- {loading &&

{t('searching')}

} - - {!loading && trimmed.length === 0 && ( -

{t('searchHint')}

- )} - - {patients.map((patient) => { - const isSelected = selectedPatientId === patient.id; - return ( - - ); - })} -
-
- ); -} diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index edeebde..72a6406 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -85,6 +85,29 @@ function CreatePatientFormFields({ [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 ( <>

{t('requiredFieldsHint')}

@@ -125,11 +148,13 @@ function CreatePatientFormFields({ }} placeholder={t('mobilePlaceholder')} required - error={fieldErrors.mobile} + error={mobileDisplayError} /> { onChange({ email: e.target.value }); diff --git a/frontend/src/components/ui/patient/PatientSearchCombobox.tsx b/frontend/src/components/ui/patient/PatientSearchCombobox.tsx new file mode 100644 index 0000000..ad25a28 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientSearchCombobox.tsx @@ -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 ( +
+

+ {selectedPatient + ? `${selectedPatient.firstName} ${selectedPatient.lastName}` + : tPatients('emptyValue')} +

+ {readOnlyHint ?

{readOnlyHint}

: null} +
+ ); + } + + return ( +
+ onSearchChange(e.target.value)} + icon={} + /> + + {!trimmed && !selectedPatient && idleHint ? ( +

{idleHint}

+ ) : null} + + {showResults ? ( +
+ {loading ? ( +

{tPatients('loadingPatients')}

+ ) : patients.length === 0 ? ( +
+

+ {emptyResultsMessage ?? tPatients('noResults')} +

+ {canAddPatient && onAddPatient ? ( + + ) : noPermissionMessage ? ( +

{noPermissionMessage}

+ ) : null} +
+ ) : ( +
+ {patients.map((patient) => ( + + ))} +
+ )} +
+ ) : null} + + {showInlineSummary && selectedPatient ? ( +
+

+ {selectedPatient.firstName} {selectedPatient.lastName} +

+

+ {formatMobileForDisplay(selectedPatient.mobile) || + selectedPatient.email || + tPatients('noContact')} +

+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx index cf14834..bf37645 100644 --- a/frontend/src/components/ui/patient/PatientsPage.tsx +++ b/frontend/src/components/ui/patient/PatientsPage.tsx @@ -1,7 +1,9 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/shared/Button'; import { patientsApi } from '@/lib/api/patients'; import { getUserFacingError } from '@/components/shared/formatApiError'; @@ -25,6 +27,8 @@ export function PatientsPage() { const t = useTranslations('patients'); const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const router = useRouter(); const { currentOrganization } = useAuth(); const toast = useToast(); const [search, setSearch] = useState(''); @@ -55,6 +59,15 @@ export function PatientsPage() { 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) { setLoadingPatients(true); toast.setError(''); diff --git a/frontend/src/components/ui/shared/TimeStepInput.tsx b/frontend/src/components/ui/shared/TimeStepInput.tsx new file mode 100644 index 0000000..480beb4 --- /dev/null +++ b/frontend/src/components/ui/shared/TimeStepInput.tsx @@ -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 ( +
+ +
+
+ + +
+ onChange(e.target.value)} + /> +
+
+ ); +} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index a4ea0f6..13f690d 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -14,7 +14,12 @@ export const patientsApi = { }, create: async (data: CreatePatientInput): Promise => { - 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; }, diff --git a/frontend/src/lib/hooks/usePatientSearchQuery.ts b/frontend/src/lib/hooks/usePatientSearchQuery.ts new file mode 100644 index 0000000..1f0c251 --- /dev/null +++ b/frontend/src/lib/hooks/usePatientSearchQuery.ts @@ -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([]); + 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 }; +} diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts index debb4d5..5b5b9f7 100644 --- a/frontend/src/types/appointment.ts +++ b/frontend/src/types/appointment.ts @@ -23,4 +23,6 @@ export interface AppointmentRecord { endAt: string; purpose: string; patient: Pick; + /** True when a treatment record is linked to this appointment. */ + hasTreatment?: boolean; }