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 PageHeader from "../components/ui/PageHeader";
|
||||||
import Pagination from "../components/ui/Pagination";
|
import Pagination from "../components/ui/Pagination";
|
||||||
import SearchableSelect from "../components/ui/SearchableSelect";
|
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 type { ApiResponse, PaginatedResponse } from "../lib/api";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -240,14 +249,7 @@ function MyPatientsPageInner() {
|
|||||||
const [newPatientName, setNewPatientName] = useState("");
|
const [newPatientName, setNewPatientName] = useState("");
|
||||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
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 [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(
|
const servicesTotal = selectedServices.reduce(
|
||||||
(sum, s) => sum + s.price_rials,
|
(sum, s) => sum + s.price_rials,
|
||||||
@@ -279,6 +281,22 @@ function MyPatientsPageInner() {
|
|||||||
});
|
});
|
||||||
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
|
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<
|
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
|
||||||
PaginatedResponse<PatientSession>
|
PaginatedResponse<PatientSession>
|
||||||
>({
|
>({
|
||||||
@@ -385,21 +403,7 @@ function MyPatientsPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openEditInfo = () => {
|
const openEditInfo = () => {
|
||||||
const p = patientProfile;
|
setEditProvinceId(patientProfile?.province_id ?? null);
|
||||||
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) : "",
|
|
||||||
});
|
|
||||||
setEditOpen(true);
|
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({
|
const settleSessionMut = useMutation({
|
||||||
mutationFn: (uuid: string) =>
|
mutationFn: (uuid: string) =>
|
||||||
@@ -1148,17 +1132,23 @@ function MyPatientsPageInner() {
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
|
||||||
{([
|
{([
|
||||||
["نام کامل", patientProfile.full_name],
|
["نام کامل", patientProfile.full_name],
|
||||||
|
["نام پدر", patientProfile.fathers_name],
|
||||||
["کد ملی", patientProfile.national_code],
|
["کد ملی", patientProfile.national_code],
|
||||||
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
|
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
|
||||||
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
|
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
|
||||||
["گروه خونی", patientProfile.blood_type],
|
["گروه خونی", patientProfile.blood_type],
|
||||||
["وضعیت تأهل", patientProfile.marital_status],
|
["وضعیت تأهل", patientProfile.marital_status],
|
||||||
|
["مقطع تحصیلی", patientProfile.education],
|
||||||
|
["رشته تحصیلی", patientProfile.field_of_study],
|
||||||
["شغل", patientProfile.job],
|
["شغل", patientProfile.job],
|
||||||
["موبایل", patientProfile.mobile],
|
["موبایل", patientProfile.mobile],
|
||||||
["تلفن منزل", patientProfile.home_phone],
|
["تلفن منزل", patientProfile.home_phone],
|
||||||
["بیمه پایه", patientProfile.basic_insurance_name],
|
["بیمه پایه", patientProfile.basic_insurance_name],
|
||||||
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
|
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
|
||||||
|
["کد پستی", patientProfile.postal_code],
|
||||||
|
["نحوه آشنایی", patientProfile.referral_source],
|
||||||
["آدرس", patientProfile.address],
|
["آدرس", patientProfile.address],
|
||||||
|
["توضیحات", patientProfile.description],
|
||||||
] as [string, string | null | undefined][]).map(([label, value]) => (
|
] as [string, string | null | undefined][]).map(([label, value]) => (
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
|
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
|
||||||
@@ -1173,85 +1163,26 @@ function MyPatientsPageInner() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
title="ویرایش اطلاعات بیمار"
|
title="ویرایش اطلاعات پرونده"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClose={() => setEditOpen(false)}
|
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 }}>
|
<PatientRecordInfoForm
|
||||||
<div>
|
defaultValues={profileToFormValues(patientProfile)}
|
||||||
<label className="cp-label">نام</label>
|
recordNumber={selectedRecord?.uuid}
|
||||||
<input className="cp-input" value={editForm.name} onChange={(e) => setEditField("name", e.target.value)} />
|
options={{
|
||||||
</div>
|
gender: GENDER_OPTS,
|
||||||
<div>
|
marital: MARITAL_OPTS,
|
||||||
<label className="cp-label">نام خانوادگی</label>
|
education: EDUCATION_OPTS,
|
||||||
<input className="cp-input" value={editForm.family} onChange={(e) => setEditField("family", e.target.value)} />
|
referral: REFERRAL_OPTS,
|
||||||
</div>
|
insurance: baseInsuranceOptions,
|
||||||
<div>
|
province: locOpts(provincesQ.data),
|
||||||
<label className="cp-label">کد ملی</label>
|
city: locOpts(citiesQ.data),
|
||||||
<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>
|
onSubmit={(v) => updatePatientMut.mutate(formValuesToPayload(v))}
|
||||||
<div>
|
isSubmitting={updatePatientMut.isPending}
|
||||||
<label className="cp-label">جنسیت</label>
|
onProvinceChange={setEditProvinceId}
|
||||||
<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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{detailTab === "visits" && (
|
{detailTab === "visits" && (
|
||||||
|
|||||||
@@ -462,13 +462,21 @@ export interface PatientProfile {
|
|||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
family?: string | null;
|
family?: string | null;
|
||||||
|
fathers_name?: string | null;
|
||||||
national_code?: string | null;
|
national_code?: string | null;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
date_of_birth?: number | null;
|
date_of_birth?: number | null;
|
||||||
blood_type?: string | null;
|
blood_type?: string | null;
|
||||||
marital_status?: string | null;
|
marital_status?: string | null;
|
||||||
|
education?: string | null;
|
||||||
|
field_of_study?: string | null;
|
||||||
job?: string | null;
|
job?: string | null;
|
||||||
address?: 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;
|
home_phone?: string | null;
|
||||||
work_phone?: string | null;
|
work_phone?: string | null;
|
||||||
mobile?: string | null;
|
mobile?: string | null;
|
||||||
@@ -478,6 +486,28 @@ export interface PatientProfile {
|
|||||||
supplementary_insurance_name?: string | null;
|
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 {
|
export interface PatientRecord {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
entity_type: 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": "محمد محمدی",
|
"full_name": "محمد محمدی",
|
||||||
"name": "محمد",
|
"name": "محمد",
|
||||||
"family": "محمدی",
|
"family": "محمدی",
|
||||||
|
"fathers_name": "رضا",
|
||||||
"national_code": "0012345678",
|
"national_code": "0012345678",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"date_of_birth": 700000000,
|
"date_of_birth": 700000000,
|
||||||
"blood_type": "O+",
|
"blood_type": "O+",
|
||||||
"marital_status": "single",
|
"marital_status": "single",
|
||||||
|
"education": "کارشناسی",
|
||||||
|
"field_of_study": "نرمافزار",
|
||||||
"job": "...",
|
"job": "...",
|
||||||
"address": "...",
|
"address": "...",
|
||||||
|
"province_id": 8,
|
||||||
|
"city_id": 42,
|
||||||
|
"postal_code": "8913746351",
|
||||||
|
"referral_source": "اینستاگرام",
|
||||||
|
"description": "...",
|
||||||
"home_phone": "...",
|
"home_phone": "...",
|
||||||
"work_phone": "...",
|
"work_phone": "...",
|
||||||
"mobile": "0912...",
|
"mobile": "0912...",
|
||||||
@@ -181,7 +189,7 @@ PATCH /api/v1/patient/{uuid}
|
|||||||
|
|
||||||
بهروزرسانی **partial** است — فقط کلیدهای ارسالشده اعمال میشوند. مقدار `""`/`null` برای فیلدهای پروفایل یعنی «پاککردن». `name` روی `User.realName` و بقیهی فیلدها روی `UserProfile` مینشینند (در صورت نبود پروفایل، ساخته میشود).
|
بهروزرسانی **partial** است — فقط کلیدهای ارسالشده اعمال میشوند. مقدار `""`/`null` برای فیلدهای پروفایل یعنی «پاککردن». `name` روی `User.realName` و بقیهی فیلدها روی `UserProfile` مینشینند (در صورت نبود پروفایل، ساخته میشود).
|
||||||
|
|
||||||
> **شماره موبایل قابل ویرایش نیست** — شناسهی حساب کاربر است و در این endpoint نادیده گرفته میشود.
|
> **شماره موبایل قابل ویرایش است** — موبایل همان شناسهی ورود کاربر است، پس ارسال `mobile` علاوه بر شمارهی تماس، **نامکاربری ورود کاربر را نیز تغییر میدهد**. باید `^09\d{9}$` و در سطح کاربران یکتا باشد.
|
||||||
|
|
||||||
**Request body:**
|
**Request body:**
|
||||||
|
|
||||||
@@ -189,14 +197,23 @@ PATCH /api/v1/patient/{uuid}
|
|||||||
{
|
{
|
||||||
"name": "محمد",
|
"name": "محمد",
|
||||||
"family": "محمدی",
|
"family": "محمدی",
|
||||||
|
"fathers_name": "رضا",
|
||||||
|
"mobile": "09131234567",
|
||||||
"national_code": "0012345678",
|
"national_code": "0012345678",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"blood_type": "O+",
|
"blood_type": "O+",
|
||||||
"marital_status": "single",
|
"marital_status": "single",
|
||||||
|
"education": "کارشناسی",
|
||||||
|
"field_of_study": "نرمافزار",
|
||||||
"job": "مهندس",
|
"job": "مهندس",
|
||||||
"home_phone": "03511111111",
|
"home_phone": "03511111111",
|
||||||
"work_phone": "03512222222",
|
"work_phone": "03512222222",
|
||||||
"address": "...",
|
"address": "...",
|
||||||
|
"province_id": 8,
|
||||||
|
"city_id": 42,
|
||||||
|
"postal_code": "8913746351",
|
||||||
|
"referral_source": "اینستاگرام",
|
||||||
|
"description": "...",
|
||||||
"basic_insurance_id": 3,
|
"basic_insurance_id": 3,
|
||||||
"supplementary_insurance_id": 9
|
"supplementary_insurance_id": 9
|
||||||
}
|
}
|
||||||
@@ -206,13 +223,21 @@ PATCH /api/v1/patient/{uuid}
|
|||||||
|-------|------|-------|
|
|-------|------|-------|
|
||||||
| `name` | string | اگر ارسال شود و خالی نباشد → `User.realName`. رشتهی خالی نادیده گرفته میشود. |
|
| `name` | string | اگر ارسال شود و خالی نباشد → `User.realName`. رشتهی خالی نادیده گرفته میشود. |
|
||||||
| `family` | string\|null | `UserProfile.family` |
|
| `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` ست میشود |
|
| `national_code` | string\|null | اگر خالی نباشد باید ۱۰ رقم و در سطح بیمار یکتا باشد؛ روی `User.nationalCode` و `UserProfile.nationalCode` ست میشود |
|
||||||
| `gender` | `male`\|`female`\|null | |
|
| `gender` | `male`\|`female`\|null | |
|
||||||
| `blood_type` | string\|null | |
|
| `blood_type` | string\|null | |
|
||||||
| `marital_status` | string\|null | |
|
| `marital_status` | string\|null | |
|
||||||
|
| `education` | string\|null | مقطع تحصیلی (`UserProfile.education`) |
|
||||||
|
| `field_of_study` | string\|null | رشتهی تحصیلی (`UserProfile.fieldOfStudy`) |
|
||||||
| `job` | string\|null | |
|
| `job` | string\|null | |
|
||||||
| `home_phone`, `work_phone` | string\|null | |
|
| `home_phone`, `work_phone` | string\|null | |
|
||||||
| `address` | 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` = حذف |
|
| `basic_insurance_id`, `supplementary_insurance_id` | int\|null | id بیمه؛ `null` = حذف |
|
||||||
|
|
||||||
**Response 200:** مثل `GET /api/v1/patient/{uuid}` (رکورد + `profile` تازه).
|
**Response 200:** مثل `GET /api/v1/patient/{uuid}` (رکورد + `profile` تازه).
|
||||||
@@ -222,8 +247,9 @@ PATCH /api/v1/patient/{uuid}
|
|||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
|
| `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_NATIONAL_CODE_TAKEN` | 409 | کد ملی متعلق به بیمار دیگری است (`field: national_code`) |
|
||||||
|
| `ERR_PROFILE_002` | 409 | موبایل متعلق به کاربر دیگری است (`field: mobile`) |
|
||||||
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
|
| `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 getUserIdentifier(): string { return $this->mobileNumber; }
|
||||||
public function eraseCredentials(): void {}
|
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 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 setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
|
||||||
public function setNationalCode(?string $code): self
|
public function setNationalCode(?string $code): self
|
||||||
|
|||||||
@@ -64,13 +64,21 @@ class PatientController extends BaseController
|
|||||||
'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(),
|
'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(),
|
||||||
'name' => $patient->getRealName(),
|
'name' => $patient->getRealName(),
|
||||||
'family' => $p?->getFamily(),
|
'family' => $p?->getFamily(),
|
||||||
|
'fathers_name' => $p?->getFathersName(),
|
||||||
'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(),
|
'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(),
|
||||||
'gender' => $p?->getGender(),
|
'gender' => $p?->getGender(),
|
||||||
'date_of_birth' => $p?->getDateOfBirth(),
|
'date_of_birth' => $p?->getDateOfBirth(),
|
||||||
'blood_type' => $p?->getBloodType(),
|
'blood_type' => $p?->getBloodType(),
|
||||||
'marital_status' => $p?->getMaritalStatus(),
|
'marital_status' => $p?->getMaritalStatus(),
|
||||||
|
'education' => $p?->getEducation(),
|
||||||
|
'field_of_study' => $p?->getFieldOfStudy(),
|
||||||
'job' => $p?->getJob(),
|
'job' => $p?->getJob(),
|
||||||
'address' => $p?->getAddress(),
|
'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(),
|
'home_phone' => $p?->getHomePhone(),
|
||||||
'work_phone' => $p?->getWorkPhone(),
|
'work_phone' => $p?->getWorkPhone(),
|
||||||
'mobile' => $patient->getMobileNumber(),
|
'mobile' => $patient->getMobileNumber(),
|
||||||
@@ -251,19 +259,45 @@ class PatientController extends BaseController
|
|||||||
$patient->setNationalCode($nationalCode);
|
$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);
|
$this->userRepo->save($patient);
|
||||||
|
|
||||||
$profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient);
|
$profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient);
|
||||||
|
|
||||||
$stringFields = [
|
$stringFields = [
|
||||||
'family' => 'setFamily',
|
'family' => 'setFamily',
|
||||||
'gender' => 'setGender',
|
'fathers_name' => 'setFathersName',
|
||||||
'blood_type' => 'setBloodType',
|
'gender' => 'setGender',
|
||||||
'marital_status' => 'setMaritalStatus',
|
'blood_type' => 'setBloodType',
|
||||||
'job' => 'setJob',
|
'marital_status' => 'setMaritalStatus',
|
||||||
'address' => 'setAddress',
|
'education' => 'setEducation',
|
||||||
'home_phone' => 'setHomePhone',
|
'field_of_study' => 'setFieldOfStudy',
|
||||||
'work_phone' => 'setWorkPhone',
|
'job' => 'setJob',
|
||||||
|
'address' => 'setAddress',
|
||||||
|
'postal_code' => 'setPostalCode',
|
||||||
|
'referral_source' => 'setReferralSource',
|
||||||
|
'description' => 'setDescription',
|
||||||
|
'home_phone' => 'setHomePhone',
|
||||||
|
'work_phone' => 'setWorkPhone',
|
||||||
];
|
];
|
||||||
foreach ($stringFields as $key => $setter) {
|
foreach ($stringFields as $key => $setter) {
|
||||||
if (array_key_exists($key, $data)) {
|
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)) {
|
if (array_key_exists('national_code', $data)) {
|
||||||
$nc = trim((string) ($data['national_code'] ?? ''));
|
$nc = trim((string) ($data['national_code'] ?? ''));
|
||||||
$profile->setNationalCode($nc === '' ? null : $nc);
|
$profile->setNationalCode($nc === '' ? null : $nc);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class ErrorCodes
|
|||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
|
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
|
||||||
|
public const ERR_PROFILE_MOBILE_TAKEN = 'ERR_PROFILE_002';
|
||||||
|
|
||||||
// SMS Wallet
|
// SMS Wallet
|
||||||
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
||||||
@@ -140,6 +141,7 @@ class ErrorCodes
|
|||||||
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
|
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
|
||||||
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
|
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
|
||||||
self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
|
self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
|
||||||
|
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
|
||||||
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
||||||
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
||||||
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||||
|
|||||||
@@ -55,12 +55,27 @@ class UserProfile
|
|||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||||
private ?string $education = null;
|
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)]
|
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||||
private ?string $job = null;
|
private ?string $job = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $address = null;
|
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)]
|
#[ORM\Column(name: 'home_phone', type: 'string', length: 30, nullable: true)]
|
||||||
private ?string $homePhone = null;
|
private ?string $homePhone = null;
|
||||||
|
|
||||||
@@ -116,8 +131,13 @@ class UserProfile
|
|||||||
public function getBloodType(): ?string { return $this->bloodType; }
|
public function getBloodType(): ?string { return $this->bloodType; }
|
||||||
public function getMaritalStatus(): ?string { return $this->maritalStatus; }
|
public function getMaritalStatus(): ?string { return $this->maritalStatus; }
|
||||||
public function getEducation(): ?string { return $this->education; }
|
public function getEducation(): ?string { return $this->education; }
|
||||||
|
public function getFieldOfStudy(): ?string { return $this->fieldOfStudy; }
|
||||||
public function getJob(): ?string { return $this->job; }
|
public function getJob(): ?string { return $this->job; }
|
||||||
public function getAddress(): ?string { return $this->address; }
|
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 getHomePhone(): ?string { return $this->homePhone; }
|
||||||
public function getWorkPhone(): ?string { return $this->workPhone; }
|
public function getWorkPhone(): ?string { return $this->workPhone; }
|
||||||
public function getInsuranceId(): ?string { return $this->insuranceId; }
|
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 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 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 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 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 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 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 setWorkPhone(?string $v): self { $this->workPhone = $v; $this->touch(); return $this; }
|
||||||
public function setInsuranceId(?string $v): self { $this->insuranceId = $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,
|
'blood_type' => $this->bloodType,
|
||||||
'marital_status' => $this->maritalStatus,
|
'marital_status' => $this->maritalStatus,
|
||||||
'education' => $this->education,
|
'education' => $this->education,
|
||||||
|
'field_of_study' => $this->fieldOfStudy,
|
||||||
'job' => $this->job,
|
'job' => $this->job,
|
||||||
'address' => $this->address,
|
'address' => $this->address,
|
||||||
|
'province_id' => $this->provinceId,
|
||||||
|
'city_id' => $this->cityId,
|
||||||
|
'postal_code' => $this->postalCode,
|
||||||
|
'referral_source' => $this->referralSource,
|
||||||
'home_phone' => $this->homePhone,
|
'home_phone' => $this->homePhone,
|
||||||
'work_phone' => $this->workPhone,
|
'work_phone' => $this->workPhone,
|
||||||
'insurance_id' => $this->insuranceId,
|
'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