diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 647890a5..bad053b4 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -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(); + }); }); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index eb9eaa53..e2bd7f6a 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -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' ? ( + ) : tab === 'records' ? ( + ) : ( 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(null); + const [title, setTitle] = useState(''); + const [date, setDate] = useState(''); + const [body, setBody] = useState(''); + + const { data, isLoading } = useQuery>({ + 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 ( + + + open()}> ثبت معاینه جدید + + + {isLoading ? ( + در حال بارگذاری... + ) : items.length === 0 ? ( + معاینهای ثبت نشده است. + ) : ( + + {items.map((m) => ( + + + + {m.title} + {formatDate(m.recorded_at)} + {m.body && {m.body}} + + + open(m)}> + setDelTarget(m)}> + + + + ))} + + )} + + setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}> + + + عنوان * + setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /> + + + تاریخ + + + + شرح + setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /> + + + save.mutate()}>ذخیره + setModal(null)}>انصراف + + + + + delTarget && del.mutate(delTarget.uuid)} + onCancel={() => setDelTarget(null)} + loading={del.isPending} + /> + + ); +} + function TabList({ q, emptyLabel, row }: { q: { data?: ApiResponse; isLoading: boolean }; emptyLabel: string; diff --git a/docs/api/patient.md b/docs/api/patient.md index 006246fd..8e7f9233 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -506,3 +506,32 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme |------|------|-------------| | 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر | | 422 | `ERR_VALIDATION_001` | فایل نامعتبر | + +--- + +## پرونده پزشکی (Medical Records) + +معاینات/یادداشتهای پزشکیِ یک پرونده. scope به رکورد و tenant صاحب رکورد. + +**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد) + +### GET `/api/v1/patient/{uuid}/medical-records` +لیست (مرتب بر اساس `recorded_at` نزولی). Response: `{ success, data: [{ uuid, title, body, recorded_at, created_at }] }` + +### POST `/api/v1/patient/{uuid}/medical-record` +```json +{ "title": "معاینه اولیه", "body": "شرح (اختیاری)", "recorded_at": 1700000000 } +``` +`title` الزامی؛ `recorded_at` اختیاری (پیشفرض زمان ثبت). Response `201`. + +### PATCH `/api/v1/patient/medical-record/{uuid}` +فیلدهای اختیاری `title` / `body` / `recorded_at`. فقط مالک؛ در غیر این صورت `404`. + +### DELETE `/api/v1/patient/medical-record/{uuid}` +حذف. فقط مالک؛ در غیر این صورت `404`. + +### Errors +| HTTP | Code | Description | +|------|------|-------------| +| 422 | `ERR_VALIDATION_001` | عنوان خالی (`field: title`) | +| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/رکورد پزشکی یافت نشد یا tenant دیگر | diff --git a/migrations/Version20260713115316.php b/migrations/Version20260713115316.php new file mode 100644 index 00000000..56da6ca3 --- /dev/null +++ b/migrations/Version20260713115316.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE patient_medical_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, body LONGTEXT DEFAULT NULL, recorded_at INT NOT NULL, created_at INT NOT NULL, record_id INT NOT NULL, UNIQUE INDEX UNIQ_3B2B3121D17F50A6 (uuid), INDEX idx_pmr_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE patient_medical_records ADD CONSTRAINT FK_3B2B31214DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE patient_medical_records DROP FOREIGN KEY FK_3B2B31214DFD750C'); + $this->addSql('DROP TABLE patient_medical_records'); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 5b9b95a2..2e20205e 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -51,10 +51,96 @@ class PatientController extends BaseController private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo, private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo, private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo, + private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo, private readonly \App\Shared\Service\FileUploadService $fileUpload, private readonly LoggerInterface $logger, ) {} + // ── Medical records (پرونده پزشکی) ──────────────────────────────────────── + + #[Route('/api/v1/patient/{uuid}/medical-records', methods: ['GET'])] + public function listMedicalRecords(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + return $this->success(array_map( + fn(\App\Patient\Entity\PatientMedicalRecord $m) => $m->toArray(), + $this->medicalRepo->findByRecord($record) + )); + } + + #[Route('/api/v1/patient/{uuid}/medical-record', methods: ['POST'])] + public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + $title = trim((string) ($data['title'] ?? '')); + if ($title === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title'); + } + + $body = isset($data['body']) ? trim((string) $data['body']) : null; + $recordedAt = isset($data['recorded_at']) && $data['recorded_at'] !== '' ? (int) $data['recorded_at'] : null; + + $medical = new \App\Patient\Entity\PatientMedicalRecord($record, $title, $body ?: null, $recordedAt); + $this->medicalRepo->save($medical); + + return $this->success($medical->toArray(), 201); + } + + #[Route('/api/v1/patient/medical-record/{uuid}', methods: ['PATCH'])] + public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $medical = $this->medicalRepo->findByUuid($uuid); + if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (isset($data['title'])) { + $t = trim((string) $data['title']); + if ($t === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title'); + } + $medical->setTitle($t); + } + if (array_key_exists('body', $data)) { + $b = trim((string) ($data['body'] ?? '')); + $medical->setBody($b === '' ? null : $b); + } + if (isset($data['recorded_at']) && $data['recorded_at'] !== '') { + $medical->setRecordedAt((int) $data['recorded_at']); + } + + $this->medicalRepo->save($medical); + + return $this->success($medical->toArray()); + } + + #[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])] + public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $medical = $this->medicalRepo->findByUuid($uuid); + if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404); + } + + $this->medicalRepo->remove($medical); + + return $this->success(['message' => 'رکورد پزشکی حذف شد']); + } + // ── Attachments (ضمیمه) ─────────────────────────────────────────────────── #[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])] diff --git a/src/Patient/Entity/PatientMedicalRecord.php b/src/Patient/Entity/PatientMedicalRecord.php new file mode 100644 index 00000000..bb6eda00 --- /dev/null +++ b/src/Patient/Entity/PatientMedicalRecord.php @@ -0,0 +1,68 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->record = $record; + $this->title = $title; + $this->body = $body; + $this->createdAt = time(); + $this->recordedAt = $recordedAt ?? $this->createdAt; + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getRecord(): PatientRecord { return $this->record; } + + public function setTitle(string $v): self { $this->title = $v; return $this; } + public function setBody(?string $v): self { $this->body = $v; return $this; } + public function setRecordedAt(int $v): self { $this->recordedAt = $v; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'title' => $this->title, + 'body' => $this->body, + 'recorded_at' => $this->recordedAt, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Patient/Repository/PatientMedicalRecordRepository.php b/src/Patient/Repository/PatientMedicalRecordRepository.php new file mode 100644 index 00000000..0fbb7811 --- /dev/null +++ b/src/Patient/Repository/PatientMedicalRecordRepository.php @@ -0,0 +1,44 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return PatientMedicalRecord[] */ + public function findByRecord(PatientRecord $record): array + { + return $this->createQueryBuilder('m') + ->where('m.record = :record') + ->setParameter('record', $record) + ->orderBy('m.recordedAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(PatientMedicalRecord $m): void + { + $this->getEntityManager()->persist($m); + $this->getEntityManager()->flush(); + } + + public function remove(PatientMedicalRecord $m): void + { + $this->getEntityManager()->remove($m); + $this->getEntityManager()->flush(); + } +} diff --git a/tests/Patient/PatientMedicalRecordTest.php b/tests/Patient/PatientMedicalRecordTest.php new file mode 100644 index 00000000..12f9f6d2 --- /dev/null +++ b/tests/Patient/PatientMedicalRecordTest.php @@ -0,0 +1,71 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $record]; + } + + public function testCrud(): void + { + [$owner, $record] = $this->recordFor(); + + $created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, [ + 'title' => 'معاینه اولیه', 'body' => 'فشار خون طبیعی', 'recorded_at' => 1_700_000_000, + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame('معاینه اولیه', $created['data']['title']); + $mUuid = $created['data']['uuid']; + + $list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner); + self::assertCount(1, $list['data']); + + $this->authJson('PATCH', '/api/v1/patient/medical-record/' . $mUuid, $owner, ['title' => 'معاینه دوم']); + self::assertSame(200, $this->responseCode()); + + $this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $owner); + self::assertSame(200, $this->responseCode()); + + $after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner); + self::assertCount(0, $after['data']); + } + + public function testRequiresTitle(): void + { + [$owner, $record] = $this->recordFor(); + $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => '']); + self::assertSame(422, $this->responseCode()); + } + + public function testOwnershipScoped(): void + { + [$owner, $record] = $this->recordFor(); + $created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => 'x']); + $mUuid = $created['data']['uuid']; + + [$other] = $this->recordFor(); + $this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $other); + self::assertSame(404, $this->responseCode()); + } +}