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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user