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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user