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
+13 -2
View File
@@ -528,8 +528,19 @@ class PatientController extends BaseController
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$search = $request->query->get('search') ?: null;
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search);
$tags = $request->query->get('tags');
$filters = [
'tags' => $tags ? array_filter(array_map('trim', explode(',', $tags))) : null,
'gender' => $request->query->get('gender') ?: null,
'insurance_id' => $request->query->get('insurance_id') ?: null,
'admitted_from' => $request->query->get('admitted_from') ?: null,
'admitted_to' => $request->query->get('admitted_to') ?: null,
'service_status' => $request->query->get('service_status') ?: null,
'has_debt' => $request->query->getBoolean('has_debt'),
];
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search, $filters);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search, $filters);
return $this->paginated(
array_map(fn(PatientRecord $r) => $r->toArray(), $records),
@@ -28,42 +28,94 @@ class PatientRecordRepository extends ServiceEntityRepository
]);
}
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null): array
/**
* @param array<string, mixed> $filters tags(string[] tenant-tag uuids), gender,
* insurance_id, admitted_from/admitted_to (unix), service_status
* (pending|completed), has_debt(bool)
* @return list<PatientRecord>
*/
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null, array $filters = []): array
{
$qb = $this->createQueryBuilder('r')
->join('r.user', 'u')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('r.id', 'DESC')
$qb = $this->baseQuery($entityType, $entityId);
$this->applyFilters($qb, $search, $filters);
return $qb->orderBy('r.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
->setParameter('search', '%' . $search . '%');
}
return $qb->getQuery()->getResult();
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countByEntity(string $entityType, int $entityId, ?string $search = null): int
/** @param array<string, mixed> $filters same shape as {@see findByEntity}. */
public function countByEntity(string $entityType, int $entityId, ?string $search = null, array $filters = []): int
{
$qb = $this->createQueryBuilder('r')
->select('COUNT(r.id)')
$qb = $this->baseQuery($entityType, $entityId)->select('COUNT(r.id)');
$this->applyFilters($qb, $search, $filters);
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function baseQuery(string $entityType, int $entityId): \Doctrine\ORM\QueryBuilder
{
return $this->createQueryBuilder('r')
->join('r.user', 'u')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId);
}
/**
* Shared search + advanced filters for the patient records list, applied to
* both the page query and its count so totals stay consistent.
* @param array<string, mixed> $filters
*/
private function applyFilters(\Doctrine\ORM\QueryBuilder $qb, ?string $search, array $filters): void
{
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
->setParameter('search', '%' . $search . '%');
}
return (int) $qb->getQuery()->getSingleScalarResult();
// برچسب‌ها — رکوردهایی که حداقل یکی از تگ‌های انتخاب‌شده را دارند.
if (!empty($filters['tags'])) {
$qb->andWhere('r.id IN (SELECT rt.id FROM App\Patient\Entity\PatientRecord rt JOIN rt.tags tg WHERE tg.uuid IN (:tagUuids))')
->setParameter('tagUuids', (array) $filters['tags']);
}
// جنسیت / نوع بیمه — از UserProfile بیمار (OneToOne با user).
if (!empty($filters['gender']) || !empty($filters['insurance_id'])) {
$qb->leftJoin(\App\UserProfile\Entity\UserProfile::class, 'pr', \Doctrine\ORM\Query\Expr\Join::WITH, 'pr.user = u');
if (!empty($filters['gender'])) {
$qb->andWhere('pr.gender = :gender')->setParameter('gender', $filters['gender']);
}
if (!empty($filters['insurance_id'])) {
$qb->andWhere('pr.basicInsuranceId = :insId')->setParameter('insId', (int) $filters['insurance_id']);
}
}
// تاریخ پذیرش — تاریخ تشکیل پرونده (record.createdAt).
if (!empty($filters['admitted_from'])) {
$qb->andWhere('r.createdAt >= :aFrom')->setParameter('aFrom', (int) $filters['admitted_from']);
}
if (!empty($filters['admitted_to'])) {
$qb->andWhere('r.createdAt <= :aTo')->setParameter('aTo', (int) $filters['admitted_to']);
}
// وضعیت سرویس / بدهی — بر اساس وجود مراجعه‌ی پرداخت‌نشده (payment_method='pending').
$pendingSub = 'SELECT sp.id FROM App\Patient\Entity\PatientSession sp WHERE sp.record = r AND sp.paymentMethod = :pendingPm';
$anySub = 'SELECT sa.id FROM App\Patient\Entity\PatientSession sa WHERE sa.record = r';
if (!empty($filters['has_debt'])) {
$qb->andWhere("EXISTS ($pendingSub)")->setParameter('pendingPm', 'pending');
}
if (($filters['service_status'] ?? null) === 'pending') {
$qb->andWhere("EXISTS ($pendingSub)")->setParameter('pendingPm', 'pending');
} elseif (($filters['service_status'] ?? null) === 'completed') {
$qb->andWhere("NOT EXISTS ($pendingSub)")
->andWhere("EXISTS ($anySub)")
->setParameter('pendingPm', 'pending');
}
}
public function countUnique(string $entityType, int $entityId, int $from, int $to): int