Files
clinicpro/assets/admin/components/PatientRecordInfoForm.tsx
T
hamedandClaude Opus 4.8 580bc983b3 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>
2026-07-13 11:51:18 +03:30

207 lines
7.3 KiB
TypeScript

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