From 1cdf853d32211720c45b113998a96ac0046dbfca Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 18:56:01 +0330 Subject: [PATCH] improvement: appointments history component added to patients feature. --- .../patients/dto/create-patient.dto.ts | 8 +- .../modules/patients/patients.controller.ts | 10 ++ .../src/modules/patients/patients.service.ts | 92 +++++++++++-- frontend/messages/en.json | 15 ++- frontend/messages/fa.json | 15 ++- frontend/messages/nl.json | 15 ++- .../[locale]/(dashboard)/patients/page.tsx | 4 + .../ui/patient/CreatePatientModal.tsx | 98 ++++++++++++-- .../ui/patient/PatientAppointmentHistory.tsx | 122 ++++++++++++++++++ .../ui/patient/PatientSummaryCard.tsx | 3 +- frontend/src/lib/api/patients.ts | 6 + frontend/src/types/patient.ts | 14 ++ 12 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/ui/patient/PatientAppointmentHistory.tsx diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index 5e72673..21a2ae7 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -1,20 +1,24 @@ -import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class CreatePatientDto { @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(80) firstName: string; @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(80) lastName: string; @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(30) mobile: string; @IsOptional() - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email?: string; @IsOptional() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index cd6fe86..b40b2f0 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -37,6 +37,16 @@ export class PatientsController { return this.patientsService.findAll(query); } + @Get(':id/appointments') + @ApiOperation({ + summary: + 'List this patient\'s appointments for the current clinic (requires TAB_PATIENTS_READ; not gated by appointments permission)', + }) + listAppointments(@Param('id') id: string, @Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.listAppointments(id, organizationId, req.user.id); + } + @Get(':id') @ApiOperation({ summary: 'Get one patient by id' }) findOne(@Param('id') id: string) { diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index 6a1f6fc..a573acd 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,6 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; +import { hasEffectivePermission } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; @@ -10,6 +12,8 @@ export class PatientsService { constructor(private readonly prisma: PrismaService) {} async create(createPatientDto: CreatePatientDto, organizationId: string) { + const firstName = this.requireNonEmptyName(createPatientDto.firstName, 'firstName'); + const lastName = this.requireNonEmptyName(createPatientDto.lastName, 'lastName'); const mobile = this.resolveMobile(createPatientDto.mobile); const existing = await this.prisma.patient.findUnique({ @@ -22,8 +26,8 @@ export class PatientsService { const patient = await this.prisma.patient.create({ data: { - firstName: createPatientDto.firstName.trim(), - lastName: createPatientDto.lastName.trim(), + firstName, + lastName, mobile, email: createPatientDto.email?.trim() || null, notes: createPatientDto.notes?.trim() || null, @@ -92,10 +96,10 @@ export class PatientsService { } = {}; if (updatePatientDto.firstName !== undefined) { - data.firstName = updatePatientDto.firstName.trim(); + data.firstName = this.requireNonEmptyName(updatePatientDto.firstName, 'firstName'); } if (updatePatientDto.lastName !== undefined) { - data.lastName = updatePatientDto.lastName.trim(); + data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName'); } if (updatePatientDto.mobile !== undefined) { data.mobile = this.resolveMobile(updatePatientDto.mobile); @@ -120,6 +124,42 @@ export class PatientsService { return { success: true, data: patient }; } + async listAppointments( + patientId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanViewPatients(actorUserId, organizationId); + await this.ensurePatient(patientId); + + const items = await this.prisma.appointment.findMany({ + where: { organizationId, patientId }, + orderBy: [{ startAt: 'desc' }], + }); + + const providerIds = [...new Set(items.map((item) => item.providerUserId))]; + const providers = + providerIds.length === 0 + ? [] + : await this.prisma.user.findMany({ + where: { id: { in: providerIds } }, + select: { id: true, name: true }, + }); + const providerNameById = new Map(providers.map((p) => [p.id, p.name])); + + return { + success: true, + data: items.map((item) => ({ + id: item.id, + startAt: item.startAt, + endAt: item.endAt, + purpose: item.purpose, + providerUserId: item.providerUserId, + providerName: providerNameById.get(item.providerUserId) ?? '', + })), + }; + } + getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); @@ -148,15 +188,51 @@ export class PatientsService { } private resolveMobile(raw: string): string { + if (!raw?.trim()) { + throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [ + { field: 'mobile', code: ErrorCode.VALIDATION_FIELD_REQUIRED }, + ]); + } + const mobile = normalizeMobile(raw); if (!mobile || !isValidMobile(mobile)) { - throw new BadRequestException( - 'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).', - ); + throw new AppException(ErrorCode.VALIDATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST, [ + { field: 'mobile', code: ErrorCode.VALIDATION_MOBILE_INVALID }, + ]); } return mobile; } + private requireNonEmptyName(value: string, field: 'firstName' | 'lastName'): string { + const trimmed = value?.trim() ?? ''; + if (!trimmed) { + throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [ + { field, code: ErrorCode.VALIDATION_FIELD_REQUIRED }, + ]); + } + return trimmed; + } + + private async assertCanViewPatients(userId: string, organizationId: string) { + const membership = await this.prisma.membership.findUnique({ + where: { + userId_organizationId: { userId, organizationId }, + }, + include: { + organization: { include: { type: true, plan: true } }, + permissions: { include: { permission: true } }, + }, + }); + + if (!membership) { + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); + } + + if (!hasEffectivePermission(membership, 'TAB_PATIENTS_READ')) { + throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); + } + } + private async ensurePatient(id: string) { const patient = await this.prisma.patient.findUnique({ where: { id }, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fcd43f4..f0d67e6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Active", "statusInactive": "Inactive", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Fields marked with * are required. Email is optional.", + "firstNameRequired": "First name is required.", + "lastNameRequired": "Last name is required.", + "emailOptional": "Email (optional)", + "emailOptionalSummary": "Email (optional):", + "appointmentHistoryTitle": "Appointment history", + "appointmentHistorySubtitle": "Past and upcoming appointments for this patient at your clinic.", + "appointmentHistoryLoading": "Loading appointment history…", + "appointmentHistoryEmpty": "No appointments recorded for this patient yet.", + "appointmentHistoryError": "Could not load appointment history.", + "appointmentHistoryProvider": "Provider: {name}", + "appointmentHistoryUnknownProvider": "Unknown provider" }, "cases": { "title": "Cases", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index edf6bcd..5c56946 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -393,7 +393,20 @@ "statusLabel": "وضعیت:", "statusActive": "فعال", "statusInactive": "غیرفعال", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "فیلدهای دارای * الزامی هستند. ایمیل اختیاری است.", + "firstNameRequired": "نام الزامی است.", + "lastNameRequired": "نام خانوادگی الزامی است.", + "emailOptional": "ایمیل (اختیاری)", + "emailOptionalSummary": "ایمیل (اختیاری):", + "appointmentHistoryTitle": "سوابق نوبت", + "appointmentHistorySubtitle": "نوبت‌های گذشته و آینده این بیمار در کلینیک شما.", + "appointmentHistoryLoading": "در حال بارگذاری سوابق نوبت…", + "appointmentHistoryEmpty": "هنوز نوبتی برای این بیمار ثبت نشده است.", + "appointmentHistoryError": "بارگذاری سوابق نوبت انجام نشد.", + "appointmentHistoryProvider": "ارائه‌دهنده: {name}", + "appointmentHistoryUnknownProvider": "ارائه‌دهنده نامشخص" }, "cases": { "title": "پرونده‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 878c88d..8abac2d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Actief", "statusInactive": "Inactief", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Velden met * zijn verplicht. E-mail is optioneel.", + "firstNameRequired": "Voornaam is verplicht.", + "lastNameRequired": "Achternaam is verplicht.", + "emailOptional": "E-mail (optioneel)", + "emailOptionalSummary": "E-mail (optioneel):", + "appointmentHistoryTitle": "Afspraakgeschiedenis", + "appointmentHistorySubtitle": "Eerdere en komende afspraken voor deze patiënt in uw kliniek.", + "appointmentHistoryLoading": "Afspraakgeschiedenis laden…", + "appointmentHistoryEmpty": "Er zijn nog geen afspraken voor deze patiënt.", + "appointmentHistoryError": "Kon afspraakgeschiedenis niet laden.", + "appointmentHistoryProvider": "Behandelaar: {name}", + "appointmentHistoryUnknownProvider": "Onbekende behandelaar" }, "cases": { "title": "Dossiers", diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index e84ae56..9266431 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -13,6 +13,7 @@ import { CreatePatientInput, Patient } from '@/types/patient'; import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -154,6 +155,9 @@ export default function PatientsPage() {
+ {selectedPatient ? ( + + ) : null}
diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index e1da9a7..edeebde 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; @@ -22,6 +23,8 @@ interface CreatePatientModalProps { variant?: 'inline' | 'dialog'; } +type FieldErrors = Partial>; + function CreatePatientFormFields({ formData, onChange, @@ -39,46 +42,113 @@ function CreatePatientFormFields({ }) { const t = useTranslations('patients'); const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const [fieldErrors, setFieldErrors] = useState({}); + + const requiredMark = t('requiredMark'); + + const validate = (): boolean => { + const nextErrors: FieldErrors = {}; + + if (!formData.firstName?.trim()) { + nextErrors.firstName = t('firstNameRequired'); + } + if (!formData.lastName?.trim()) { + nextErrors.lastName = t('lastNameRequired'); + } + if (!formData.mobile?.trim()) { + nextErrors.mobile = tValidation('mobileRequired'); + } else if (!isValidMobile(normalizeMobile(formData.mobile) ?? '')) { + nextErrors.mobile = tValidation('mobileInvalid'); + } + + if (formData.email?.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email.trim())) { + nextErrors.email = tValidation('emailInvalid'); + } + + setFieldErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + }; + + const handleSubmit = () => { + if (!validate()) { + return; + } + onSubmit(); + }; + + const isSubmitDisabled = useMemo( + () => + !formData.firstName?.trim() || + !formData.lastName?.trim() || + !isValidMobile(normalizeMobile(formData.mobile || '') ?? ''), + [formData.firstName, formData.lastName, formData.mobile], + ); return ( <> +

{t('requiredFieldsHint')}

+
onChange({ firstName: e.target.value })} + onChange={(e) => { + onChange({ firstName: e.target.value }); + if (fieldErrors.firstName) { + setFieldErrors((prev) => ({ ...prev, firstName: undefined })); + } + }} + required + error={fieldErrors.firstName} /> onChange({ lastName: e.target.value })} + onChange={(e) => { + onChange({ lastName: e.target.value }); + if (fieldErrors.lastName) { + setFieldErrors((prev) => ({ ...prev, lastName: undefined })); + } + }} + required + error={fieldErrors.lastName} /> onChange({ mobile: e.target.value })} + onChange={(e) => { + onChange({ mobile: e.target.value }); + if (fieldErrors.mobile) { + setFieldErrors((prev) => ({ ...prev, mobile: undefined })); + } + }} placeholder={t('mobilePlaceholder')} + required + error={fieldErrors.mobile} /> onChange({ email: e.target.value })} + onChange={(e) => { + onChange({ email: e.target.value }); + if (fieldErrors.email) { + setFieldErrors((prev) => ({ ...prev, email: undefined })); + } + }} + error={fieldErrors.email} />
diff --git a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx new file mode 100644 index 0000000..93d86c1 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { formatTimeForInput } from '@/components/appointments/appointmentTime'; +import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { patientsApi } from '@/lib/api/patients'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { PatientAppointmentHistoryItem } from '@/types/patient'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; + +interface PatientAppointmentHistoryProps { + patientId: string; +} + +function formatAppointmentDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) { + const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((response) => setTreatmentCatalog(response.data)) + .catch(() => {}); + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + setLoading(true); + setError(null); + try { + const response = await patientsApi.listAppointments(patientId); + if (!cancelled) { + setItems(response.data); + } + } catch (err: unknown) { + if (!cancelled) { + setError(getUserFacingError(err, tErrors, t('appointmentHistoryError'))); + setItems([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [patientId, t, tErrors]); + + return ( +
+
+

{t('appointmentHistoryTitle')}

+

{t('appointmentHistorySubtitle')}

+
+ + {loading ? ( +

{t('appointmentHistoryLoading')}

+ ) : error ? ( +

{error}

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

{t('appointmentHistoryEmpty')}

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    +

    + {formatAppointmentDate(item.startAt)} +

    +

    + {formatTimeForInput(new Date(item.startAt))} + {' – '} + {formatTimeForInput(new Date(item.endAt))} +

    +
    +
    + {item.purpose ? ( + + ) : null} +

    + {t('appointmentHistoryProvider', { + name: item.providerName || t('appointmentHistoryUnknownProvider'), + })} +

    +
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index 63974d2..5a714c0 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -28,7 +28,8 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}

- {t('emailLabel')} {patient.email || t('emptyValue')} + {t('emailOptionalSummary')}{' '} + {patient.email || t('emptyValue')}

{t('statusLabel')}{' '} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index 27309e6..a4ea0f6 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -3,6 +3,7 @@ import { CreatePatientInput, CreatePatientResponse, Patient, + PatientAppointmentHistoryResponse, PatientsListResponse, } from '@/types/patient'; @@ -21,4 +22,9 @@ export const patientsApi = { const response = await apiClient.get(`/patients/${id}`); return response.data; }, + + listAppointments: async (patientId: string): Promise => { + const response = await apiClient.get(`/patients/${patientId}/appointments`); + return response.data; + }, }; diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts index 0fa9aee..1dad2bf 100644 --- a/frontend/src/types/patient.ts +++ b/frontend/src/types/patient.ts @@ -39,3 +39,17 @@ export interface CreatePatientResponse { data: Patient; existing?: boolean; } + +export interface PatientAppointmentHistoryItem { + id: string; + startAt: string; + endAt: string; + purpose: string; + providerUserId: string; + providerName: string; +} + +export interface PatientAppointmentHistoryResponse { + success: boolean; + data: PatientAppointmentHistoryItem[]; +}