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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user