From 15c6f5dce7dad718ff8d0a51f8718a031d0c4998 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 14:54:38 +0330 Subject: [PATCH] =?UTF-8?q?feat(patients):=20phase=20A=20=E2=80=94=20recor?= =?UTF-8?q?ds=20list=20+=20create/edit=20form=20(Figma)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the patient records (پرونده‌ها) area, phase A of the Figma redesign: - BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to PatientRecord (migration + EAGER-hydrated collection). POST /patient and PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags` (foreign tag → 422); demographic fields (gender, date_of_birth, referral_source, description) continue to live on UserProfile via PATCH. - FE: new PatientsListPage (table + card views, search, pagination, tags column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage (create/edit) that POSTs the record then PATCHes the demographics. Point the sidebar "پرونده" entry to the new list. Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet, call-center) follow. Backend covered by PHPUnit, FE by Vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/admin/App.tsx | 5 + assets/admin/components/layout/Sidebar.tsx | 6 +- .../pages/PatientRecordFormPage.test.tsx | 53 ++++++ assets/admin/pages/PatientRecordFormPage.tsx | 154 ++++++++++++++++++ assets/admin/pages/PatientsListPage.test.tsx | 44 +++++ assets/admin/pages/PatientsListPage.tsx | 154 ++++++++++++++++++ assets/admin/types/index.ts | 4 + docs/api/patient.md | 6 +- migrations/Version20260713110856.php | 37 +++++ src/Patient/Controller/PatientController.php | 43 +++++ src/Patient/Entity/PatientRecord.php | 44 +++++ tests/Patient/PatientRecordFieldsTest.php | 53 ++++++ tests/Patient/PatientRecordTagsApiTest.php | 72 ++++++++ 13 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 assets/admin/pages/PatientRecordFormPage.test.tsx create mode 100644 assets/admin/pages/PatientRecordFormPage.tsx create mode 100644 assets/admin/pages/PatientsListPage.test.tsx create mode 100644 assets/admin/pages/PatientsListPage.tsx create mode 100644 migrations/Version20260713110856.php create mode 100644 tests/Patient/PatientRecordFieldsTest.php create mode 100644 tests/Patient/PatientRecordTagsApiTest.php diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 61136948..6df5e321 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -53,6 +53,8 @@ import SettingsMenuPage from './pages/SettingsMenuPage'; import AccountSettingsPage from './pages/AccountSettingsPage'; import TagsSettingsPage from './pages/TagsSettingsPage'; import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; +import PatientsListPage from './pages/PatientsListPage'; +import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; import PwaInstallBanner from './components/ui/PwaInstallBanner'; @@ -190,6 +192,9 @@ export default function App() { {/* دکتر / منشی / کلینیک */} } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index cf147b92..524e1086 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -199,7 +199,7 @@ function buildSections( label: "نوبت‌ها", }, { - to: "/admin/my-patients", + to: "/admin/patients", icon: FolderOpenIcon, label: "پرونده بیماران", feature: "patient_records", @@ -280,7 +280,7 @@ function buildSections( label: "نوبت‌های من", }, { - to: "/admin/my-patients", + to: "/admin/patients", icon: FolderOpenIcon, label: "پرونده بیماران", feature: "patient_records", @@ -351,7 +351,7 @@ function buildSections( label: "نوبت‌ها", }, { - to: "/admin/my-patients", + to: "/admin/patients", icon: FolderOpenIcon, label: "پرونده بیماران", feature: "patient_records", diff --git a/assets/admin/pages/PatientRecordFormPage.test.tsx b/assets/admin/pages/PatientRecordFormPage.test.tsx new file mode 100644 index 00000000..8a0e9182 --- /dev/null +++ b/assets/admin/pages/PatientRecordFormPage.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +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 PatientRecordFormPage from './PatientRecordFormPage'; + +const post = api.post as ReturnType; +const patch = api.patch as ReturnType; + +beforeEach(() => { + post.mockReset(); patch.mockReset(); + post.mockResolvedValue({ success: true, data: { uuid: 'new-1' } }); + patch.mockResolvedValue({ success: true, data: {} }); +}); + +describe('PatientRecordFormPage (تشکیل پرونده)', () => { + it('creates a record then patches the demographic fields', async () => { + renderWithProviders(, { route: '/admin/patients/new' }); + + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'بیمار نمونه' } }); + fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1001' } }); + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'female' } }); // gender + fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '1234567890' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } }); + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient', expect.objectContaining({ + name: 'بیمار نمونه', mobile: '09120000000', national_code: '1234567890', record_number: 'P-1001', + }))); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/patient/new-1', expect.objectContaining({ gender: 'female' }))); + }); + + it('blocks submit and shows a validation error for a bad national code', async () => { + renderWithProviders(, { route: '/admin/patients/new' }); + + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'ب' } }); + fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1' } }); + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'male' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '12' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } }); + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + expect(await screen.findByText('کد ملی باید ۱۰ رقم باشد')).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/assets/admin/pages/PatientRecordFormPage.tsx b/assets/admin/pages/PatientRecordFormPage.tsx new file mode 100644 index 00000000..966c1796 --- /dev/null +++ b/assets/admin/pages/PatientRecordFormPage.tsx @@ -0,0 +1,154 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { ChevronRightIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { PatientRecord } from '../types'; +import PersianDateInput from '../components/ui/PersianDateInput'; + +const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر']; + +const schema = z.object({ + name: z.string().min(1, 'نام و نام خانوادگی الزامی است'), + record_number: z.string().min(1, 'شماره پرونده الزامی است'), + gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }), + national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'), + mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'), + birth_date: z.string().optional(), + referral_source: z.string().optional(), + description: z.string().optional(), +}); +type Form = z.infer; + +const toEpoch = (iso?: string) => (iso ? Math.floor(new Date(iso).getTime() / 1000) : null); +const fromEpoch = (ts?: number | null) => (ts ? new Date(ts * 1000).toISOString().slice(0, 10) : ''); + +/** تشکیل/ویرایش پرونده — patient record create & edit form (Figma "تشکیل پرونده"). */ +export default function PatientRecordFormPage() { + const { uuid } = useParams<{ uuid: string }>(); + const isEdit = !!uuid; + const navigate = useNavigate(); + const qc = useQueryClient(); + + const form = useForm
({ + resolver: zodResolver(schema), + defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' }, + }); + + const { data: recordData } = useQuery>({ + queryKey: ['patient', uuid], + queryFn: () => api.get(`/api/v1/patient/${uuid}`), + enabled: isEdit, + }); + + useEffect(() => { + const r = recordData?.data; + if (!r) return; + const p: any = r.profile ?? {}; + form.reset({ + name: r.user_name ?? '', + record_number: r.record_number ?? '', + gender: (p.gender as 'male' | 'female') ?? undefined, + national_code: r.user_national_code ?? '', + mobile: r.user_mobile ?? '', + birth_date: fromEpoch(p.date_of_birth), + referral_source: p.referral_source ?? '', + description: p.description ?? '', + }); + }, [recordData]); // eslint-disable-line react-hooks/exhaustive-deps + + const save = useMutation({ + mutationFn: async (d: Form) => { + const profilePayload = { + gender: d.gender, + date_of_birth: toEpoch(d.birth_date), + referral_source: d.referral_source || null, + description: d.description || null, + }; + if (isEdit) { + return api.patch(`/api/v1/patient/${uuid}`, { + name: d.name, national_code: d.national_code, mobile: d.mobile, record_number: d.record_number, ...profilePayload, + }); + } + // create: POST creates the record + identity, then PATCH applies the profile demographics + const created = await api.post>('/api/v1/patient', { + name: d.name, mobile: d.mobile, national_code: d.national_code, record_number: d.record_number, + }); + const newUuid = (created as any)?.data?.uuid; + if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload); + return created; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['patients'] }); + toast.success(isEdit ? 'پرونده ویرایش شد' : 'پرونده تشکیل شد'); + navigate('/admin/patients'); + }, + onError: (e: any) => toast.error(e.message), + }); + + const Field = ({ label, required, error, children }: { label: string; required?: boolean; error?: string; children: React.ReactNode }) => ( +
+ + {children} + {error && {error}} +
+ ); + + return ( +
+
+ بازگشت +
پرونده › {isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}
+
+ + save.mutate(d))} + style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + form.setValue('birth_date', v)} /> + + +
+
+
+ +
+ +