From 64c7e5a25757c09c2caaf83a8ea531cfa597f888 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 14:49:37 +0330 Subject: [PATCH] feature: phase0 - Global patients + mobile normalization --- .../migration.sql | 28 ++ backend/prisma/schema.prisma | 33 +- backend/src/common/phone.spec.ts | 52 +++ backend/src/common/phone.ts | 54 +++ .../appointments/appointments.service.ts | 12 +- .../patients/dto/create-patient.dto.ts | 3 +- .../modules/patients/patients.controller.ts | 22 +- .../src/modules/patients/patients.service.ts | 136 +++++--- .../modules/treatments/treatments.service.ts | 6 +- frontend/messages/en.json | 8 +- frontend/messages/fa.json | 8 +- frontend/messages/nl.json | 8 +- .../(dashboard)/appointments/page.tsx | 23 +- .../[locale]/(dashboard)/patients/page.tsx | 311 +++++++++--------- .../appointments/AppointmentScheduleGrid.tsx | 9 +- .../AppointmentsPatientSearch.tsx | 3 +- .../ui/patient/CreatePatientModal.tsx | 10 +- .../ui/patient/PatientSearchSelect.tsx | 5 +- .../ui/patient/PatientSummaryCard.tsx | 3 +- frontend/src/lib/api/patients.ts | 3 +- frontend/src/lib/phone.ts | 44 +++ frontend/src/types/appointment.ts | 2 +- frontend/src/types/patient.ts | 12 +- 23 files changed, 535 insertions(+), 260 deletions(-) create mode 100644 backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql create mode 100644 backend/src/common/phone.spec.ts create mode 100644 backend/src/common/phone.ts create mode 100644 frontend/src/lib/phone.ts diff --git a/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql new file mode 100644 index 0000000..9625d89 --- /dev/null +++ b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql @@ -0,0 +1,28 @@ +-- Global patients: mobile is cloud-wide unique identity; org scope removed. +-- Test/dev data only — clear patient-linked rows before reshape. + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; +DELETE FROM "appointments"; +DELETE FROM "patients"; + +ALTER TABLE "patients" DROP CONSTRAINT IF EXISTS "patients_organizationId_fkey"; + +DROP INDEX IF EXISTS "patients_organizationId_createdAt_idx"; +DROP INDEX IF EXISTS "patients_organizationId_lastName_firstName_idx"; + +ALTER TABLE "patients" DROP COLUMN "organizationId"; +ALTER TABLE "patients" DROP COLUMN "phone"; + +ALTER TABLE "patients" ADD COLUMN "mobile" TEXT NOT NULL; +ALTER TABLE "patients" ADD COLUMN "createdByOrganizationId" TEXT; + +CREATE UNIQUE INDEX "patients_mobile_key" ON "patients"("mobile"); +CREATE INDEX "patients_lastName_firstName_idx" ON "patients"("lastName", "firstName"); + +ALTER TABLE "patients" + ADD CONSTRAINT "patients_createdByOrganizationId_fkey" + FOREIGN KEY ("createdByOrganizationId") REFERENCES "organizations"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ffe59bc..117d9e8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -60,7 +60,7 @@ model Organization { sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithOthers OrganizationLink[] @relation("OrganizationA") sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") - patients Patient[] + createdPatients Patient[] @relation("PatientCreatedBy") appointments Appointment[] treatments Treatment[] caseSends TreatmentCaseSend[] @@ -72,24 +72,23 @@ model Organization { } model Patient { - id String @id @default(uuid()) - organizationId String - firstName String - lastName String - phone String? - email String? - dateOfBirth DateTime? - notes String? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + firstName String + lastName String + mobile String @unique + email String? + dateOfBirth DateTime? + notes String? + isActive Boolean @default(true) + createdByOrganizationId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id]) - treatments Treatment[] - appointments Appointment[] + createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull) + treatments Treatment[] + appointments Appointment[] - @@index([organizationId, createdAt]) - @@index([organizationId, lastName, firstName]) + @@index([lastName, firstName]) @@map("patients") } diff --git a/backend/src/common/phone.spec.ts b/backend/src/common/phone.spec.ts new file mode 100644 index 0000000..1838bd0 --- /dev/null +++ b/backend/src/common/phone.spec.ts @@ -0,0 +1,52 @@ +import { + formatMobileForDisplay, + isValidMobile, + mobileSearchDigits, + normalizeMobile, +} from './phone'; + +describe('normalizeMobile', () => { + it('normalizes 09-prefixed numbers', () => { + expect(normalizeMobile('09121234567')).toBe('+989121234567'); + }); + + it('normalizes without leading zero', () => { + expect(normalizeMobile('9121234567')).toBe('+989121234567'); + }); + + it('normalizes +98 prefix', () => { + expect(normalizeMobile('+989121234567')).toBe('+989121234567'); + }); + + it('normalizes 0098 prefix', () => { + expect(normalizeMobile('00989121234567')).toBe('+989121234567'); + }); + + it('normalizes spaced input', () => { + expect(normalizeMobile('0912 123 4567')).toBe('+989121234567'); + }); + + it('rejects invalid numbers', () => { + expect(normalizeMobile('123')).toBeNull(); + expect(normalizeMobile('')).toBeNull(); + }); +}); + +describe('isValidMobile', () => { + it('validates normalized mobile', () => { + expect(isValidMobile('+989121234567')).toBe(true); + expect(isValidMobile('09121234567')).toBe(false); + }); +}); + +describe('formatMobileForDisplay', () => { + it('formats E.164 to local spaced form', () => { + expect(formatMobileForDisplay('+989121234567')).toBe('0912 123 4567'); + }); +}); + +describe('mobileSearchDigits', () => { + it('strips non-digits', () => { + expect(mobileSearchDigits('+98 912-123-4567')).toBe('989121234567'); + }); +}); diff --git a/backend/src/common/phone.ts b/backend/src/common/phone.ts new file mode 100644 index 0000000..b2b0ada --- /dev/null +++ b/backend/src/common/phone.ts @@ -0,0 +1,54 @@ +/** Canonical Iran mobile: +989XXXXXXXXX (12 chars). */ +export const IR_MOBILE_REGEX = /^\+989\d{9}$/; + +/** + * Normalize user-entered mobile to E.164 for Iran (+98…). + * Accepts 09…, 9…, +98…, 0098… with optional spaces/dashes. + */ +export function normalizeMobile(input: string): string | null { + const trimmed = input?.trim(); + if (!trimmed) { + return null; + } + + let digits = trimmed.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + digits = digits.slice(1); + } + + digits = digits.replace(/\D/g, ''); + + if (digits.startsWith('0098')) { + digits = digits.slice(4); + } else if (digits.startsWith('98') && digits.length >= 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + + return null; +} + +export function isValidMobile(normalized: string): boolean { + return IR_MOBILE_REGEX.test(normalized); +} + +/** Display-friendly local format: 09XX XXX XXXX */ +export function formatMobileForDisplay(normalized: string): string { + if (!isValidMobile(normalized)) { + return normalized; + } + const local = `0${normalized.slice(3)}`; + return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`; +} + +/** Strip to digits only for partial search matching. */ +export function mobileSearchDigits(input: string): string { + return input.replace(/\D/g, ''); +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2293082..769255e 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -96,7 +96,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, orderBy: [{ startAt: 'asc' }], @@ -152,7 +152,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -215,7 +215,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -300,9 +300,9 @@ export class AppointmentsService { } } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientInOrg(patientId: string, _organizationId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index fa92769..5e72673 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -9,10 +9,9 @@ export class CreatePatientDto { @MaxLength(80) lastName: string; - @IsOptional() @IsString() @MaxLength(30) - phone?: string; + mobile: string; @IsOptional() @IsEmail() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index 0e97c6e..46be5c1 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -3,7 +3,6 @@ import { Controller, Get, Param, - ParseIntPipe, Patch, Post, Query, @@ -25,30 +24,27 @@ export class PatientsController { constructor(private readonly patientsService: PatientsService) {} @Post() - @ApiOperation({ summary: 'Create a patient for current organization' }) + @ApiOperation({ summary: 'Create or return existing global patient by mobile' }) create(@Body() createPatientDto: CreatePatientDto, @Req() req) { const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.create(createPatientDto, organizationId); } @Get() - @ApiOperation({ summary: 'List patients with search and pagination' }) - findAll(@Query() query: ListPatientsDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findAll(query, organizationId); + @ApiOperation({ summary: 'Search all patients globally' }) + findAll(@Query() query: ListPatientsDto) { + return this.patientsService.findAll(query); } @Get(':id') @ApiOperation({ summary: 'Get one patient by id' }) - findOne(@Param('id') id: string, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findOne(id, organizationId); + findOne(@Param('id') id: string) { + return this.patientsService.findOne(id); } @Patch(':id') - @ApiOperation({ summary: 'Update patient' }) - update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.update(id, updatePatientDto, organizationId); + @ApiOperation({ summary: 'Update global patient record' }) + update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) { + return this.patientsService.update(id, updatePatientDto); } } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index ac99936..6a1f6fc 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; @@ -9,34 +10,38 @@ export class PatientsService { constructor(private readonly prisma: PrismaService) {} async create(createPatientDto: CreatePatientDto, organizationId: string) { + const mobile = this.resolveMobile(createPatientDto.mobile); + + const existing = await this.prisma.patient.findUnique({ + where: { mobile }, + }); + + if (existing) { + return { success: true, data: existing, existing: true as const }; + } + const patient = await this.prisma.patient.create({ data: { - ...createPatientDto, + firstName: createPatientDto.firstName.trim(), + lastName: createPatientDto.lastName.trim(), + mobile, + email: createPatientDto.email?.trim() || null, + notes: createPatientDto.notes?.trim() || null, dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null, - organizationId, + createdByOrganizationId: organizationId, }, }); - return { success: true, data: patient }; + return { success: true, data: patient, existing: false as const }; } - async findAll(query: ListPatientsDto, organizationId: string) { + async findAll(query: ListPatientsDto) { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; - const where = { - organizationId, - ...(q - ? { - OR: [ - { firstName: { contains: q, mode: 'insensitive' as const } }, - { lastName: { contains: q, mode: 'insensitive' as const } }, - { email: { contains: q, mode: 'insensitive' as const } }, - { phone: { contains: q, mode: 'insensitive' as const } }, - ], - } - : {}), - }; + const where = q?.trim() + ? this.buildSearchWhere(q.trim()) + : {}; const [items, total] = await Promise.all([ this.prisma.patient.findMany({ @@ -62,9 +67,9 @@ export class PatientsService { }; } - async findOne(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, + async findOne(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, }); if (!patient) { @@ -74,35 +79,92 @@ export class PatientsService { return { success: true, data: patient }; } - async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { - await this.ensurePatient(id, organizationId); + async update(id: string, updatePatientDto: UpdatePatientDto) { + await this.ensurePatient(id); + + const data: { + firstName?: string; + lastName?: string; + mobile?: string; + email?: string | null; + notes?: string | null; + dateOfBirth?: Date | null; + } = {}; + + if (updatePatientDto.firstName !== undefined) { + data.firstName = updatePatientDto.firstName.trim(); + } + if (updatePatientDto.lastName !== undefined) { + data.lastName = updatePatientDto.lastName.trim(); + } + if (updatePatientDto.mobile !== undefined) { + data.mobile = this.resolveMobile(updatePatientDto.mobile); + } + if (updatePatientDto.email !== undefined) { + data.email = updatePatientDto.email?.trim() || null; + } + if (updatePatientDto.notes !== undefined) { + data.notes = updatePatientDto.notes?.trim() || null; + } + if (updatePatientDto.dateOfBirth !== undefined) { + data.dateOfBirth = updatePatientDto.dateOfBirth + ? new Date(updatePatientDto.dateOfBirth) + : null; + } const patient = await this.prisma.patient.update({ where: { id }, - data: { - ...updatePatientDto, - dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined, - }, + data, }); return { success: true, data: patient }; } - private async ensurePatient(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, - select: { id: true }, - }); - - if (!patient) { - throw new NotFoundException('Patient not found'); - } - } - getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } + + private buildSearchWhere(q: string) { + const orConditions: Array> = [ + { firstName: { contains: q, mode: 'insensitive' as const } }, + { lastName: { contains: q, mode: 'insensitive' as const } }, + { email: { contains: q, mode: 'insensitive' as const } }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ mobile: normalized }); + } else { + const digits = mobileSearchDigits(q); + if (digits.length >= 3) { + orConditions.push({ mobile: { contains: digits } }); + } + } + + return { OR: orConditions }; + } + + private resolveMobile(raw: string): string { + const mobile = normalizeMobile(raw); + if (!mobile || !isValidMobile(mobile)) { + throw new BadRequestException( + 'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).', + ); + } + return mobile; + } + + private async ensurePatient(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!patient) { + throw new NotFoundException('Patient not found'); + } + } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index e44fb96..cf09b4f 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -549,9 +549,9 @@ export class TreatmentsService { ]); } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientInOrg(patientId: string, _organizationId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f58cb41..3ed3e7c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -296,15 +296,17 @@ "errorSavePatient": "Failed to save patient.", "firstName": "First name", "lastName": "Last name", - "phone": "Phone", + "mobile": "Mobile", + "mobilePlaceholder": "09121234567", + "mobileLabel": "Mobile:", + "patientAlreadyExists": "A patient with this mobile already exists: {firstName} {lastName}. They were selected for you.", "savePatient": "Save Patient", "dialogTitle": "New patient", - "searchPlaceholder": "Search patients by name, phone, email", + "searchPlaceholder": "Search patients by name, mobile, email", "loadingPatients": "Loading patients...", "noResults": "No patients found for this search.", "noContact": "No contact", "selectPatient": "Select a patient to view details.", - "phoneLabel": "Phone:", "emailLabel": "Email:", "statusLabel": "Status:", "statusActive": "Active", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e87d189..3de25cc 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -296,15 +296,17 @@ "errorSavePatient": "ذخیره بیمار ناموفق بود.", "firstName": "نام", "lastName": "نام خانوادگی", - "phone": "تلفن", + "mobile": "موبایل", + "mobilePlaceholder": "09121234567", + "mobileLabel": "موبایل:", + "patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.", "savePatient": "ذخیره بیمار", "dialogTitle": "بیمار جدید", - "searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", + "searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل", "loadingPatients": "در حال بارگذاری بیماران...", "noResults": "هیچ بیماری برای این جستجو یافت نشد.", "noContact": "بدون اطلاعات تماس", "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", - "phoneLabel": "تلفن:", "emailLabel": "ایمیل:", "statusLabel": "وضعیت:", "statusActive": "فعال", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 517a742..789e111 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -296,15 +296,17 @@ "errorSavePatient": "Patiënt opslaan mislukt.", "firstName": "Voornaam", "lastName": "Achternaam", - "phone": "Telefoon", + "mobile": "Mobiel", + "mobilePlaceholder": "0612345678", + "mobileLabel": "Mobiel:", + "patientAlreadyExists": "Er bestaat al een patiënt met dit mobiele nummer: {firstName} {lastName}. Deze is voor u geselecteerd.", "savePatient": "Patiënt opslaan", "dialogTitle": "Nieuwe patiënt", - "searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail", + "searchPlaceholder": "Zoek patiënten op naam, mobiel, e-mail", "loadingPatients": "Patiënten laden...", "noResults": "Geen patiënten gevonden voor deze zoekopdracht.", "noContact": "Geen contact", "selectPatient": "Selecteer een patiënt om details te bekijken.", - "phoneLabel": "Telefoon:", "emailLabel": "E-mail:", "statusLabel": "Status:", "statusActive": "Actief", diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 10207e0..9d0a025 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -24,7 +24,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', lastName: '', - phone: '', + mobile: '', email: '', }; @@ -152,12 +152,21 @@ export default function AppointmentsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); + 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) { const message = err && typeof err === 'object' && 'message' in err diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 61a5f21..ddc52b1 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,151 +1,160 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -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'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - phone: '', - email: '', -}; - -export default function PatientsPage() { - const t = useTranslations('patients'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - 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 canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

{t('title')}

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; +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'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export default function PatientsPage() { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + 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 canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + async function loadPatients(q: string) { + setLoadingPatients(true); + toast.setError(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + if (response.existing) { + toast.showInfo( + t('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + return ( +
+
+

{t('title')}

+ +
+ + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )} + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 2f90b9d..64ca665 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -20,6 +20,7 @@ import { } from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; +import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; const HOUR_PX = 80; @@ -300,7 +301,9 @@ export function AppointmentScheduleGrid({ clusterSize > 1 ? t('overlappingChoose', { count: clusterSize }) : null, - !isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, + !isUnderOneHour && apt.patient.mobile + ? formatMobileForDisplay(apt.patient.mobile) + : null, ] .filter(Boolean) .join(' · '); @@ -338,10 +341,10 @@ export function AppointmentScheduleGrid({ {patientName} {!isUnderOneHour && - apt.patient.phone && + apt.patient.mobile && lane.laneCount === 1 && ( - {apt.patient.phone} + {formatMobileForDisplay(apt.patient.mobile)} )} {!isUnderOneHour && clusterSize > 1 && ( diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index 5dce8a1..a238353 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -4,6 +4,7 @@ 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 { @@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({ {patient.firstName} {patient.lastName}

- {patient.phone || patient.email || tPatients('noContact')} + {formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}

); diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 7e0f44f..6f792ad 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Input } from '@/components/ui/shared/Input'; import { CreatePatientInput } from '@/types/patient'; +import { isValidMobile, normalizeMobile } from '@/lib/phone'; interface CreatePatientModalProps { isOpen: boolean; @@ -49,9 +50,10 @@ function CreatePatientFormFields({ onChange={(e) => onChange({ lastName: e.target.value })} /> onChange({ phone: e.target.value })} + label={t('mobile')} + value={formData.mobile || ''} + onChange={(e) => onChange({ mobile: e.target.value })} + placeholder={t('mobilePlaceholder')} /> {t('savePatient')} diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 4f4d707..0ca43c0 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSearchSelectProps { @@ -56,7 +57,9 @@ export function PatientSearchSelect({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || t('noContact')}

+

+ {formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')} +

); })} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index fb2e1aa..63974d2 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSummaryCardProps { @@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { {patient.firstName} {patient.lastName}

- {t('phoneLabel')} {patient.phone || t('emptyValue')} + {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}

{t('emailLabel')} {patient.email || t('emptyValue')} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index 8578c9c..27309e6 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -1,6 +1,7 @@ import { apiClient } from './client'; import { CreatePatientInput, + CreatePatientResponse, Patient, PatientsListResponse, } from '@/types/patient'; @@ -11,7 +12,7 @@ export const patientsApi = { return response.data; }, - create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => { + create: async (data: CreatePatientInput): Promise => { const response = await apiClient.post('/patients', data); return response.data; }, diff --git a/frontend/src/lib/phone.ts b/frontend/src/lib/phone.ts new file mode 100644 index 0000000..df28c3f --- /dev/null +++ b/frontend/src/lib/phone.ts @@ -0,0 +1,44 @@ +/** Canonical Iran mobile: +989XXXXXXXXX */ +export const IR_MOBILE_REGEX = /^\+989\d{9}$/; + +export function normalizeMobile(input: string): string | null { + const trimmed = input?.trim(); + if (!trimmed) { + return null; + } + + let digits = trimmed.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + digits = digits.slice(1); + } + + digits = digits.replace(/\D/g, ''); + + if (digits.startsWith('0098')) { + digits = digits.slice(4); + } else if (digits.startsWith('98') && digits.length >= 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + + return null; +} + +export function isValidMobile(normalized: string): boolean { + return IR_MOBILE_REGEX.test(normalized); +} + +export function formatMobileForDisplay(normalized: string): string { + if (!isValidMobile(normalized)) { + return normalized; + } + const local = `0${normalized.slice(3)}`; + return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`; +} diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts index 3614dd6..2435c80 100644 --- a/frontend/src/types/appointment.ts +++ b/frontend/src/types/appointment.ts @@ -25,5 +25,5 @@ export interface AppointmentRecord { startAt: string; endAt: string; purpose: string; - patient: Pick; + patient: Pick; } diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts index bb0e8c5..0fa9aee 100644 --- a/frontend/src/types/patient.ts +++ b/frontend/src/types/patient.ts @@ -1,13 +1,13 @@ export interface Patient { id: string; - organizationId: string; firstName: string; lastName: string; - phone?: string | null; + mobile: string; email?: string | null; dateOfBirth?: string | null; notes?: string | null; isActive: boolean; + createdByOrganizationId?: string | null; createdAt: string; updatedAt: string; } @@ -15,7 +15,7 @@ export interface Patient { export interface CreatePatientInput { firstName: string; lastName: string; - phone?: string; + mobile: string; email?: string; dateOfBirth?: string; notes?: string; @@ -33,3 +33,9 @@ export interface PatientsListResponse { }; }; } + +export interface CreatePatientResponse { + success: boolean; + data: Patient; + existing?: boolean; +}