feat(patient): complete "اطلاعات پرونده" demographic form

Backend:
- Add profile columns field_of_study, province_id, city_id, postal_code,
  referral_source (UserProfile + migration).
- Extend PATCH /api/v1/patient/{uuid} to persist all demographic fields
  and return them in the patient profile payload.
- Support editable mobile (login identifier): validation, uniqueness,
  User.setMobileNumber, new ERR_PROFILE_002.
- Update docs/api/patient.md.

Frontend:
- New reusable Input, Field, and PatientRecordInfoForm (RHF + Zod).
- usePatient/useUpdatePatient hooks and patientForm mapping helpers.
- Extend the existing "info" tab in MyPatientsPage to the full field set
  via the shared form (province/city/insurance options, Jalali date).

Tests: Patient entity + PATCH integration (PHPUnit); form, hooks, and
mapping helpers (Vitest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 11:51:18 +03:30
co-authored by Claude Opus 4.8
parent 9d8de9bc33
commit 580bc983b3
19 changed files with 1042 additions and 128 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import {
tsToDateStr,
dateStrToTs,
profileToFormValues,
formValuesToPayload,
} from '@/lib/patientForm';
import type { PatientProfile } from '@/types';
import type { PatientFormValues } from '@/components/PatientRecordInfoForm';
describe('date helpers', () => {
it('timestamp ↔ YYYY-MM-DD رفت‌وبرگشت', () => {
const ts = dateStrToTs('2025-01-07');
expect(ts).toBe(Math.floor(Date.parse('2025-01-07T00:00:00Z') / 1000));
expect(tsToDateStr(ts)).toBe('2025-01-07');
});
it('خالی/نامعتبر → مقدار تهی', () => {
expect(tsToDateStr(null)).toBe('');
expect(tsToDateStr(0)).toBe('');
expect(dateStrToTs('')).toBeNull();
});
});
describe('profileToFormValues', () => {
it('فیلدهای جدید و تاریخ را درست مپ می‌کند', () => {
const p: PatientProfile = {
name: 'ساغر', fathers_name: 'رضا', national_code: '0012345678',
date_of_birth: dateStrToTs('2000-05-10')!, field_of_study: 'نرم‌افزار',
province_id: 8, city_id: 42, postal_code: '8913746351',
referral_source: 'اینستاگرام', description: 'توضیح', mobile: '09131234567',
};
const v = profileToFormValues(p);
expect(v.name).toBe('ساغر');
expect(v.fathers_name).toBe('رضا');
expect(v.birth_date).toBe('2000-05-10');
expect(v.province_id).toBe(8);
expect(v.city_id).toBe(42);
expect(v.referral_source).toBe('اینستاگرام');
});
it('پروفایل تهی → مقادیر پیش‌فرض امن', () => {
const v = profileToFormValues(null);
expect(v.name).toBe('');
expect(v.birth_date).toBe('');
expect(v.province_id).toBeNull();
});
});
describe('formValuesToPayload', () => {
const base: PatientFormValues = {
name: 'ساغر', mobile: '', national_code: '0012345678', gender: 'female',
birth_date: '2000-05-10', fathers_name: 'رضا', marital_status: 'single',
basic_insurance_id: '3', field_of_study: 'نرم‌افزار', education: 'کارشناسی',
job: 'مهندس', province_id: 8, city_id: '42', home_phone: '', address: 'یزد',
postal_code: '8913746351', referral_source: 'اینستاگرام', description: '',
};
it('تاریخ به timestamp و idها به عدد تبدیل می‌شوند', () => {
const out = formValuesToPayload(base);
expect(out.date_of_birth).toBe(dateStrToTs('2000-05-10'));
expect(out.basic_insurance_id).toBe(3);
expect(out.province_id).toBe(8);
expect(out.city_id).toBe(42);
});
it('موبایل خالی ارسال نمی‌شود؛ موبایل پرشده ارسال می‌شود', () => {
expect('mobile' in formValuesToPayload(base)).toBe(false);
const withMobile = formValuesToPayload({ ...base, mobile: '09131234567' });
expect(withMobile.mobile).toBe('09131234567');
});
it('id خالی → null', () => {
const out = formValuesToPayload({ ...base, basic_insurance_id: null, province_id: '' });
expect(out.basic_insurance_id).toBeNull();
expect(out.province_id).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { SelectOption } from '../components/ui/SearchableSelect';
import type { PatientProfile, PatientProfileUpdate } from '../types';
import type { PatientFormValues } from '../components/PatientRecordInfoForm';
// ── enumهای ثابت فرم «اطلاعات پرونده» (بدون منبع API) ─────────────────────────
export const GENDER_OPTS: SelectOption[] = [
{ value: 'female', label: 'زن' },
{ value: 'male', label: 'مرد' },
];
export const MARITAL_OPTS: SelectOption[] = [
{ value: 'single', label: 'مجرد' },
{ value: 'married', label: 'متأهل' },
{ value: 'divorced', label: 'مطلقه' },
{ value: 'widowed', label: 'بیوه' },
];
export const EDUCATION_OPTS: SelectOption[] = [
'زیر دیپلم', 'دیپلم', 'کاردانی', 'کارشناسی', 'کارشناسی ارشد', 'دکتری',
].map((v) => ({ value: v, label: v }));
export const REFERRAL_OPTS: SelectOption[] = [
'اینستاگرام', 'معرفی دوستان', 'جستجوی اینترنتی', 'بیلبورد', 'سایر',
].map((v) => ({ value: v, label: v }));
// ── تبدیل تاریخ: timestamp ثانیه‌ای ↔ رشتهٔ میلادی YYYY-MM-DD ─────────────────
export function tsToDateStr(ts?: number | null): string {
if (!ts) return '';
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export function dateStrToTs(s: string): number | null {
if (!s) return null;
const t = Date.parse(`${s}T00:00:00Z`);
return Number.isNaN(t) ? null : Math.floor(t / 1000);
}
/** پروفایل API → مقادیر پیش‌فرض فرم. */
export function profileToFormValues(p: PatientProfile | null): PatientFormValues {
return {
name: p?.name ?? '',
mobile: p?.mobile ?? '',
national_code: p?.national_code ?? '',
gender: p?.gender ?? null,
birth_date: tsToDateStr(p?.date_of_birth),
fathers_name: p?.fathers_name ?? '',
marital_status: p?.marital_status ?? null,
basic_insurance_id: p?.basic_insurance_id ?? null,
field_of_study: p?.field_of_study ?? '',
education: p?.education ?? null,
job: p?.job ?? '',
province_id: p?.province_id ?? null,
city_id: p?.city_id ?? null,
home_phone: p?.home_phone ?? '',
address: p?.address ?? '',
postal_code: p?.postal_code ?? '',
referral_source: p?.referral_source ?? null,
description: p?.description ?? '',
};
}
/** مقادیر فرم → بدنهٔ PATCH. موبایل خالی ارسال نمی‌شود تا شناسهٔ ورود دست‌نخورده بماند. */
export function formValuesToPayload(v: PatientFormValues): PatientProfileUpdate {
const num = (x: number | string | null): number | null =>
x === null || x === '' ? null : Number(x);
const payload: PatientProfileUpdate = {
name: v.name,
national_code: v.national_code,
gender: v.gender,
fathers_name: v.fathers_name,
date_of_birth: dateStrToTs(v.birth_date),
marital_status: v.marital_status,
education: v.education,
field_of_study: v.field_of_study,
job: v.job,
basic_insurance_id: num(v.basic_insurance_id),
province_id: num(v.province_id),
city_id: num(v.city_id),
home_phone: v.home_phone,
address: v.address,
postal_code: v.postal_code,
referral_source: v.referral_source,
description: v.description,
};
if (v.mobile) payload.mobile = v.mobile;
return payload;
}