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