feat(patients): phase A — records list + create/edit form (Figma)

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 14:54:38 +03:30
co-authored by Claude Opus 4.8
parent c46800610f
commit 15c6f5dce7
13 changed files with 671 additions and 4 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Tag\Entity\TenantTag;
use App\Tests\ApiTestCase;
/**
* PatientRecord carries clinic-scoped case-file fields (record_number, gender,
* birth_date, referral_source, description) and a set of TenantTag labels.
*/
class PatientRecordFieldsTest extends ApiTestCase
{
public function testFieldsAndTagsRoundTrip(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
$t1 = new TenantTag('doctor', $doctor->getId(), 'فوری', '#FF0000');
$t2 = new TenantTag('doctor', $doctor->getId(), 'پیگیری', '#00AA00');
$this->em->persist($t1);
$this->em->persist($t2);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']); // random unique mobile — db_test is never reset
$patient->setRealName('بیمار نمونه');
$this->em->flush();
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$record->setRecordNumber('P-1001')
->setTags([$t1, $t2]);
$this->em->persist($record);
$this->em->flush();
$uuid = $record->getUuid();
$this->em->clear();
/** @var PatientRecordRepository $repo */
$repo = static::getContainer()->get(PatientRecordRepository::class);
$reloaded = $repo->findByUuid($uuid);
self::assertNotNull($reloaded);
self::assertCount(2, $reloaded->getTags());
$arr = $reloaded->toArray();
self::assertSame('P-1001', $arr['record_number']);
self::assertCount(2, $arr['tags']);
self::assertSame('فوری', $arr['tags'][0]['name']);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Tag\Entity\TenantTag;
use App\Tests\ApiTestCase;
/**
* POST/PATCH /patient accept `record_number` and tenant-scoped `tags` (uuids).
*/
class PatientRecordTagsApiTest extends ApiTestCase
{
private function doctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
private function tag(int $doctorId, string $name): TenantTag
{
$t = new TenantTag('doctor', $doctorId, $name, '#FF0000');
$this->em->persist($t);
$this->em->flush();
return $t;
}
public function testCreateAndUpdateWithRecordNumberAndTags(): void
{
[$owner, $doctor] = $this->doctor();
$t1 = $this->tag($doctor->getId(), 'فوری');
$t2 = $this->tag($doctor->getId(), 'پیگیری');
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$created = $this->authJson('POST', '/api/v1/patient', $owner, [
'mobile' => $mobile,
'name' => 'بیمار نمونه',
'record_number' => 'P-1001',
'tags' => [$t1->getUuid(), $t2->getUuid()],
]);
self::assertSame(201, $this->responseCode());
self::assertSame('P-1001', $created['data']['record_number']);
self::assertCount(2, $created['data']['tags']);
$uuid = $created['data']['uuid'];
// update: keep one tag, change record number
$updated = $this->authJson('PATCH', '/api/v1/patient/' . $uuid, $owner, [
'record_number' => 'P-2002',
'tags' => [$t1->getUuid()],
]);
self::assertSame(200, $this->responseCode());
self::assertSame('P-2002', $updated['data']['record_number']);
self::assertCount(1, $updated['data']['tags']);
}
public function testRejectsForeignTag(): void
{
[$owner, $doctor] = $this->doctor();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$created = $this->authJson('POST', '/api/v1/patient', $owner, ['mobile' => $mobile, 'name' => 'ب']);
$uuid = $created['data']['uuid'];
[, $doctorB] = $this->doctor();
$foreign = $this->tag($doctorB->getId(), 'خارجی');
$this->authJson('PATCH', '/api/v1/patient/' . $uuid, $owner, ['tags' => [$foreign->getUuid()]]);
self::assertSame(422, $this->responseCode());
}
}