From 580bc983b3701fffca0d2b6fc83e87eb54700033 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 11:51:18 +0330 Subject: [PATCH] =?UTF-8?q?feat(patient):=20complete=20"=D8=A7=D8=B7=D9=84?= =?UTF-8?q?=D8=A7=D8=B9=D8=A7=D8=AA=20=D9=BE=D8=B1=D9=88=D9=86=D8=AF=D9=87?= =?UTF-8?q?"=20demographic=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/PatientRecordInfoForm.test.tsx | 83 +++++++ .../components/PatientRecordInfoForm.tsx | 206 ++++++++++++++++++ assets/admin/components/ui/Field.tsx | 26 +++ assets/admin/components/ui/Input.tsx | 23 ++ .../admin/components/ui/InputField.test.tsx | 40 ++++ assets/admin/hooks/usePatient.test.tsx | 53 +++++ assets/admin/hooks/usePatient.ts | 40 ++++ assets/admin/lib/patientForm.test.ts | 76 +++++++ assets/admin/lib/patientForm.ts | 83 +++++++ assets/admin/pages/MyPatientsPage.tsx | 167 +++++--------- assets/admin/types/index.ts | 30 +++ docs/api/patient.md | 30 ++- migrations/Version20260713074150.php | 31 +++ src/Auth/Entity/User.php | 2 + src/Patient/Controller/PatientController.php | 61 +++++- src/Shared/Constant/ErrorCodes.php | 2 + src/UserProfile/Entity/UserProfile.php | 30 +++ tests/Patient/PatientUpdateProfileTest.php | 123 +++++++++++ tests/Patient/UserProfileDemographicsTest.php | 64 ++++++ 19 files changed, 1042 insertions(+), 128 deletions(-) create mode 100644 assets/admin/components/PatientRecordInfoForm.test.tsx create mode 100644 assets/admin/components/PatientRecordInfoForm.tsx create mode 100644 assets/admin/components/ui/Field.tsx create mode 100644 assets/admin/components/ui/Input.tsx create mode 100644 assets/admin/components/ui/InputField.test.tsx create mode 100644 assets/admin/hooks/usePatient.test.tsx create mode 100644 assets/admin/hooks/usePatient.ts create mode 100644 assets/admin/lib/patientForm.test.ts create mode 100644 assets/admin/lib/patientForm.ts create mode 100644 migrations/Version20260713074150.php create mode 100644 tests/Patient/PatientUpdateProfileTest.php create mode 100644 tests/Patient/UserProfileDemographicsTest.php diff --git a/assets/admin/components/PatientRecordInfoForm.test.tsx b/assets/admin/components/PatientRecordInfoForm.test.tsx new file mode 100644 index 00000000..4613a9f8 --- /dev/null +++ b/assets/admin/components/PatientRecordInfoForm.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PatientRecordInfoForm, { + type PatientFormValues, + type PatientFormOptions, +} from '@/components/PatientRecordInfoForm'; + +const options: PatientFormOptions = { + gender: [{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }], + marital: [{ value: 'single', label: 'مجرد' }], + education: [{ value: 'bachelor', label: 'کارشناسی' }], + insurance: [{ value: 3, label: 'تأمین اجتماعی' }], + province: [{ value: 8, label: 'یزد' }], + city: [{ value: 42, label: 'یزد' }], + referral: [{ value: 'instagram', label: 'اینستاگرام' }], +}; + +const baseValues: PatientFormValues = { + name: 'ساغر صابری نژاد', + mobile: '', + national_code: '', + gender: null, + birth_date: '', + fathers_name: '', + marital_status: null, + basic_insurance_id: null, + field_of_study: '', + education: null, + job: '', + province_id: null, + city_id: null, + home_phone: '', + address: '', + postal_code: '', + referral_source: null, + description: '', +}; + +function setup(overrides: Partial = {}) { + const onSubmit = vi.fn(); + render( + , + ); + return { onSubmit }; +} + +describe('PatientRecordInfoForm', () => { + it('برچسب‌های اصلی و شماره پروندهٔ read-only را رندر می‌کند', () => { + setup(); + expect(screen.getByText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument(); + expect(screen.getByText('نحوه آشنایی')).toBeInTheDocument(); + expect((screen.getByDisplayValue('123456789') as HTMLInputElement).readOnly).toBe(true); + }); + + it('نام خالی → خطای الزامی و onSubmit صدا نمی‌خورد', async () => { + const { onSubmit } = setup({ name: '' }); + await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ })); + expect(await screen.findByText('نام و نام خانوادگی الزامی است')).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('کدملی نامعتبر → خطای ۱۰ رقم', async () => { + setup({ national_code: '123' }); + await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ })); + expect(await screen.findByText('کد ملی باید ۱۰ رقم باشد')).toBeInTheDocument(); + }); + + it('ورودی معتبر → onSubmit با مقادیر فرم', async () => { + const { onSubmit } = setup({ national_code: '0012345678' }); + await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + name: 'ساغر صابری نژاد', + national_code: '0012345678', + }); + }); +}); diff --git a/assets/admin/components/PatientRecordInfoForm.tsx b/assets/admin/components/PatientRecordInfoForm.tsx new file mode 100644 index 00000000..d055e073 --- /dev/null +++ b/assets/admin/components/PatientRecordInfoForm.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Field from './ui/Field'; +import Input from './ui/Input'; +import MobileInput from './ui/MobileInput'; +import SearchableSelect from './ui/SearchableSelect'; +import type { SelectOption } from './ui/SearchableSelect'; +import PersianDatePicker from './ui/PersianDatePicker'; + +/** + * اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بک‌اند: + * نام الزامی؛ موبایل در صورت پرشدن باید ^09\d{9}$؛ کدملی در صورت پرشدن ۱۰ رقم. + * `birth_date` رشتهٔ میلادی YYYY-MM-DD است؛ تبدیل به timestamp در لایهٔ صفحه انجام می‌شود. + */ +export const patientFormSchema = z.object({ + name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'), + mobile: z.string().trim().regex(/^09\d{9}$/, 'شماره موبایل نامعتبر است').or(z.literal('')), + national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')), + gender: z.string().nullable(), + birth_date: z.string(), + fathers_name: z.string(), + marital_status: z.string().nullable(), + basic_insurance_id: z.union([z.number(), z.string(), z.null()]), + field_of_study: z.string(), + education: z.string().nullable(), + job: z.string(), + province_id: z.union([z.number(), z.string(), z.null()]), + city_id: z.union([z.number(), z.string(), z.null()]), + home_phone: z.string(), + address: z.string(), + postal_code: z.string(), + referral_source: z.string().nullable(), + description: z.string(), +}); + +export type PatientFormValues = z.infer; + +export interface PatientFormOptions { + gender: SelectOption[]; + marital: SelectOption[]; + education: SelectOption[]; + insurance: SelectOption[]; + province: SelectOption[]; + city: SelectOption[]; + referral: SelectOption[]; +} + +interface Props { + defaultValues: PatientFormValues; + recordNumber?: string | null; + options: PatientFormOptions; + onSubmit: (values: PatientFormValues) => void; + isSubmitting?: boolean; + /** برای بارگذاری وابستهٔ شهرها هنگام تغییر استان (منطق در صفحه). */ + onProvinceChange?: (provinceId: number | null) => void; +} + +const grid: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 'var(--gap)', +}; + +export default function PatientRecordInfoForm({ + defaultValues, + recordNumber, + options, + onSubmit, + isSubmitting, + onProvinceChange, +}: Props) { + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(patientFormSchema), + defaultValues, + }); + + const sel = (name: keyof PatientFormValues, label: string, opts: SelectOption[], placeholder = 'انتخاب کنید...') => ( + + ( + + )} + /> + + ); + + return ( +
+
+ + + + + + + + + {sel('gender', 'جنسیت', options.gender)} + + + + + + + ( + + )} + /> + + + + ( + + )} + /> + + + + + + + {sel('marital_status', 'وضعیت تاهل', options.marital)} + {sel('basic_insurance_id', 'نوع بیمه', options.insurance)} + + + + + + {sel('education', 'مقطع تحصیلی', options.education)} + + + + + + + ( + { field.onChange(v); onProvinceChange?.(v === null ? null : Number(v)); }} + placeholder="انتخاب کنید..." + /> + )} + /> + + + {sel('city_id', 'شهر', options.city)} + + + + + + + + + + + + + + {sel('referral_source', 'نحوه آشنایی', options.referral)} +
+ +
+ +