feat(patients): phase B3 — medical records (پرونده پزشکی)

Add patient medical-exam entries: a new PatientMedicalRecord entity
(record-scoped, CASCADE) + repository, and owner-scoped CRUD endpoints
(GET list, POST create, PATCH, DELETE) under /api/v1/patient. Wire the
"پرونده پزشکی" tab in PatientDetailPage (list + add/edit modal with title,
date and notes + delete). PHPUnit covers CRUD + ownership + validation;
Vitest covers the tab. API docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 15:27:34 +03:30
co-authored by Claude Opus 4.8
parent 98943ac249
commit 293eb8a0d2
8 changed files with 450 additions and 0 deletions
@@ -66,4 +66,13 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
});
it('opens the add-exam modal on the medical-record tab', async () => {
renderDetail();
await screen.findByText('ساغر صابری');
fireEvent.click(screen.getByText('پرونده پزشکی'));
fireEvent.click(await screen.findByRole('button', { name: /ثبت معاینه جدید/ }));
// modal opens with a title field
expect(await screen.findByPlaceholderText('مثلاً: معاینه اولیه')).toBeInTheDocument();
});
});
+110
View File
@@ -7,12 +7,16 @@ import {
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } 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 { useAuthStore } from '../stores/authStore';
import { formatDate } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
@@ -125,6 +129,8 @@ export default function PatientDetailPage() {
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
) : tab === 'attach' ? (
<AttachmentsTab uuid={uuid!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
@@ -214,6 +220,110 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
);
}
interface MedicalItem { uuid: string; title: string; body?: string | null; recorded_at: number }
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
function MedicalRecordsTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | MedicalItem | null>(null);
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [body, setBody] = useState('');
const { data, isLoading } = useQuery<ApiResponse<MedicalItem[]>>({
queryKey: ['patient-medical', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/medical-records`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-medical', uuid] });
const open = (m?: MedicalItem) => {
setTitle(m?.title ?? ''); setBody(m?.body ?? '');
setDate(m?.recorded_at ? new Date(m.recorded_at * 1000).toISOString().slice(0, 10) : '');
setModal(m ?? 'create');
};
const save = useMutation({
mutationFn: () => {
const payload = { title, body: body || null, recorded_at: date ? Math.floor(new Date(date).getTime() / 1000) : undefined };
return modal === 'create'
? api.post(`/api/v1/patient/${uuid}/medical-record`, payload)
: api.patch(`/api/v1/patient/medical-record/${(modal as MedicalItem).uuid}`, payload);
},
onSuccess: () => { invalidate(); setModal(null); toast.success('ذخیره شد'); },
onError: (e: any) => toast.error(e.message),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/medical-record/${u}`),
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('حذف شد'); },
onError: (e: any) => { toast.error(e.message); setDelTarget(null); },
});
return (
<div>
<div style={{ marginBottom: 14 }}>
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>معاینهای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((m) => (
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{m.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(m.recorded_at)}</div>
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
</div>
</div>
</div>
))}
</div>
)}
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="field-label">عنوان *</label>
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /></div>
</div>
<div>
<label className="field-label">تاریخ</label>
<PersianDateInput value={date} onChange={setDate} />
</div>
<div>
<label className="field-label">شرح</label>
<div className="field" style={{ height: 'auto' }}><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={!title.trim() || save.isPending} onClick={() => save.mutate()}>ذخیره</button>
<button className="btn" onClick={() => setModal(null)}>انصراف</button>
</div>
</div>
</Modal>
<ConfirmDialog
open={!!delTarget}
title="حذف معاینه"
message={`آیا از حذف «${delTarget?.title}» مطمئن هستید؟`}
confirmLabel="حذف"
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
onCancel={() => setDelTarget(null)}
loading={del.isPending}
/>
</div>
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;