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
@@ -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<PatientFormValues> = {}) {
const onSubmit = vi.fn();
render(
<PatientRecordInfoForm
defaultValues={{ ...baseValues, ...overrides }}
recordNumber="123456789"
options={options}
onSubmit={onSubmit}
/>,
);
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',
});
});
});
@@ -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<typeof patientFormSchema>;
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<PatientFormValues>({
resolver: zodResolver(patientFormSchema),
defaultValues,
});
const sel = (name: keyof PatientFormValues, label: string, opts: SelectOption[], placeholder = 'انتخاب کنید...') => (
<Field label={label} error={errors[name]?.message as string | undefined}>
<Controller
name={name}
control={control}
render={({ field }) => (
<SearchableSelect
options={opts}
value={field.value as string | number | null}
onChange={field.onChange}
placeholder={placeholder}
/>
)}
/>
</Field>
);
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div style={grid}>
<Field label="نام و نام خانوادگی مراجعه کننده" error={errors.name?.message}>
<Input {...register('name')} hasError={!!errors.name} placeholder="نام و نام خانوادگی" />
</Field>
<Field label="شماره پرونده">
<Input value={recordNumber ?? '—'} readOnly dir="ltr" />
</Field>
{sel('gender', 'جنسیت', options.gender)}
<Field label="کدملی" error={errors.national_code?.message}>
<Input {...register('national_code')} hasError={!!errors.national_code} dir="ltr" maxLength={10} placeholder="کد ملی" />
</Field>
<Field label="شماره تماس" error={errors.mobile?.message}>
<Controller
name="mobile"
control={control}
render={({ field }) => (
<MobileInput value={field.value} onChange={field.onChange} hasError={!!errors.mobile} />
)}
/>
</Field>
<Field label="تاریخ تولد" error={errors.birth_date?.message}>
<Controller
name="birth_date"
control={control}
render={({ field }) => (
<PersianDatePicker value={field.value} onChange={field.onChange} placeholder="تاریخ تولد" enableYearPicker />
)}
/>
</Field>
<Field label="نام پدر">
<Input {...register('fathers_name')} placeholder="نام پدر را وارد نمایید" />
</Field>
{sel('marital_status', 'وضعیت تاهل', options.marital)}
{sel('basic_insurance_id', 'نوع بیمه', options.insurance)}
<Field label="رشته تحصیلی">
<Input {...register('field_of_study')} placeholder="رشته تحصیلی را وارد نمایید" />
</Field>
{sel('education', 'مقطع تحصیلی', options.education)}
<Field label="شغل">
<Input {...register('job')} placeholder="شغل را وارد نمایید" />
</Field>
<Field label="استان" error={errors.province_id?.message as string | undefined}>
<Controller
name="province_id"
control={control}
render={({ field }) => (
<SearchableSelect
options={options.province}
value={field.value as string | number | null}
onChange={(v) => { field.onChange(v); onProvinceChange?.(v === null ? null : Number(v)); }}
placeholder="انتخاب کنید..."
/>
)}
/>
</Field>
{sel('city_id', 'شهر', options.city)}
<Field label="تلفن ثابت">
<Input {...register('home_phone')} dir="ltr" placeholder="تلفن ثابت را وارد نمایید..." />
</Field>
<Field label="آدرس" style={{ gridColumn: 'span 2' }}>
<Input {...register('address')} placeholder="آدرس محل سکونت را وارد نمایید..." />
</Field>
<Field label="کد پستی">
<Input {...register('postal_code')} dir="ltr" placeholder="کدپستی محل سکونت را وارد نمایید..." />
</Field>
{sel('referral_source', 'نحوه آشنایی', options.referral)}
</div>
<div style={{ marginTop: 'var(--gap)' }}>
<Field label="توضیحات">
<textarea
{...register('description')}
className="cp-input"
rows={4}
style={{ resize: 'vertical', minHeight: 96 }}
placeholder="توضیحات"
/>
</Field>
</div>
<div style={{ marginTop: 'var(--gap)' }}>
<button type="submit" className="cp-btn cp-btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'در حال ثبت...' : 'ثبت اطلاعات'}
</button>
</div>
</form>
);
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
interface Props {
label: string;
htmlFor?: string;
error?: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}
/**
* چیدمان یک فیلد فرم: برچسب بالا، کنترل (children)، و پیام خطای زیر آن.
* presentational — منطق فرم/ولیدیشن بیرون از این کامپوننت است.
*/
export default function Field({ label, htmlFor, error, className, style, children }: Props) {
return (
<div className={className} style={{ display: 'flex', flexDirection: 'column', gap: 6, ...style }}>
<label htmlFor={htmlFor} className="cp-label">{label}</label>
{children}
{error && (
<span style={{ color: 'var(--danger)', fontSize: 12 }}>{error}</span>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
hasError?: boolean;
};
/**
* Input متنی پایهٔ design system: کلاس `cp-input`، حالت خطا (قاب قرمز)،
* و forwardRef برای سازگاری با react-hook-form `register`.
*/
export default React.forwardRef<HTMLInputElement, Props>(function Input(
{ hasError, className, style, ...rest },
ref,
) {
return (
<input
{...rest}
ref={ref}
className={className ?? 'cp-input'}
style={hasError ? { borderColor: 'var(--danger)', ...(style || {}) } : style}
/>
);
});
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import Input from '@/components/ui/Input';
import Field from '@/components/ui/Field';
describe('Input', () => {
it('مقدار و placeholder را رندر می‌کند', () => {
render(<Input defaultValue="ساغر" placeholder="نام" />);
const el = screen.getByPlaceholderText('نام') as HTMLInputElement;
expect(el.value).toBe('ساغر');
expect(el.className).toBe('cp-input');
});
it('در حالت hasError قاب قرمز می‌گیرد', () => {
render(<Input placeholder="کدملی" hasError />);
const el = screen.getByPlaceholderText('کدملی') as HTMLInputElement;
expect(el.style.borderColor).toBe('var(--danger)');
});
});
describe('Field', () => {
it('برچسب و فرزند را نشان می‌دهد و بدون خطا پیام خطا ندارد', () => {
render(
<Field label="نام پدر">
<Input placeholder="نام پدر را وارد کنید" />
</Field>,
);
expect(screen.getByText('نام پدر')).toBeInTheDocument();
expect(screen.getByPlaceholderText('نام پدر را وارد کنید')).toBeInTheDocument();
});
it('پیام خطا را وقتی error داده شود نمایش می‌دهد', () => {
render(
<Field label="کدملی" error="کد ملی باید ۱۰ رقم باشد">
<Input />
</Field>,
);
expect(screen.getByText('کد ملی باید ۱۰ رقم باشد')).toBeInTheDocument();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { renderHookWithClient } from '@/test/utils';
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '@/lib/api';
import { usePatient, useUpdatePatient } from '@/hooks/usePatient';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
patch.mockReset();
});
describe('usePatient', () => {
it('پروفایل را از data.data.profile استخراج می‌کند', async () => {
get.mockResolvedValue({
data: { uuid: 'u-1', profile: { name: 'ساغر', national_code: '0012345678', city_id: 42 } },
});
const { result } = renderHookWithClient(() => usePatient('u-1'));
await waitFor(() => expect(result.current.profile).not.toBeNull());
expect(get).toHaveBeenCalledWith('/api/v1/patient/u-1');
expect(result.current.profile?.name).toBe('ساغر');
expect(result.current.profile?.city_id).toBe(42);
expect(result.current.record?.uuid).toBe('u-1');
});
it('بدون uuid کوئری اجرا نمی‌شود', () => {
const { result } = renderHookWithClient(() => usePatient(undefined));
expect(get).not.toHaveBeenCalled();
expect(result.current.profile).toBeNull();
});
});
describe('useUpdatePatient', () => {
it('PATCH را با بدنه به آدرس بیمار می‌فرستد', async () => {
patch.mockResolvedValue({ data: { uuid: 'u-1', profile: {} } });
const { result } = renderHookWithClient(() => useUpdatePatient('u-1'));
result.current.mutate({ fathers_name: 'رضا', city_id: 42 });
await waitFor(() => expect(patch).toHaveBeenCalled());
expect(patch).toHaveBeenCalledWith('/api/v1/patient/u-1', { fathers_name: 'رضا', city_id: 42 });
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord, PatientProfileUpdate } from '../types';
/**
* دریافت یک پروندهٔ بیمار به‌همراه پروفایل کامل (`profile`).
* منبع: GET /api/v1/patient/{uuid}. کلید کوئری: ['patient', uuid].
*/
export function usePatient(uuid: string | undefined) {
const query = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}`),
enabled: !!uuid,
});
const record = query.data?.data ?? null;
return {
record,
profile: record?.profile ?? null,
isLoading: query.isLoading,
isError: query.isError,
};
}
/**
* به‌روزرسانی partial پروفایل بیمار.
* منبع: PATCH /api/v1/patient/{uuid}. پس از موفقیت، کوئری همان بیمار را invalidate می‌کند.
*/
export function useUpdatePatient(uuid: string) {
const qc = useQueryClient();
return useMutation<ApiResponse<PatientRecord>, unknown, PatientProfileUpdate>({
mutationFn: (body) => api.patch(`/api/v1/patient/${uuid}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient', uuid] });
},
});
}
+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;
}
+49 -118
View File
@@ -40,6 +40,15 @@ import Modal from "../components/ui/Modal";
import PageHeader from "../components/ui/PageHeader";
import Pagination from "../components/ui/Pagination";
import SearchableSelect from "../components/ui/SearchableSelect";
import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
import {
GENDER_OPTS,
MARITAL_OPTS,
EDUCATION_OPTS,
REFERRAL_OPTS,
profileToFormValues,
formValuesToPayload,
} from "../lib/patientForm";
import type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import {
@@ -240,14 +249,7 @@ function MyPatientsPageInner() {
const [newPatientName, setNewPatientName] = useState("");
const mobileInputRef = useRef<HTMLInputElement>(null);
const EMPTY_EDIT = {
name: "", family: "", national_code: "", gender: "", blood_type: "",
marital_status: "", job: "", home_phone: "", work_phone: "", address: "",
basic_insurance_id: "", supplementary_insurance_id: "",
};
const [editOpen, setEditOpen] = useState(false);
const [editForm, setEditForm] = useState<Record<string, string>>(EMPTY_EDIT);
const setEditField = (k: string, v: string) => setEditForm((f) => ({ ...f, [k]: v }));
const servicesTotal = selectedServices.reduce(
(sum, s) => sum + s.price_rials,
@@ -279,6 +281,22 @@ function MyPatientsPageInner() {
});
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
// استان/شهر برای فرم «اطلاعات پرونده» (منبع: دامنهٔ Location؛ شهر وابسته به استان)
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
const provincesQ = useQuery({
queryKey: ["provinces"],
queryFn: () => api.get<any>("/api/v1/provinces"),
staleTime: 600_000,
});
const citiesQ = useQuery({
queryKey: ["cities", editProvinceId],
queryFn: () =>
api.get<any>(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ""}`),
staleTime: 300_000,
});
const locOpts = (raw: any) =>
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
PaginatedResponse<PatientSession>
>({
@@ -385,21 +403,7 @@ function MyPatientsPageInner() {
};
const openEditInfo = () => {
const p = patientProfile;
setEditForm({
name: p?.name ?? "",
family: p?.family ?? "",
national_code: p?.national_code ?? "",
gender: p?.gender ?? "",
blood_type: p?.blood_type ?? "",
marital_status: p?.marital_status ?? "",
job: p?.job ?? "",
home_phone: p?.home_phone ?? "",
work_phone: p?.work_phone ?? "",
address: p?.address ?? "",
basic_insurance_id: p?.basic_insurance_id != null ? String(p.basic_insurance_id) : "",
supplementary_insurance_id: p?.supplementary_insurance_id != null ? String(p.supplementary_insurance_id) : "",
});
setEditProvinceId(patientProfile?.province_id ?? null);
setEditOpen(true);
};
@@ -417,26 +421,6 @@ function MyPatientsPageInner() {
},
});
const submitEditInfo = () => {
if (!editForm.name.trim()) {
toast.error("نام بیمار الزامی است");
return;
}
updatePatientMut.mutate({
name: editForm.name.trim(),
family: editForm.family.trim() || null,
national_code: editForm.national_code.trim(),
gender: editForm.gender || null,
blood_type: editForm.blood_type.trim() || null,
marital_status: editForm.marital_status || null,
job: editForm.job.trim() || null,
home_phone: editForm.home_phone.trim() || null,
work_phone: editForm.work_phone.trim() || null,
address: editForm.address.trim() || null,
basic_insurance_id: editForm.basic_insurance_id ? Number(editForm.basic_insurance_id) : null,
supplementary_insurance_id: editForm.supplementary_insurance_id ? Number(editForm.supplementary_insurance_id) : null,
});
};
const settleSessionMut = useMutation({
mutationFn: (uuid: string) =>
@@ -1148,17 +1132,23 @@ function MyPatientsPageInner() {
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
{([
["نام کامل", patientProfile.full_name],
["نام پدر", patientProfile.fathers_name],
["کد ملی", patientProfile.national_code],
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
["گروه خونی", patientProfile.blood_type],
["وضعیت تأهل", patientProfile.marital_status],
["مقطع تحصیلی", patientProfile.education],
["رشته تحصیلی", patientProfile.field_of_study],
["شغل", patientProfile.job],
["موبایل", patientProfile.mobile],
["تلفن منزل", patientProfile.home_phone],
["بیمه پایه", patientProfile.basic_insurance_name],
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
["کد پستی", patientProfile.postal_code],
["نحوه آشنایی", patientProfile.referral_source],
["آدرس", patientProfile.address],
["توضیحات", patientProfile.description],
] as [string, string | null | undefined][]).map(([label, value]) => (
<div key={label}>
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
@@ -1173,85 +1163,26 @@ function MyPatientsPageInner() {
<Modal
open={editOpen}
title="ویرایش اطلاعات بیمار"
title="ویرایش اطلاعات پرونده"
size="lg"
onClose={() => setEditOpen(false)}
footer={
<>
<button className="cp-btn-ghost" onClick={() => setEditOpen(false)}>انصراف</button>
<button className="cp-btn-primary" onClick={submitEditInfo} disabled={updatePatientMut.isPending}>
{updatePatientMut.isPending ? "در حال ذخیره…" : "ذخیره"}
</button>
</>
}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12 }}>
<div>
<label className="cp-label">نام</label>
<input className="cp-input" value={editForm.name} onChange={(e) => setEditField("name", e.target.value)} />
</div>
<div>
<label className="cp-label">نام خانوادگی</label>
<input className="cp-input" value={editForm.family} onChange={(e) => setEditField("family", e.target.value)} />
</div>
<div>
<label className="cp-label">کد ملی</label>
<input className="cp-input" dir="ltr" inputMode="numeric" maxLength={10} value={editForm.national_code} onChange={(e) => setEditField("national_code", e.target.value.replace(/\D/g, ""))} />
</div>
<div>
<label className="cp-label">جنسیت</label>
<select className="cp-select" value={editForm.gender} onChange={(e) => setEditField("gender", e.target.value)}>
<option value=""></option>
<option value="male">مرد</option>
<option value="female">زن</option>
</select>
</div>
<div>
<label className="cp-label">گروه خونی</label>
<input className="cp-input" value={editForm.blood_type} onChange={(e) => setEditField("blood_type", e.target.value)} />
</div>
<div>
<label className="cp-label">وضعیت تأهل</label>
<select className="cp-select" value={editForm.marital_status} onChange={(e) => setEditField("marital_status", e.target.value)}>
<option value=""></option>
<option value="single">مجرد</option>
<option value="married">متأهل</option>
</select>
</div>
<div>
<label className="cp-label">شغل</label>
<input className="cp-input" value={editForm.job} onChange={(e) => setEditField("job", e.target.value)} />
</div>
<div>
<label className="cp-label">تلفن منزل</label>
<input className="cp-input" dir="ltr" value={editForm.home_phone} onChange={(e) => setEditField("home_phone", e.target.value)} />
</div>
<div>
<label className="cp-label">تلفن محل کار</label>
<input className="cp-input" dir="ltr" value={editForm.work_phone} onChange={(e) => setEditField("work_phone", e.target.value)} />
</div>
<div>
<label className="cp-label">بیمه پایه</label>
<select className="cp-select" value={editForm.basic_insurance_id} onChange={(e) => setEditField("basic_insurance_id", e.target.value)}>
<option value=""></option>
{baseInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="cp-label">بیمه تکمیلی</label>
<select className="cp-select" value={editForm.supplementary_insurance_id} onChange={(e) => setEditField("supplementary_insurance_id", e.target.value)}>
<option value=""></option>
{suppInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div style={{ marginTop: 12 }}>
<label className="cp-label">آدرس</label>
<textarea className="cp-textarea" rows={2} value={editForm.address} onChange={(e) => setEditField("address", e.target.value)} />
</div>
<div style={{ marginTop: 12, fontSize: 12, color: "var(--text-3)" }}>
شماره موبایل ({patientProfile?.mobile}) شناسهی حساب بیمار است و از اینجا قابل تغییر نیست.
</div>
<PatientRecordInfoForm
defaultValues={profileToFormValues(patientProfile)}
recordNumber={selectedRecord?.uuid}
options={{
gender: GENDER_OPTS,
marital: MARITAL_OPTS,
education: EDUCATION_OPTS,
referral: REFERRAL_OPTS,
insurance: baseInsuranceOptions,
province: locOpts(provincesQ.data),
city: locOpts(citiesQ.data),
}}
onSubmit={(v) => updatePatientMut.mutate(formValuesToPayload(v))}
isSubmitting={updatePatientMut.isPending}
onProvinceChange={setEditProvinceId}
/>
</Modal>
{detailTab === "visits" && (
+30
View File
@@ -462,13 +462,21 @@ export interface PatientProfile {
full_name?: string | null;
name?: string | null;
family?: string | null;
fathers_name?: string | null;
national_code?: string | null;
gender?: string | null;
date_of_birth?: number | null;
blood_type?: string | null;
marital_status?: string | null;
education?: string | null;
field_of_study?: string | null;
job?: string | null;
address?: string | null;
province_id?: number | null;
city_id?: number | null;
postal_code?: string | null;
referral_source?: string | null;
description?: string | null;
home_phone?: string | null;
work_phone?: string | null;
mobile?: string | null;
@@ -478,6 +486,28 @@ export interface PatientProfile {
supplementary_insurance_name?: string | null;
}
/** بدنه‌ی PATCH /api/v1/patient/{uuid} — تمام کلیدها اختیاری (partial update). */
export interface PatientProfileUpdate {
name?: string;
mobile?: string;
national_code?: string;
gender?: string | null;
fathers_name?: string | null;
date_of_birth?: number | null;
marital_status?: string | null;
education?: string | null;
field_of_study?: string | null;
job?: string | null;
basic_insurance_id?: number | null;
province_id?: number | null;
city_id?: number | null;
home_phone?: string | null;
address?: string | null;
postal_code?: string | null;
referral_source?: string | null;
description?: string | null;
}
export interface PatientRecord {
uuid: string;
entity_type: string;
+28 -2
View File
@@ -141,13 +141,21 @@ Returns a single patient record, enriched with the patient's full profile (`prof
"full_name": "محمد محمدی",
"name": "محمد",
"family": "محمدی",
"fathers_name": "رضا",
"national_code": "0012345678",
"gender": "male",
"date_of_birth": 700000000,
"blood_type": "O+",
"marital_status": "single",
"education": "کارشناسی",
"field_of_study": "نرم‌افزار",
"job": "...",
"address": "...",
"province_id": 8,
"city_id": 42,
"postal_code": "8913746351",
"referral_source": "اینستاگرام",
"description": "...",
"home_phone": "...",
"work_phone": "...",
"mobile": "0912...",
@@ -181,7 +189,7 @@ PATCH /api/v1/patient/{uuid}
به‌روزرسانی **partial** است — فقط کلیدهای ارسال‌شده اعمال می‌شوند. مقدار `""`/`null` برای فیلدهای پروفایل یعنی «پاک‌کردن». `name` روی `User.realName` و بقیه‌ی فیلدها روی `UserProfile` می‌نشینند (در صورت نبود پروفایل، ساخته می‌شود).
> **شماره موبایل قابل ویرایش نیست** — شناسه‌ی حساب کاربر است و در این endpoint نادیده گرفته می‌شود.
> **شماره موبایل قابل ویرایش است** — موبایل همان شناسه‌ی ورود کاربر است، پس ارسال `mobile` علاوه بر شماره‌ی تماس، **نام‌کاربری ورود کاربر را نیز تغییر می‌دهد**. باید `^09\d{9}$` و در سطح کاربران یکتا باشد.
**Request body:**
@@ -189,14 +197,23 @@ PATCH /api/v1/patient/{uuid}
{
"name": "محمد",
"family": "محمدی",
"fathers_name": "رضا",
"mobile": "09131234567",
"national_code": "0012345678",
"gender": "male",
"blood_type": "O+",
"marital_status": "single",
"education": "کارشناسی",
"field_of_study": "نرم‌افزار",
"job": "مهندس",
"home_phone": "03511111111",
"work_phone": "03512222222",
"address": "...",
"province_id": 8,
"city_id": 42,
"postal_code": "8913746351",
"referral_source": "اینستاگرام",
"description": "...",
"basic_insurance_id": 3,
"supplementary_insurance_id": 9
}
@@ -206,13 +223,21 @@ PATCH /api/v1/patient/{uuid}
|-------|------|-------|
| `name` | string | اگر ارسال شود و خالی نباشد → `User.realName`. رشته‌ی خالی نادیده گرفته می‌شود. |
| `family` | string\|null | `UserProfile.family` |
| `fathers_name` | string\|null | `UserProfile.fathersName` (نام پدر) |
| `mobile` | string\|null | اگر خالی نباشد و با موبایل فعلی فرق کند باید `^09\d{9}$` و یکتا باشد؛ روی `User.mobileNumber` ست می‌شود و **شناسه‌ی ورود** را عوض می‌کند |
| `national_code` | string\|null | اگر خالی نباشد باید ۱۰ رقم و در سطح بیمار یکتا باشد؛ روی `User.nationalCode` و `UserProfile.nationalCode` ست می‌شود |
| `gender` | `male`\|`female`\|null | |
| `blood_type` | string\|null | |
| `marital_status` | string\|null | |
| `education` | string\|null | مقطع تحصیلی (`UserProfile.education`) |
| `field_of_study` | string\|null | رشته‌ی تحصیلی (`UserProfile.fieldOfStudy`) |
| `job` | string\|null | |
| `home_phone`, `work_phone` | string\|null | |
| `address` | string\|null | |
| `province_id`, `city_id` | int\|null | id استان/شهر (category؛ `null` = حذف) |
| `postal_code` | string\|null | کد پستی |
| `referral_source` | string\|null | نحوه‌ی آشنایی |
| `description` | string\|null | توضیحات |
| `basic_insurance_id`, `supplementary_insurance_id` | int\|null | id بیمه؛ `null` = حذف |
**Response 200:** مثل `GET /api/v1/patient/{uuid}` (رکورد + `profile` تازه).
@@ -222,8 +247,9 @@ PATCH /api/v1/patient/{uuid}
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
| `ERR_VALIDATION_001` | 422 | کد ملی باید ۱۰ رقم باشد (`field: national_code`) |
| `ERR_VALIDATION_001` | 422 | کد ملی باید ۱۰ رقم باشد (`field: national_code`) یا موبایل نامعتبر است (`field: mobile`) |
| `ERR_PROFILE_NATIONAL_CODE_TAKEN` | 409 | کد ملی متعلق به بیمار دیگری است (`field: national_code`) |
| `ERR_PROFILE_002` | 409 | موبایل متعلق به کاربر دیگری است (`field: mobile`) |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260713074150 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add patient demographic fields to profiles: field_of_study, province_id, city_id, postal_code, referral_source';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE profiles ADD field_of_study VARCHAR(100) DEFAULT NULL, ADD province_id INT DEFAULT NULL, ADD city_id INT DEFAULT NULL, ADD postal_code VARCHAR(20) DEFAULT NULL, ADD referral_source VARCHAR(100) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE profiles DROP field_of_study, DROP province_id, DROP city_id, DROP postal_code, DROP referral_source');
}
}
+2
View File
@@ -85,6 +85,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function getUserIdentifier(): string { return $this->mobileNumber; }
public function eraseCredentials(): void {}
/** موبایل همان شناسه ورود است؛ تغییر آن نام‌کاربری کاربر را نیز تغییر می‌دهد. یکتایی در سطح فراخوان بررسی شود. */
public function setMobileNumber(string $mobileNumber): self { $this->mobileNumber = $mobileNumber; $this->updatedAt = time(); return $this; }
public function setEmail(?string $email): self { $this->email = $email; return $this; }
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self
+53 -8
View File
@@ -64,13 +64,21 @@ class PatientController extends BaseController
'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(),
'name' => $patient->getRealName(),
'family' => $p?->getFamily(),
'fathers_name' => $p?->getFathersName(),
'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(),
'gender' => $p?->getGender(),
'date_of_birth' => $p?->getDateOfBirth(),
'blood_type' => $p?->getBloodType(),
'marital_status' => $p?->getMaritalStatus(),
'education' => $p?->getEducation(),
'field_of_study' => $p?->getFieldOfStudy(),
'job' => $p?->getJob(),
'address' => $p?->getAddress(),
'province_id' => $p?->getProvinceId(),
'city_id' => $p?->getCityId(),
'postal_code' => $p?->getPostalCode(),
'referral_source' => $p?->getReferralSource(),
'description' => $p?->getDescription(),
'home_phone' => $p?->getHomePhone(),
'work_phone' => $p?->getWorkPhone(),
'mobile' => $patient->getMobileNumber(),
@@ -251,19 +259,45 @@ class PatientController extends BaseController
$patient->setNationalCode($nationalCode);
}
}
// موبایل = شناسه ورود کاربر؛ تغییر آن باید ۱۱ رقمی معتبر و در سطح کاربران یکتا باشد
if (array_key_exists('mobile', $data)) {
$mobile = trim((string) ($data['mobile'] ?? ''));
if ($mobile !== '' && $mobile !== $patient->getMobileNumber()) {
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$owner = $this->userRepo->findByMobile($mobile);
if ($owner !== null && $owner->getId() !== $patient->getId()) {
return $this->error(
ErrorCodes::ERR_PROFILE_MOBILE_TAKEN,
ErrorCodes::message(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN),
409,
'mobile'
);
}
$patient->setMobileNumber($mobile);
}
}
$this->userRepo->save($patient);
$profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient);
$stringFields = [
'family' => 'setFamily',
'gender' => 'setGender',
'blood_type' => 'setBloodType',
'marital_status' => 'setMaritalStatus',
'job' => 'setJob',
'address' => 'setAddress',
'home_phone' => 'setHomePhone',
'work_phone' => 'setWorkPhone',
'family' => 'setFamily',
'fathers_name' => 'setFathersName',
'gender' => 'setGender',
'blood_type' => 'setBloodType',
'marital_status' => 'setMaritalStatus',
'education' => 'setEducation',
'field_of_study' => 'setFieldOfStudy',
'job' => 'setJob',
'address' => 'setAddress',
'postal_code' => 'setPostalCode',
'referral_source' => 'setReferralSource',
'description' => 'setDescription',
'home_phone' => 'setHomePhone',
'work_phone' => 'setWorkPhone',
];
foreach ($stringFields as $key => $setter) {
if (array_key_exists($key, $data)) {
@@ -272,6 +306,17 @@ class PatientController extends BaseController
}
}
$intFields = [
'province_id' => 'setProvinceId',
'city_id' => 'setCityId',
];
foreach ($intFields as $key => $setter) {
if (array_key_exists($key, $data)) {
$v = $data[$key];
$profile->$setter(($v === null || $v === '') ? null : (int) $v);
}
}
if (array_key_exists('national_code', $data)) {
$nc = trim((string) ($data['national_code'] ?? ''));
$profile->setNationalCode($nc === '' ? null : $nc);
+2
View File
@@ -65,6 +65,7 @@ class ErrorCodes
// Profile
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
public const ERR_PROFILE_MOBILE_TAKEN = 'ERR_PROFILE_002';
// SMS Wallet
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
@@ -140,6 +141,7 @@ class ErrorCodes
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید',
+30
View File
@@ -55,12 +55,27 @@ class UserProfile
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $education = null;
#[ORM\Column(name: 'field_of_study', type: 'string', length: 100, nullable: true)]
private ?string $fieldOfStudy = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $job = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $address = null;
#[ORM\Column(name: 'province_id', type: 'integer', nullable: true)]
private ?int $provinceId = null;
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
private ?int $cityId = null;
#[ORM\Column(name: 'postal_code', type: 'string', length: 20, nullable: true)]
private ?string $postalCode = null;
#[ORM\Column(name: 'referral_source', type: 'string', length: 100, nullable: true)]
private ?string $referralSource = null;
#[ORM\Column(name: 'home_phone', type: 'string', length: 30, nullable: true)]
private ?string $homePhone = null;
@@ -116,8 +131,13 @@ class UserProfile
public function getBloodType(): ?string { return $this->bloodType; }
public function getMaritalStatus(): ?string { return $this->maritalStatus; }
public function getEducation(): ?string { return $this->education; }
public function getFieldOfStudy(): ?string { return $this->fieldOfStudy; }
public function getJob(): ?string { return $this->job; }
public function getAddress(): ?string { return $this->address; }
public function getProvinceId(): ?int { return $this->provinceId; }
public function getCityId(): ?int { return $this->cityId; }
public function getPostalCode(): ?string { return $this->postalCode; }
public function getReferralSource(): ?string { return $this->referralSource; }
public function getHomePhone(): ?string { return $this->homePhone; }
public function getWorkPhone(): ?string { return $this->workPhone; }
public function getInsuranceId(): ?string { return $this->insuranceId; }
@@ -140,8 +160,13 @@ class UserProfile
public function setBloodType(?string $v): self { $this->bloodType = $v; $this->touch(); return $this; }
public function setMaritalStatus(?string $v): self { $this->maritalStatus = $v; $this->touch(); return $this; }
public function setEducation(?string $v): self { $this->education = $v; $this->touch(); return $this; }
public function setFieldOfStudy(?string $v): self { $this->fieldOfStudy = $v; $this->touch(); return $this; }
public function setJob(?string $v): self { $this->job = $v; $this->touch(); return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
public function setProvinceId(?int $v): self { $this->provinceId = $v; $this->touch(); return $this; }
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
public function setPostalCode(?string $v): self { $this->postalCode = $v; $this->touch(); return $this; }
public function setReferralSource(?string $v): self { $this->referralSource = $v; $this->touch(); return $this; }
public function setHomePhone(?string $v): self { $this->homePhone = $v; $this->touch(); return $this; }
public function setWorkPhone(?string $v): self { $this->workPhone = $v; $this->touch(); return $this; }
public function setInsuranceId(?string $v): self { $this->insuranceId = $v; $this->touch(); return $this; }
@@ -170,8 +195,13 @@ class UserProfile
'blood_type' => $this->bloodType,
'marital_status' => $this->maritalStatus,
'education' => $this->education,
'field_of_study' => $this->fieldOfStudy,
'job' => $this->job,
'address' => $this->address,
'province_id' => $this->provinceId,
'city_id' => $this->cityId,
'postal_code' => $this->postalCode,
'referral_source' => $this->referralSource,
'home_phone' => $this->homePhone,
'work_phone' => $this->workPhone,
'insurance_id' => $this->insuranceId,
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* Integration coverage for PATCH /api/v1/patient/{uuid}:
* the extended demographic fields persist, and mobile edits validate,
* enforce uniqueness, and rewrite the patient's login identifier.
*
* Gate: a doctor caller falls back to the seeded `free` plan, which grants
* `patient_records` in db_test — so no explicit subscription is needed.
*/
class PatientUpdateProfileTest extends ApiTestCase
{
private User $doctorUser;
private int $doctorId;
protected function setUp(): void
{
parent::setUp();
$this->doctorUser = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($this->doctorUser, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
$this->doctorId = $doctor->getId();
}
/** Create a patient User + PatientRecord owned by the test doctor; return the record uuid. */
private function makeRecord(?string $mobile = null): array
{
$patient = $this->createUser(['ROLE_USER'], $mobile);
$patient->setRealName('ساغر صابری نژاد');
$this->em->persist($patient);
$record = new PatientRecord('doctor', $this->doctorId, $patient, 'doctor', $this->doctorId);
$this->em->persist($record);
$this->em->flush();
return [$record->getUuid(), $patient];
}
public function testUpdatePersistsExtendedDemographicFields(): void
{
[$uuid] = $this->makeRecord();
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, [
'fathers_name' => 'رضا',
'education' => 'کارشناسی',
'field_of_study' => 'نرم‌افزار',
'job' => 'مهندس',
'province_id' => 8,
'city_id' => 42,
'postal_code' => '8913746351',
'referral_source' => 'اینستاگرام',
'description' => 'یادداشت آزمایشی',
]);
self::assertSame(200, $this->responseCode());
$profile = $res['data']['profile'] ?? [];
self::assertSame('رضا', $profile['fathers_name']);
self::assertSame('کارشناسی', $profile['education']);
self::assertSame('نرم‌افزار', $profile['field_of_study']);
self::assertSame('مهندس', $profile['job']);
self::assertSame(8, $profile['province_id']);
self::assertSame(42, $profile['city_id']);
self::assertSame('8913746351', $profile['postal_code']);
self::assertSame('اینستاگرام', $profile['referral_source']);
self::assertSame('یادداشت آزمایشی', $profile['description']);
}
public function testEmptyStringClearsFieldToNull(): void
{
[$uuid] = $this->makeRecord();
$this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['job' => 'مهندس']);
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['job' => '']);
self::assertSame(200, $this->responseCode());
self::assertNull($res['data']['profile']['job']);
}
public function testMobileEditRewritesLoginIdentifier(): void
{
[$uuid, $patient] = $this->makeRecord();
$newMobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => $newMobile]);
self::assertSame(200, $this->responseCode());
self::assertSame($newMobile, $res['data']['profile']['mobile']);
$this->em->refresh($patient);
self::assertSame($newMobile, $patient->getMobileNumber());
self::assertSame($newMobile, $patient->getUserIdentifier());
}
public function testMobileTakenByAnotherUserReturns409(): void
{
$taken = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$this->createUser(['ROLE_USER'], $taken); // occupy the number
[$uuid] = $this->makeRecord();
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => $taken]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_PROFILE_002', $res['errors'][0]['code']);
}
public function testInvalidMobileReturns422(): void
{
[$uuid] = $this->makeRecord();
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => '12345']);
self::assertSame(422, $this->responseCode());
self::assertSame('ERR_VALIDATION_001', $res['errors'][0]['code']);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\UserProfile\Entity\UserProfile;
use PHPUnit\Framework\TestCase;
/**
* Unit coverage for the patient demographic columns added to UserProfile
* (field_of_study, province_id, city_id, postal_code, referral_source):
* setters round-trip through the getters and toArray() exposes the API keys.
*/
class UserProfileDemographicsTest extends TestCase
{
private function profile(): UserProfile
{
return new UserProfile(new User('09121234567'));
}
public function testNewDemographicSettersRoundTrip(): void
{
$p = $this->profile();
$p->setFieldOfStudy('نرم‌افزار')
->setProvinceId(8)
->setCityId(42)
->setPostalCode('8913746351')
->setReferralSource('اینستاگرام');
self::assertSame('نرم‌افزار', $p->getFieldOfStudy());
self::assertSame(8, $p->getProvinceId());
self::assertSame(42, $p->getCityId());
self::assertSame('8913746351', $p->getPostalCode());
self::assertSame('اینستاگرام', $p->getReferralSource());
}
public function testNewFieldsDefaultToNull(): void
{
$p = $this->profile();
self::assertNull($p->getFieldOfStudy());
self::assertNull($p->getProvinceId());
self::assertNull($p->getCityId());
self::assertNull($p->getPostalCode());
self::assertNull($p->getReferralSource());
}
public function testToArrayExposesNewKeys(): void
{
$arr = $this->profile()
->setFieldOfStudy('پزشکی')
->setProvinceId(1)
->setCityId(2)
->setPostalCode('1234567890')
->setReferralSource('معرفی دوستان')
->toArray();
self::assertSame('پزشکی', $arr['field_of_study']);
self::assertSame(1, $arr['province_id']);
self::assertSame(2, $arr['city_id']);
self::assertSame('1234567890', $arr['postal_code']);
self::assertSame('معرفی دوستان', $arr['referral_source']);
}
}