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