feature: phase0 - Global patients + mobile normalization

This commit is contained in:
2026-06-28 14:49:37 +03:30
parent 653d67e15b
commit 64c7e5a257
23 changed files with 535 additions and 260 deletions

View File

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

View File

@@ -60,7 +60,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[] createdPatients Patient[] @relation("PatientCreatedBy")
appointments Appointment[] appointments Appointment[]
treatments Treatment[] treatments Treatment[]
caseSends TreatmentCaseSend[] caseSends TreatmentCaseSend[]
@@ -72,24 +72,23 @@ model Organization {
} }
model Patient { model Patient {
id String @id @default(uuid()) id String @id @default(uuid())
organizationId String firstName String
firstName String lastName String
lastName String mobile String @unique
phone String? email String?
email String? dateOfBirth DateTime?
dateOfBirth DateTime? notes String?
notes String? isActive Boolean @default(true)
isActive Boolean @default(true) createdByOrganizationId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id]) createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull)
treatments Treatment[] treatments Treatment[]
appointments Appointment[] appointments Appointment[]
@@index([organizationId, createdAt]) @@index([lastName, firstName])
@@index([organizationId, lastName, firstName])
@@map("patients") @@map("patients")
} }

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ export class AppointmentsService {
}, },
include: { include: {
patient: { patient: {
select: { id: true, firstName: true, lastName: true, phone: true }, select: { id: true, firstName: true, lastName: true, mobile: true },
}, },
}, },
orderBy: [{ startAt: 'asc' }], orderBy: [{ startAt: 'asc' }],
@@ -152,7 +152,7 @@ export class AppointmentsService {
}, },
include: { include: {
patient: { 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: { include: {
patient: { 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) { private async ensurePatientInOrg(patientId: string, _organizationId: string) {
const patient = await this.prisma.patient.findFirst({ const patient = await this.prisma.patient.findUnique({
where: { id: patientId, organizationId }, where: { id: patientId },
select: { id: true }, select: { id: true },
}); });
if (!patient) { if (!patient) {

View File

@@ -9,10 +9,9 @@ export class CreatePatientDto {
@MaxLength(80) @MaxLength(80)
lastName: string; lastName: string;
@IsOptional()
@IsString() @IsString()
@MaxLength(30) @MaxLength(30)
phone?: string; mobile: string;
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()

View File

@@ -3,7 +3,6 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
ParseIntPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -25,30 +24,27 @@ export class PatientsController {
constructor(private readonly patientsService: PatientsService) {} constructor(private readonly patientsService: PatientsService) {}
@Post() @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) { create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.create(createPatientDto, organizationId); return this.patientsService.create(createPatientDto, organizationId);
} }
@Get() @Get()
@ApiOperation({ summary: 'List patients with search and pagination' }) @ApiOperation({ summary: 'Search all patients globally' })
findAll(@Query() query: ListPatientsDto, @Req() req) { findAll(@Query() query: ListPatientsDto) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.findAll(query);
return this.patientsService.findAll(query, organizationId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one patient by id' }) @ApiOperation({ summary: 'Get one patient by id' })
findOne(@Param('id') id: string, @Req() req) { findOne(@Param('id') id: string) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.findOne(id);
return this.patientsService.findOne(id, organizationId);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update patient' }) @ApiOperation({ summary: 'Update global patient record' })
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) { update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.update(id, updatePatientDto);
return this.patientsService.update(id, updatePatientDto, organizationId);
} }
} }

View File

@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
import { CreatePatientDto } from './dto/create-patient.dto'; import { CreatePatientDto } from './dto/create-patient.dto';
import { ListPatientsDto } from './dto/list-patients.dto'; import { ListPatientsDto } from './dto/list-patients.dto';
import { UpdatePatientDto } from './dto/update-patient.dto'; import { UpdatePatientDto } from './dto/update-patient.dto';
@@ -9,34 +10,38 @@ export class PatientsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async create(createPatientDto: CreatePatientDto, organizationId: string) { 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({ const patient = await this.prisma.patient.create({
data: { 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, 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 { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where = { const where = q?.trim()
organizationId, ? this.buildSearchWhere(q.trim())
...(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 [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.patient.findMany({ this.prisma.patient.findMany({
@@ -62,9 +67,9 @@ export class PatientsService {
}; };
} }
async findOne(id: string, organizationId: string) { async findOne(id: string) {
const patient = await this.prisma.patient.findFirst({ const patient = await this.prisma.patient.findUnique({
where: { id, organizationId }, where: { id },
}); });
if (!patient) { if (!patient) {
@@ -74,35 +79,92 @@ export class PatientsService {
return { success: true, data: patient }; return { success: true, data: patient };
} }
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { async update(id: string, updatePatientDto: UpdatePatientDto) {
await this.ensurePatient(id, organizationId); 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({ const patient = await this.prisma.patient.update({
where: { id }, where: { id },
data: { data,
...updatePatientDto,
dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined,
},
}); });
return { success: true, data: patient }; 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 }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) { if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected'); throw new BadRequestException('Organization is not selected');
} }
return user.organizationId; return user.organizationId;
} }
private buildSearchWhere(q: string) {
const orConditions: Array<Record<string, unknown>> = [
{ 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');
}
}
} }

View File

@@ -549,9 +549,9 @@ export class TreatmentsService {
]); ]);
} }
private async ensurePatientInOrg(patientId: string, organizationId: string) { private async ensurePatientInOrg(patientId: string, _organizationId: string) {
const patient = await this.prisma.patient.findFirst({ const patient = await this.prisma.patient.findUnique({
where: { id: patientId, organizationId }, where: { id: patientId },
select: { id: true }, select: { id: true },
}); });
if (!patient) { if (!patient) {

View File

@@ -296,15 +296,17 @@
"errorSavePatient": "Failed to save patient.", "errorSavePatient": "Failed to save patient.",
"firstName": "First name", "firstName": "First name",
"lastName": "Last 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", "savePatient": "Save Patient",
"dialogTitle": "New patient", "dialogTitle": "New patient",
"searchPlaceholder": "Search patients by name, phone, email", "searchPlaceholder": "Search patients by name, mobile, email",
"loadingPatients": "Loading patients...", "loadingPatients": "Loading patients...",
"noResults": "No patients found for this search.", "noResults": "No patients found for this search.",
"noContact": "No contact", "noContact": "No contact",
"selectPatient": "Select a patient to view details.", "selectPatient": "Select a patient to view details.",
"phoneLabel": "Phone:",
"emailLabel": "Email:", "emailLabel": "Email:",
"statusLabel": "Status:", "statusLabel": "Status:",
"statusActive": "Active", "statusActive": "Active",

View File

@@ -296,15 +296,17 @@
"errorSavePatient": "ذخیره بیمار ناموفق بود.", "errorSavePatient": "ذخیره بیمار ناموفق بود.",
"firstName": "نام", "firstName": "نام",
"lastName": "نام خانوادگی", "lastName": "نام خانوادگی",
"phone": "تلفن", "mobile": "موبایل",
"mobilePlaceholder": "09121234567",
"mobileLabel": "موبایل:",
"patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.",
"savePatient": "ذخیره بیمار", "savePatient": "ذخیره بیمار",
"dialogTitle": "بیمار جدید", "dialogTitle": "بیمار جدید",
"searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", "searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل",
"loadingPatients": "در حال بارگذاری بیماران...", "loadingPatients": "در حال بارگذاری بیماران...",
"noResults": "هیچ بیماری برای این جستجو یافت نشد.", "noResults": "هیچ بیماری برای این جستجو یافت نشد.",
"noContact": "بدون اطلاعات تماس", "noContact": "بدون اطلاعات تماس",
"selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.",
"phoneLabel": "تلفن:",
"emailLabel": "ایمیل:", "emailLabel": "ایمیل:",
"statusLabel": "وضعیت:", "statusLabel": "وضعیت:",
"statusActive": "فعال", "statusActive": "فعال",

View File

@@ -296,15 +296,17 @@
"errorSavePatient": "Patiënt opslaan mislukt.", "errorSavePatient": "Patiënt opslaan mislukt.",
"firstName": "Voornaam", "firstName": "Voornaam",
"lastName": "Achternaam", "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", "savePatient": "Patiënt opslaan",
"dialogTitle": "Nieuwe patiënt", "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...", "loadingPatients": "Patiënten laden...",
"noResults": "Geen patiënten gevonden voor deze zoekopdracht.", "noResults": "Geen patiënten gevonden voor deze zoekopdracht.",
"noContact": "Geen contact", "noContact": "Geen contact",
"selectPatient": "Selecteer een patiënt om details te bekijken.", "selectPatient": "Selecteer een patiënt om details te bekijken.",
"phoneLabel": "Telefoon:",
"emailLabel": "E-mail:", "emailLabel": "E-mail:",
"statusLabel": "Status:", "statusLabel": "Status:",
"statusActive": "Actief", "statusActive": "Actief",

View File

@@ -24,7 +24,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
const EMPTY_PATIENT_FORM: CreatePatientInput = { const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '', firstName: '',
lastName: '', lastName: '',
phone: '', mobile: '',
email: '', email: '',
}; };
@@ -152,12 +152,21 @@ export default function AppointmentsPage() {
setPatientForm(EMPTY_PATIENT_FORM); setPatientForm(EMPTY_PATIENT_FORM);
await loadPatientsSearch(search); await loadPatientsSearch(search);
setSelectedPatient(response.data); setSelectedPatient(response.data);
toast.showSuccess( if (response.existing) {
t('successPatientSaved', { toast.showInfo(
firstName: response.data.firstName, tPatients('patientAlreadyExists', {
lastName: response.data.lastName, firstName: response.data.firstName,
}), lastName: response.data.lastName,
); }),
);
} else {
toast.showSuccess(
t('successPatientSaved', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
);
}
} catch (err: unknown) { } catch (err: unknown) {
const message = const message =
err && typeof err === 'object' && 'message' in err err && typeof err === 'object' && 'message' in err

View File

@@ -1,151 +1,160 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useTranslations } from 'next-intl'; import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { Button } from '@/components/ui/shared/Button'; import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { ToastStack } from '@/components/ui/shared/Toast'; import { hasPermission } from '@/components/shared/permissions';
import { CreatePatientInput, Patient } from '@/types/patient';
import { patientsApi } from '@/lib/api/patients'; import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
import { useAuth } from '@/lib/hooks/useAuth'; const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
import { useToast } from '@/lib/hooks/useToast'; lastName: '',
mobile: '',
import { hasPermission } from '@/components/shared/permissions'; email: '',
};
import { CreatePatientInput, Patient } from '@/types/patient';
export default function PatientsPage() {
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; const t = useTranslations('patients');
const tCommon = useTranslations('common');
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; const { currentOrganization } = useAuth();
const toast = useToast();
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [loadingPatients, setLoadingPatients] = useState(false);
const EMPTY_PATIENT_FORM: CreatePatientInput = { const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
firstName: '', const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
lastName: '',
const sortedPatients = useMemo(
phone: '', () =>
[...patients].sort((a, b) =>
email: '', `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
}; [patients],
);
useEffect(() => {
export default function PatientsPage() { const timeout = setTimeout(() => {
void loadPatients(search);
const t = useTranslations('patients'); }, 300);
return () => clearTimeout(timeout);
const tCommon = useTranslations('common'); }, [search]);
const { currentOrganization } = useAuth(); useEffect(() => {
void loadPatients('');
const toast = useToast(); }, []);
const [search, setSearch] = useState(''); async function loadPatients(q: string) {
setLoadingPatients(true);
const [patients, setPatients] = useState<Patient[]>([]); toast.setError('');
try {
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
const [loadingPatients, setLoadingPatients] = useState(false); setPatients(items);
const [isCreateOpen, setIsCreateOpen] = useState(false); if (selectedPatient) {
const freshSelected = items.find((item) => item.id === selectedPatient.id);
const [savingPatient, setSavingPatient] = useState(false); setSelectedPatient(freshSelected);
}
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); } catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); } finally {
setLoadingPatients(false);
}
}
const sortedPatients = useMemo(
async function handleCreatePatient() {
() => setSavingPatient(true);
toast.setError('');
[...patients].sort((a, b) => try {
const response = await patientsApi.create(patientForm);
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
), await loadPatients(search);
setSelectedPatient(response.data);
[patients], if (response.existing) {
toast.showInfo(
); t('patientAlreadyExists', {
firstName: response.data.firstName,
lastName: response.data.lastName,
}),
useEffect(() => { );
} else {
const timeout = setTimeout(() => { toast.showSuccess(
t('successPatientSaved', {
void loadPatients(search); firstName: response.data.firstName,
lastName: response.data.lastName,
}, 300); }),
);
return () => clearTimeout(timeout); }
} catch (error: unknown) {
}, [search]); toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
} finally {
setSavingPatient(false);
}
useEffect(() => { }
void loadPatients(''); return (
<div className="space-y-6">
}, []); <div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<Button
variant="primary"
async function loadPatients(q: string) { disabled={!canEditPatients}
onClick={() => {
setLoadingPatients(true); if (!canEditPatients) return;
toast.clear();
toast.setError(''); setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
try { }}
title={!canEditPatients ? tCommon('readOnlyAccess') : undefined}
const response = await patientsApi.list({ q, page: 1, limit: 25 }); >
{t('newPatient')}
const items = response.data.items; </Button>
</div>
setPatients(items);
<ToastStack {...toast.messages} />
{isCreateOpen && (
if (selectedPatient) { <CreatePatientModal
isOpen={isCreateOpen}
const freshSelected = items.find((item) => item.id === selectedPatient.id); formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
setSelectedPatient(freshSelected); onSubmit={() => void handleCreatePatient()}
onClose={() => {
} setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
} catch (error: unknown) { }}
loading={savingPatient}
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); />
)}
} finally {
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
setLoadingPatients(false); <div className="xl:col-span-1">
<PatientSearchSelect
} search={search}
onSearchChange={setSearch}
} patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
async function handleCreatePatient() { />
</div>
<div className="xl:col-span-2 space-y-4">
<PatientSummaryCard patient={selectedPatient} />
</div>
</div>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/appointments/appointmentOverlapLayout'; } from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { formatMobileForDisplay } from '@/lib/phone';
import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import { startOfLocalDay } from '@/components/appointments/appointmentTime';
const HOUR_PX = 80; const HOUR_PX = 80;
@@ -300,7 +301,9 @@ export function AppointmentScheduleGrid({
clusterSize > 1 clusterSize > 1
? t('overlappingChoose', { count: clusterSize }) ? t('overlappingChoose', { count: clusterSize })
: null, : null,
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, !isUnderOneHour && apt.patient.mobile
? formatMobileForDisplay(apt.patient.mobile)
: null,
] ]
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
@@ -338,10 +341,10 @@ export function AppointmentScheduleGrid({
{patientName} {patientName}
</span> </span>
{!isUnderOneHour && {!isUnderOneHour &&
apt.patient.phone && apt.patient.mobile &&
lane.laneCount === 1 && ( lane.laneCount === 1 && (
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90"> <span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
{apt.patient.phone} {formatMobileForDisplay(apt.patient.mobile)}
</span> </span>
)} )}
{!isUnderOneHour && clusterSize > 1 && ( {!isUnderOneHour && clusterSize > 1 && (

View File

@@ -4,6 +4,7 @@ import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { formatMobileForDisplay } from '@/lib/phone';
import type { Patient } from '@/types/patient'; import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps { interface AppointmentsPatientSearchProps {
@@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({
{patient.firstName} {patient.lastName} {patient.firstName} {patient.lastName}
</p> </p>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{patient.phone || patient.email || tPatients('noContact')} {formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}
</p> </p>
</button> </button>
); );

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { CreatePatientInput } from '@/types/patient'; import { CreatePatientInput } from '@/types/patient';
import { isValidMobile, normalizeMobile } from '@/lib/phone';
interface CreatePatientModalProps { interface CreatePatientModalProps {
isOpen: boolean; isOpen: boolean;
@@ -49,9 +50,10 @@ function CreatePatientFormFields({
onChange={(e) => onChange({ lastName: e.target.value })} onChange={(e) => onChange({ lastName: e.target.value })}
/> />
<Input <Input
label={t('phone')} label={t('mobile')}
value={formData.phone || ''} value={formData.mobile || ''}
onChange={(e) => onChange({ phone: e.target.value })} onChange={(e) => onChange({ mobile: e.target.value })}
placeholder={t('mobilePlaceholder')}
/> />
<Input <Input
label={tCommon('email')} label={tCommon('email')}
@@ -66,7 +68,7 @@ function CreatePatientFormFields({
variant="primary" variant="primary"
onClick={onSubmit} onClick={onSubmit}
isLoading={loading} isLoading={loading}
disabled={!formData.firstName || !formData.lastName} disabled={!formData.firstName || !formData.lastName || !isValidMobile(normalizeMobile(formData.mobile || '') ?? '')}
> >
{t('savePatient')} {t('savePatient')}
</Button> </Button>

View File

@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { formatMobileForDisplay } from '@/lib/phone';
import { Patient } from '@/types/patient'; import { Patient } from '@/types/patient';
interface PatientSearchSelectProps { interface PatientSearchSelectProps {
@@ -56,7 +57,9 @@ export function PatientSearchSelect({
<p className="text-sm font-medium text-text-primary"> <p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName} {patient.firstName} {patient.lastName}
</p> </p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || t('noContact')}</p> <p className="text-xs text-text-muted">
{formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')}
</p>
</button> </button>
); );
})} })}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { formatMobileForDisplay } from '@/lib/phone';
import { Patient } from '@/types/patient'; import { Patient } from '@/types/patient';
interface PatientSummaryCardProps { interface PatientSummaryCardProps {
@@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
{patient.firstName} {patient.lastName} {patient.firstName} {patient.lastName}
</h2> </h2>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{t('phoneLabel')} {patient.phone || t('emptyValue')} {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
</p> </p>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{t('emailLabel')} {patient.email || t('emptyValue')} {t('emailLabel')} {patient.email || t('emptyValue')}

View File

@@ -1,6 +1,7 @@
import { apiClient } from './client'; import { apiClient } from './client';
import { import {
CreatePatientInput, CreatePatientInput,
CreatePatientResponse,
Patient, Patient,
PatientsListResponse, PatientsListResponse,
} from '@/types/patient'; } from '@/types/patient';
@@ -11,7 +12,7 @@ export const patientsApi = {
return response.data; return response.data;
}, },
create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => { create: async (data: CreatePatientInput): Promise<CreatePatientResponse> => {
const response = await apiClient.post('/patients', data); const response = await apiClient.post('/patients', data);
return response.data; return response.data;
}, },

44
frontend/src/lib/phone.ts Normal file
View File

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

View File

@@ -25,5 +25,5 @@ export interface AppointmentRecord {
startAt: string; startAt: string;
endAt: string; endAt: string;
purpose: string; purpose: string;
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'phone'>; patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'>;
} }

View File

@@ -1,13 +1,13 @@
export interface Patient { export interface Patient {
id: string; id: string;
organizationId: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
phone?: string | null; mobile: string;
email?: string | null; email?: string | null;
dateOfBirth?: string | null; dateOfBirth?: string | null;
notes?: string | null; notes?: string | null;
isActive: boolean; isActive: boolean;
createdByOrganizationId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -15,7 +15,7 @@ export interface Patient {
export interface CreatePatientInput { export interface CreatePatientInput {
firstName: string; firstName: string;
lastName: string; lastName: string;
phone?: string; mobile: string;
email?: string; email?: string;
dateOfBirth?: string; dateOfBirth?: string;
notes?: string; notes?: string;
@@ -33,3 +33,9 @@ export interface PatientsListResponse {
}; };
}; };
} }
export interface CreatePatientResponse {
success: boolean;
data: Patient;
existing?: boolean;
}