feat(patients): inline tag popover + advanced filters on the records list

Port the remaining pieces of tauri /files list into /admin/patients:

- inline tag assignment: the برچسب‌ها cell (table + card) opens a popover to
  assign/remove tenant tags without leaving the list. Uses existing endpoints
  (GET /api/v1/tenant-tags + PATCH /api/v1/patient/{uuid} { tags:[uuid] }).
  New component assets/admin/components/PatientTagsCell.tsx.
- advanced filter modal (PatientsFilterModal): admission date range, insurance,
  service status (pending/completed), has-debt, gender, tags — wired to the
  list query with an active-filter badge on the button.

Backend: GET /api/v1/patients gains tags/gender/insurance_id/admitted_from/
admitted_to/service_status/has_debt filters via a shared applyFilters() on
PatientRecordRepository (findByEntity + countByEntity stay consistent). Debt
and service status derive from unpaid sessions (payment_method='pending'),
documented in docs/api/patient.md.

Tests: tests/Patient/PatientListFilterTest.php (5) + PatientsListPage tag-popover
and filter-apply tests. Pre-existing LoginPage.test failures are unrelated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-14 19:40:54 +03:30
co-authored by Claude Opus 4.8
parent 4edeffe325
commit fd91840ba2
8 changed files with 578 additions and 52 deletions
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Tag\Entity\TenantTag;
use App\Tests\ApiTestCase;
use App\UserProfile\Entity\UserProfile;
/**
* GET /api/v1/patients advanced filters: tags, gender, insurance, admission
* date range, service status (pending/completed) and has-debt.
*/
class PatientListFilterTest 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];
}
/**
* @param array{gender?:string,insurance?:int,tag?:TenantTag,pending?:bool,paid?:bool,createdAt?:int} $opts
*/
private function record(Doctor $doctor, array $opts = []): PatientRecord
{
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
if (isset($opts['createdAt'])) {
$ref = new \ReflectionProperty($record, 'createdAt');
$ref->setAccessible(true);
$ref->setValue($record, $opts['createdAt']);
}
$this->em->persist($record);
if (isset($opts['gender']) || isset($opts['insurance'])) {
$profile = new UserProfile($patient);
if (isset($opts['gender'])) $profile->setGender($opts['gender']);
if (isset($opts['insurance'])) $profile->setBasicInsuranceId($opts['insurance']);
$this->em->persist($profile);
}
if (isset($opts['tag'])) {
$record->getTags()->add($opts['tag']);
}
if (!empty($opts['pending']) || !empty($opts['paid'])) {
$session = new PatientSession($record);
$session->setPaymentMethod(!empty($opts['pending']) ? 'pending' : 'cash');
$this->em->persist($session);
}
$this->em->flush();
return $record;
}
private function tag(Doctor $doctor, string $name): TenantTag
{
$tag = new TenantTag('doctor', $doctor->getId(), $name, '#5559CE');
$this->em->persist($tag);
$this->em->flush();
return $tag;
}
private function uuids(array $res): array
{
return array_map(static fn(array $r) => $r['uuid'], $res['data']);
}
public function testFilterByTag(): void
{
[$owner, $doctor] = $this->doctor();
$tag = $this->tag($doctor, 'خوش‌حساب');
$tagged = $this->record($doctor, ['tag' => $tag]);
$this->record($doctor); // untagged
$res = $this->authJson('GET', '/api/v1/patients?tags=' . $tag->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(1, $res['meta']['totalRecords']);
self::assertSame($tagged->getUuid(), $res['data'][0]['uuid']);
}
public function testFilterByGenderAndInsurance(): void
{
[$owner, $doctor] = $this->doctor();
$male = $this->record($doctor, ['gender' => 'male', 'insurance' => 7]);
$this->record($doctor, ['gender' => 'female', 'insurance' => 9]);
$byGender = $this->authJson('GET', '/api/v1/patients?gender=male', $owner);
self::assertSame(1, $byGender['meta']['totalRecords']);
self::assertSame($male->getUuid(), $byGender['data'][0]['uuid']);
$byIns = $this->authJson('GET', '/api/v1/patients?insurance_id=7', $owner);
self::assertSame(1, $byIns['meta']['totalRecords']);
self::assertSame($male->getUuid(), $byIns['data'][0]['uuid']);
}
public function testFilterByAdmissionDateRange(): void
{
[$owner, $doctor] = $this->doctor();
$old = $this->record($doctor, ['createdAt' => 1000]);
$new = $this->record($doctor, ['createdAt' => 5000]);
$res = $this->authJson('GET', '/api/v1/patients?admitted_from=4000&admitted_to=6000', $owner);
self::assertSame(1, $res['meta']['totalRecords']);
self::assertSame($new->getUuid(), $res['data'][0]['uuid']);
self::assertNotContains($old->getUuid(), $this->uuids($res));
}
public function testFilterByServiceStatusAndDebt(): void
{
[$owner, $doctor] = $this->doctor();
$pending = $this->record($doctor, ['pending' => true]);
$completed = $this->record($doctor, ['paid' => true]);
$onlyPending = $this->authJson('GET', '/api/v1/patients?service_status=pending', $owner);
self::assertSame(1, $onlyPending['meta']['totalRecords']);
self::assertSame($pending->getUuid(), $onlyPending['data'][0]['uuid']);
$onlyCompleted = $this->authJson('GET', '/api/v1/patients?service_status=completed', $owner);
self::assertSame(1, $onlyCompleted['meta']['totalRecords']);
self::assertSame($completed->getUuid(), $onlyCompleted['data'][0]['uuid']);
$withDebt = $this->authJson('GET', '/api/v1/patients?has_debt=1', $owner);
self::assertSame(1, $withDebt['meta']['totalRecords']);
self::assertSame($pending->getUuid(), $withDebt['data'][0]['uuid']);
}
public function testNoFiltersReturnsAll(): void
{
[$owner, $doctor] = $this->doctor();
$this->record($doctor);
$this->record($doctor);
$res = $this->authJson('GET', '/api/v1/patients', $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(2, $res['meta']['totalRecords']);
}
}