From e43bfbad713cc0668fc7c602c46661b38716ff42 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 1 Oct 2026 19:40:32 +0530 Subject: [PATCH] feat(abdm): add task status filtering, search, and completed task visibility on task board - Add getTasks() method with status filtering (all, open, completed, failed) to AbdmWorkTaskService - Enhance AbdmTaskBoard to enrich all task types with health_records, invoice_code, and care contexts - Add status toggle filter buttons (All, Open, Completed) with dynamic count badges to Task Board UI - Add real-time fuzzy/keyword search input for instant filtering by invoice ID, patient name, ABHA, or task code - Render completed/linked tasks with green LINKED badge, gateway reference, and FHIR Preview button - Add unit tests in AbdmTaskBoardBackfillTest --- app/Controllers/AbdmTaskBoard.php | 130 ++++++++--- app/Libraries/AbdmWorkTaskService.php | 35 ++- app/Views/abdm/task_board.php | 210 +++++++++++++++--- tests/unit/Abdm/AbdmTaskBoardBackfillTest.php | 59 +++++ 4 files changed, 372 insertions(+), 62 deletions(-) diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index ef0535a..33bcfa8 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -13,6 +13,7 @@ class AbdmTaskBoard extends BaseController public function __construct() { $this->taskService = new AbdmWorkTaskService(); + $this->db = db_connect(); } public function index() @@ -22,7 +23,14 @@ class AbdmTaskBoard extends BaseController $this->backfillImmunizationTasks(); $this->backfillHealthDocumentTasks(); $this->backfillWellnessTasks(); - $tasks = $this->enrichTasksWithCoolingState($this->enrichImmunizationPushState($this->taskService->getOpenTasks(300))); + + $taskStatus = strtolower(trim((string) ($this->request->getGet('task_status') ?? 'all'))); + if (! in_array($taskStatus, ['all', 'open', 'completed', 'failed'], true)) { + $taskStatus = 'all'; + } + + $rawTasks = $this->taskService->getTasks($taskStatus, 600); + $tasks = $this->enrichTasksWithCoolingState($this->enrichTasksWithHealthRecordState($rawTasks)); $dateFrom = trim((string) ($this->request->getGet('date_from') ?? date('Y-m-d'))); $dateTo = trim((string) ($this->request->getGet('date_to') ?? date('Y-m-d'))); @@ -38,6 +46,7 @@ class AbdmTaskBoard extends BaseController return view('abdm/task_board', [ 'tasks' => $tasks, + 'task_status' => $taskStatus, 'dashboard_metrics' => $this->getDashboardMetrics($dateFrom, $dateTo), 'dashboard_date_from' => $dateFrom, 'dashboard_date_to' => $dateTo, @@ -55,9 +64,17 @@ class AbdmTaskBoard extends BaseController $this->backfillImmunizationTasks(); $this->backfillHealthDocumentTasks(); $this->backfillWellnessTasks(); + + $taskStatus = strtolower(trim((string) ($this->request->getGet('task_status') ?? 'all'))); + if (! in_array($taskStatus, ['all', 'open', 'completed', 'failed'], true)) { + $taskStatus = 'all'; + } + + $rawTasks = $this->taskService->getTasks($taskStatus, 600); return $this->response->setJSON([ 'ok' => 1, - 'tasks' => $this->enrichTasksWithCoolingState($this->enrichImmunizationPushState($this->taskService->getOpenTasks(300))), + 'task_status' => $taskStatus, + 'tasks' => $this->enrichTasksWithCoolingState($this->enrichTasksWithHealthRecordState($rawTasks)), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); @@ -89,48 +106,91 @@ class AbdmTaskBoard extends BaseController } /** @param array> $tasks */ - private function enrichImmunizationPushState(array $tasks): array + private function enrichTasksWithHealthRecordState(array $tasks): array { - if ($tasks === [] || ! $this->db->tableExists('health_records')) { - return $tasks; + if ($tasks === []) { + return []; } - $entityIds = []; - foreach ($tasks as $task) { - if (($task['task_type'] ?? '') === 'immunization_record_publish') { - $entityIds[] = (string) ($task['entity_id'] ?? ''); + // 1. Extract invoice metadata and resolve invoice_code + $invoiceIds = []; + foreach ($tasks as &$task) { + $payload = json_decode((string) ($task['payload_json'] ?? ''), true); + $meta = (is_array($payload) && isset($payload['meta']) && is_array($payload['meta'])) ? $payload['meta'] : []; + if (! empty($meta['invoice_id'])) { + $task['invoice_id'] = (int) $meta['invoice_id']; + $invoiceIds[] = (int) $meta['invoice_id']; + } + if (! empty($meta['invoice_code'])) { + $task['invoice_code'] = (string) $meta['invoice_code']; } } - $entityIds = array_values(array_filter(array_unique($entityIds), static fn (string $id): bool => $id !== '')); - if ($entityIds === []) { - return $tasks; + unset($task); + + $db = $this->db ?? db_connect(); + + if (! empty($invoiceIds) && $db->tableExists('invoice_master')) { + $invRows = $db->table('invoice_master') + ->select('id, invoice_code') + ->whereIn('id', array_unique($invoiceIds)) + ->get() + ->getResultArray(); + $codeMap = []; + foreach ($invRows as $ir) { + $codeMap[(int) $ir['id']] = (string) ($ir['invoice_code'] ?? ''); + } + foreach ($tasks as &$task) { + if (! empty($task['invoice_id']) && empty($task['invoice_code']) && isset($codeMap[(int) $task['invoice_id']])) { + $task['invoice_code'] = $codeMap[(int) $task['invoice_id']]; + } + } + unset($task); } - $latest = []; - $rows = $this->db->table('health_records') - ->select('id, entity_id, push_status, care_context_reference, linked_at') - ->where('hi_type', 'ImmunizationRecord') - ->where('entity_type', 'immunization') - ->whereIn('entity_id', $entityIds) - ->orderBy('id', 'DESC') - ->get() - ->getResultArray(); - foreach ($rows as $row) { - $entityId = (string) ($row['entity_id'] ?? ''); - if ($entityId !== '' && ! isset($latest[$entityId])) { - $latest[$entityId] = $row; + // 2. Lookup health_records by entity_id + $latestHr = []; + if ($db->tableExists('health_records')) { + $entityIds = []; + foreach ($tasks as $task) { + $entityId = trim((string) ($task['entity_id'] ?? '')); + if ($entityId !== '') { + $entityIds[] = $entityId; + } + } + $entityIds = array_values(array_filter(array_unique($entityIds))); + + if ($entityIds !== []) { + $rows = $db->table('health_records') + ->select('id, entity_type, entity_id, push_status, care_context_reference, linked_at, abdm_txn_id') + ->whereIn('entity_id', $entityIds) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $eid = (string) ($row['entity_id'] ?? ''); + if ($eid !== '' && ! isset($latestHr[$eid])) { + $latestHr[$eid] = $row; + } + } } } foreach ($tasks as &$task) { - if (($task['task_type'] ?? '') !== 'immunization_record_publish') { - continue; - } - $healthRecord = $latest[(string) ($task['entity_id'] ?? '')] ?? null; - $pushStatus = strtolower(trim((string) ($healthRecord['push_status'] ?? ''))); - $task['bridge_health_record_id'] = (int) ($healthRecord['id'] ?? 0); + $eid = (string) ($task['entity_id'] ?? ''); + $hr = $latestHr[$eid] ?? null; + $pushStatus = strtolower(trim((string) ($hr['push_status'] ?? ''))); + $task['bridge_health_record_id'] = (int) ($hr['id'] ?? 0); $task['bridge_push_status'] = $pushStatus; - $task['bridge_care_context_reference'] = trim((string) ($healthRecord['care_context_reference'] ?? '')); + + $careContext = trim((string) ($hr['care_context_reference'] ?? '')); + if ($careContext === '') { + $resultText = (string) ($task['last_action_result'] ?? ''); + if (preg_match('/(RAD-\d+-\d+|LAB-\d+-\d+|OPD-\d+-\d+|INVOICE-\w+-\d+-\d+|IMM-\d+-\d+|DIS-\d+-\d+)/i', $resultText, $ccm)) { + $careContext = $ccm[1]; + } + } + + $task['bridge_care_context_reference'] = $careContext; $task['bridge_submitted'] = in_array($pushStatus, ['queued', 'pushed', 'linked'], true) ? 1 : 0; } unset($task); @@ -138,6 +198,12 @@ class AbdmTaskBoard extends BaseController return $tasks; } + /** @param array> $tasks */ + private function enrichImmunizationPushState(array $tasks): array + { + return $this->enrichTasksWithHealthRecordState($tasks); + } + /** @param array> $tasks */ private function enrichTasksWithCoolingState(array $tasks): array { diff --git a/app/Libraries/AbdmWorkTaskService.php b/app/Libraries/AbdmWorkTaskService.php index 6b6252a..e4e12d8 100644 --- a/app/Libraries/AbdmWorkTaskService.php +++ b/app/Libraries/AbdmWorkTaskService.php @@ -115,24 +115,51 @@ class AbdmWorkTaskService } /** + * @param string $status 'all' (default), 'open', 'completed', or 'failed' + * @param int $limit Max rows to fetch + * @param string|null $taskType Optional task type filter * @return array> */ - public function getOpenTasks(int $limit = 200): array + public function getTasks(string $status = 'all', int $limit = 500, ?string $taskType = null): array { if (! $this->db->tableExists('abdm_work_tasks')) { return []; } - $this->syncPatientAbhaBacklog(max(200, $limit)); + $status = strtolower(trim($status)); + if (in_array($status, ['open', 'all'], true)) { + $this->syncPatientAbhaBacklog(max(200, min(500, $limit))); + } - return $this->db->table('abdm_work_tasks') - ->whereIn('status', ['pending', 'in_progress']) + $builder = $this->db->table('abdm_work_tasks'); + + if ($status === 'open') { + $builder->whereIn('status', ['pending', 'in_progress']); + } elseif ($status === 'completed') { + $builder->whereIn('status', ['completed', 'linked']); + } elseif ($status === 'failed') { + $builder->whereIn('status', ['failed', 'cancelled']); + } + + if (! empty($taskType)) { + $builder->where('task_type', trim($taskType)); + } + + return $builder ->orderBy('id', 'DESC') ->limit(max(1, $limit)) ->get() ->getResultArray(); } + /** + * @return array> + */ + public function getOpenTasks(int $limit = 200): array + { + return $this->getTasks('open', $limit); + } + public function markTaskStatus(int $taskId, string $status, string $resultText = ''): bool { if (! $this->db->tableExists('abdm_work_tasks') || $taskId <= 0) { diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index cfa4ccd..baee141 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -104,6 +104,20 @@
+
+
+ Work Queue + +
+
+ +
+ + + +
+
+
@@ -132,15 +146,37 @@ $meta = (isset($payload['meta']) && is_array($payload['meta'])) ? $payload['meta'] : []; $opdId = (int) ($meta['opd_id'] ?? 0); $opdSessionId = (int) ($meta['opd_session_id'] ?? 0); + $invoiceId = (int) ($t['invoice_id'] ?? ($meta['invoice_id'] ?? 0)); + $invoiceCode = trim((string) ($t['invoice_code'] ?? ($meta['invoice_code'] ?? ''))); + $invoiceDisplay = $invoiceCode !== '' ? $invoiceCode : ($invoiceId > 0 ? ('#' . $invoiceId) : ''); + $showSandbox = ($type === 'opd_prescription_publish' && $opdId > 0); $showPreview = ($type === 'opd_prescription_publish' && $opdId > 0) || in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish', 'immunization_record_publish', 'health_document_publish', 'wellness_record_publish'], true); $bridgeSubmitted = (int) ($t['bridge_submitted'] ?? 0) === 1; $bridgeCareContext = trim((string) ($t['bridge_care_context_reference'] ?? '')); + + $rawTaskStatus = strtolower(trim((string) ($t['status'] ?? 'pending'))); + $isTaskLinked = in_array($rawTaskStatus, ['completed', 'linked'], true) || ($bridgeSubmitted && strtolower((string) ($t['bridge_push_status'] ?? '')) === 'linked'); + + $searchText = strtolower(implode(' ', array_filter([ + (string) ($t['task_code'] ?? ''), + (string) ($t['task_type'] ?? ''), + (string) ($t['patient_name'] ?? ''), + (string) ($t['patient_id'] ?? ''), + (string) ($t['abha_id'] ?? ''), + (string) ($t['entity_id'] ?? ''), + $invoiceCode, + $invoiceId > 0 ? (string) $invoiceId : '', + $bridgeCareContext, + (string) ($t['last_action_result'] ?? ''), + ]))); ?> - + + + +
+ +
Inv:
+
#
+ +
+ + + +
- - LINKED -
+ LINKED + +
+ +
+ +
+ BRIDGE SUBMITTED -
+
@@ -186,24 +234,37 @@
-
- - - - +
+ + Done + + + + + + + + + + + + + + + + + - - - - - - - -
+ + No tasks match the selected filter. +
@@ -665,7 +726,82 @@ var taskTableCard = document.getElementById('taskTableCard'); var dashboardCard = document.getElementById('dashboardCard'); + var currentCategoryFilter = 'dashboard'; + var currentStatusFilter = 'all'; + var currentSearchQuery = ''; + + function updateTaskTableDisplay() { + var rows = document.querySelectorAll('#taskTable tbody tr:not(#taskTableEmptyRow)'); + var emptyRow = document.getElementById('taskTableEmptyRow'); + var q = currentSearchQuery.trim().toLowerCase(); + + var visibleCount = 0; + var countAll = 0; + var countOpen = 0; + var countCompleted = 0; + + rows.forEach(function (row) { + var type = (row.getAttribute('data-task-type') || '').toLowerCase(); + var status = (row.getAttribute('data-task-status') || '').toLowerCase(); + var isLinked = row.getAttribute('data-is-linked') === '1' || status === 'completed' || status === 'linked'; + var searchData = (row.getAttribute('data-search') || '').toLowerCase(); + + // Check if matches current category + var categoryMatch = (currentCategoryFilter === 'all' || type === currentCategoryFilter.toLowerCase()); + if (!categoryMatch) { + row.style.display = 'none'; + return; + } + + // Tally status counts for this category + countAll++; + if (isLinked) { + countCompleted++; + } else { + countOpen++; + } + + // Check status match + var statusMatch = false; + if (currentStatusFilter === 'all') { + statusMatch = true; + } else if (currentStatusFilter === 'completed') { + statusMatch = isLinked; + } else if (currentStatusFilter === 'open') { + statusMatch = !isLinked; + } else if (currentStatusFilter === status) { + statusMatch = true; + } + + // Check search match + var searchMatch = !q || searchData.indexOf(q) !== -1; + + if (statusMatch && searchMatch) { + row.style.display = ''; + visibleCount++; + } else { + row.style.display = 'none'; + } + }); + + // Update counts in UI + var elCountAll = document.getElementById('countAll'); + var elCountOpen = document.getElementById('countOpen'); + var elCountCompleted = document.getElementById('countCompleted'); + var elTableCount = document.getElementById('taskTableCount'); + if (elCountAll) elCountAll.textContent = countAll; + if (elCountOpen) elCountOpen.textContent = countOpen; + if (elCountCompleted) elCountCompleted.textContent = countCompleted; + if (elTableCount) elTableCount.textContent = visibleCount; + + if (emptyRow) { + emptyRow.classList.toggle('d-none', visibleCount > 0); + } + } + function applyFilter(filter) { + currentCategoryFilter = filter; + // Hide dedicated section cards if (opdBookCard) opdBookCard.classList.add('d-none'); if (opdConsultCard) opdConsultCard.classList.add('d-none'); @@ -686,11 +822,12 @@ if (invoiceCard) invoiceCard.classList.remove('d-none'); } else { if (taskTableCard) taskTableCard.classList.remove('d-none'); - var rows = document.querySelectorAll('#taskTable tbody tr'); - rows.forEach(function (row) { - var type = (row.getAttribute('data-task-type') || '').toLowerCase(); - row.style.display = (type === filter.toLowerCase()) ? '' : 'none'; - }); + var titleEl = document.getElementById('taskTableTitle'); + if (titleEl) { + var btn = document.querySelector('.filter-btn[data-filter="' + filter + '"]'); + titleEl.textContent = btn ? btn.textContent.trim() : 'Work Queue'; + } + updateTaskTableDisplay(); } document.querySelectorAll('.filter-btn').forEach(function (btn) { @@ -1270,6 +1407,27 @@ }); }); + document.querySelectorAll('.status-filter-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + document.querySelectorAll('.status-filter-btn').forEach(function (b) { + b.classList.remove('btn-primary', 'active'); + b.classList.add('btn-outline-primary'); + }); + btn.classList.remove('btn-outline-primary'); + btn.classList.add('btn-primary', 'active'); + currentStatusFilter = (btn.getAttribute('data-status') || 'all').toLowerCase(); + updateTaskTableDisplay(); + }); + }); + + var searchInput = document.getElementById('taskTableSearch'); + if (searchInput) { + searchInput.addEventListener('input', function () { + currentSearchQuery = searchInput.value || ''; + updateTaskTableDisplay(); + }); + } + document.querySelectorAll('.sandbox-btn').forEach(function (btn) { btn.addEventListener('click', function () { openSandboxForRow(btn.closest('tr')); diff --git a/tests/unit/Abdm/AbdmTaskBoardBackfillTest.php b/tests/unit/Abdm/AbdmTaskBoardBackfillTest.php index 081b0e4..d872a03 100644 --- a/tests/unit/Abdm/AbdmTaskBoardBackfillTest.php +++ b/tests/unit/Abdm/AbdmTaskBoardBackfillTest.php @@ -25,4 +25,63 @@ final class AbdmTaskBoardBackfillTest extends CIUnitTestCase $this->assertFalse($method->invoke($controller, '12345')); $this->assertFalse($method->invoke($controller, 'invalid_address')); } + + public function testEnrichTasksWithHealthRecordStateExtractsInvoiceAndCareContext(): void + { + $controller = (new ReflectionClass(AbdmTaskBoard::class))->newInstanceWithoutConstructor(); + $method = new ReflectionMethod(AbdmTaskBoard::class, 'enrichTasksWithHealthRecordState'); + $method->setAccessible(true); + + $mockTasks = [ + [ + 'id' => 686, + 'task_code' => 'ABDM-20261001151127-C8C055', + 'task_type' => 'radiology_report_publish', + 'entity_id' => '51450', + 'status' => 'completed', + 'payload_json' => json_encode([ + 'meta' => [ + 'lab_type' => 3, + 'invoice_id' => 40291, + 'invoice_code' => 'N26100040291', + ], + ]), + 'last_action_result' => 'Linked by cron: REQ-20261001151202-7447b1c4', + ], + [ + 'id' => 679, + 'task_code' => 'ABDM-20261001142918-25422B', + 'task_type' => 'radiology_report_publish', + 'entity_id' => '51450', + 'status' => 'completed', + 'payload_json' => json_encode([ + 'meta' => [ + 'invoice_id' => 40291, + ], + ]), + 'last_action_result' => 'Care context already linked in ABDM: RAD-51450-20261001', + ], + ]; + + $enriched = $method->invoke($controller, $mockTasks); + + $this->assertCount(2, $enriched); + $this->assertSame(40291, $enriched[0]['invoice_id']); + $this->assertSame('N26100040291', $enriched[0]['invoice_code']); + $this->assertSame(40291, $enriched[1]['invoice_id']); + // From last_action_result regex fallback + $this->assertSame('RAD-51450-20261001', $enriched[1]['bridge_care_context_reference']); + } + + public function testWorkTaskServiceHasGetTasksMethod(): void + { + $serviceClass = new ReflectionClass(\App\Libraries\AbdmWorkTaskService::class); + $this->assertTrue($serviceClass->hasMethod('getTasks')); + $this->assertTrue($serviceClass->hasMethod('getOpenTasks')); + + $getTasksMethod = $serviceClass->getMethod('getTasks'); + $params = $getTasksMethod->getParameters(); + $this->assertSame('status', $params[0]->getName()); + $this->assertSame('all', $params[0]->getDefaultValue()); + } }