improvement: appointment dialog UX improved.

This commit is contained in:
2026-07-12 22:07:11 +03:30
parent 53b43f2cdb
commit abf0371a5b
18 changed files with 1006 additions and 759 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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>
)} )}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 });

View 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>
);
}

View File

@@ -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('');

View 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>
);
}

View File

@@ -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;
}, },

View 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 };
}

View File

@@ -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;
} }