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;