From 41d89a1969687d3cc3d79d6066b4ebb476f3976f Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Tue, 29 Sep 2026 14:02:22 +0530 Subject: [PATCH] feat(abdm): enhance care context discovery, async token retry, and modal backdrop cleanup - Add multi-type care context discovery in AbdmGateway (DiagnosticReport, DischargeSummary, Immunization, OPConsult) with target task prioritization - Implement automated retry backoff in EAtriaBridgeConnector for asynchronous ABDM link token callback delivery - Handle already-linked contexts gracefully and synchronize Task Board row status on link completion - Resolve modal backdrop freeze and inactive main screen on radiology report template update in PathLab_Report and welcome_message - Add unit tests CareContextDiscoveryTest and EAtriaBridgeConnectorLinkTest --- app/Controllers/AbdmGateway.php | 342 +++++++++++++++--- app/Controllers/AbdmTaskBoard.php | 10 + app/Libraries/Abdm/EAtriaBridgeConnector.php | 51 ++- .../PathLab_Report/ultrasound_report_edit.php | 98 ++++- .../ultrasound_template_list.php | 78 +++- app/Views/abdm/task_board.php | 37 +- app/Views/partials/abdm_hip_link_modal.php | 314 ++++++++++++++-- app/Views/welcome_message.php | 28 ++ tests/unit/Abdm/CareContextDiscoveryTest.php | 283 +++++++++++++++ .../Abdm/EAtriaBridgeConnectorLinkTest.php | 79 ++++ 10 files changed, 1211 insertions(+), 109 deletions(-) create mode 100644 tests/unit/Abdm/CareContextDiscoveryTest.php create mode 100644 tests/unit/Abdm/EAtriaBridgeConnectorLinkTest.php diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 50fe269..f82900a 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -5990,18 +5990,126 @@ class AbdmGateway extends BaseController return count($common) / max(1, count($union)); } - private function findCareContextsForPatient(int $patientId, string $patientRef, string $patientName): array - { + private function findCareContextsForPatient( + int $patientId, + string $patientRef, + string $patientName, + string $taskType = '', + string $entityId = '', + int $taskId = 0 + ): array { + $this->db = $this->db ?? \Config\Database::connect(); $careContextsV3 = []; $careContextsFull = []; + $seenRefs = []; - // 1. Query health_records table + $addContext = function (array $item) use (&$careContextsV3, &$careContextsFull, &$seenRefs) { + $ref = trim((string) ($item['referenceNumber'] ?? $item['careContextId'] ?? '')); + if ($ref === '' || isset($seenRefs[$ref])) { + return; + } + $seenRefs[$ref] = true; + $careContextsV3[] = [ + 'referenceNumber' => $ref, + 'display' => $item['display'] ?? $ref, + ]; + $careContextsFull[] = $item; + }; + + // 1. If explicit task requested (e.g. from Task Board row) + if ($taskType !== '' && $entityId !== '') { + $taskEntityId = (int) $entityId; + if (in_array($taskType, ['radiology_report_publish', 'lab_report_publish'], true) && $this->db->tableExists('lab_request')) { + $labReq = $this->db->table('lab_request lr') + ->select('lr.id, lr.patient_id, lr.lab_type, lr.charge_id, lr.report_name, lr.Report_Data, lr.report_data_Impression, lr.status, lr.Request_Date, lr.reported_time, lr.collected_time') + ->where('lr.id', $taskEntityId) + ->get(1) + ->getRowArray(); + + if (! empty($labReq)) { + $isImaging = ((int) ($labReq['lab_type'] ?? 0) === 6) || str_contains($taskType, 'radiology'); + $prefix = $isImaging ? 'RAD' : 'LAB'; + $title = trim((string) ($labReq['report_name'] ?? '')); + if ($title === '' && ! empty($labReq['charge_id']) && $this->db->tableExists('radiology_ultrasound_template')) { + $tpl = $this->db->table('radiology_ultrasound_template')->select('template_name')->where('id', (int) $labReq['charge_id'])->get(1)->getRowArray(); + if (! empty($tpl['template_name'])) { + $title = trim((string) $tpl['template_name']); + } + } + if ($title === '') { + $title = $this->mapLabTypeToTitle((int) ($labReq['lab_type'] ?? 0)) ?: ($isImaging ? 'Radiology Report' : 'Lab Report'); + } + $reportedDate = ! empty($labReq['reported_time']) + ? $labReq['reported_time'] + : (! empty($labReq['Request_Date']) ? $labReq['Request_Date'] : date('Y-m-d')); + $visitDate = date('Y-m-d', strtotime((string) $reportedDate)); + $dateStr = date('d M Y', strtotime($visitDate)); + $ccRef = $prefix . '-' . $taskEntityId . '-' . str_replace('-', '', $visitDate); + $display = ($isImaging ? 'Radiology Report - ' : 'Diagnostic Report - ') . $title . ' (' . $dateStr . ')'; + + $hasFindings = trim((string) ($labReq['Report_Data'] ?? '')) !== '' || trim((string) ($labReq['report_data_Impression'] ?? '')) !== ''; + $statusVal = (int) ($labReq['status'] ?? 0); + $isReady = in_array($statusVal, [1, 2], true) || $hasFindings; + + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'DiagnosticReportRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => $isReady, + 'is_primary' => true, + ]); + } + } elseif ($taskType === 'opd_prescription_publish' && $this->db->tableExists('opd_prescription')) { + $pr = $this->db->table('opd_prescription') + ->where('id', $taskEntityId) + ->orWhere('session_id', $taskEntityId) + ->get(1) + ->getRowArray(); + if (! empty($pr)) { + $visitDate = ! empty($pr['date_opd_visit']) ? $pr['date_opd_visit'] : date('Y-m-d', strtotime((string) ($pr['p_datetime'] ?? 'now'))); + $dateStr = date('d M Y', strtotime((string) $visitDate)); + $sessionId = ! empty($pr['session_id']) ? $pr['session_id'] : $pr['id']; + $ccRef = 'OPD-' . $patientId . '-S' . $sessionId . '-' . str_replace('-', '', (string) $visitDate); + $display = 'OPConsultRecord - ' . $dateStr; + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'OPConsultRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => true, + ]); + } + } elseif ($taskType === 'ipd_discharge_publish' && $this->db->tableExists('ipd_master')) { + $ipd = $this->db->table('ipd_master')->where('id', $taskEntityId)->get(1)->getRowArray(); + if (! empty($ipd)) { + $disDate = ! empty($ipd['discharge_date']) ? $ipd['discharge_date'] : date('Y-m-d'); + $dateStr = date('d M Y', strtotime((string) $disDate)); + $ccRef = 'DISCHARGE-' . $taskEntityId . '-' . str_replace('-', '', (string) $disDate); + $display = 'DischargeSummaryRecord - ' . $dateStr; + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'DischargeSummaryRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => true, + ]); + } + } + } + + // 2. Query health_records table (persisted FHIR bundles) if ($this->db->tableExists('health_records')) { $rows = $this->db->table('health_records') ->select('id, patient_id, abha_id, hi_type, care_context_reference, created_at, updated_at') ->where('patient_id', $patientId) ->orderBy('id', 'DESC') - ->limit(200) + ->limit(100) ->get() ->getResultArray(); @@ -6011,71 +6119,198 @@ class AbdmGateway extends BaseController $ccRef = 'HR-' . (int) ($row['id'] ?? 0); } $hiType = trim((string) ($row['hi_type'] ?? 'HealthDocumentRecord')); - $dateStr = date('d M Y', strtotime($row['created_at'] ?? $row['updated_at'] ?? 'now')); + $dateStr = date('d M Y', strtotime((string) ($row['created_at'] ?? $row['updated_at'] ?? 'now'))); $display = $hiType . ' - ' . $dateStr; - $careContextsV3[] = [ + $addContext([ + 'careContextId' => $ccRef, 'referenceNumber' => $ccRef, - 'display' => $display, - ]; - $careContextsFull[] = [ - 'careContextId' => $ccRef, - 'referenceNumber' => $ccRef, - 'display' => $display, - 'record_type' => $hiType, - 'patient_id' => $patientId, - ]; + 'display' => $display, + 'record_type' => $hiType, + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); } } - // 2. Fallback: query opd_prescription if health_records has no entries - if (empty($careContextsV3) && $this->db->tableExists('opd_prescription')) { + // 3. Query lab_request table (All Radiology and Lab Reports for this patient) + if ($this->db->tableExists('lab_request')) { + $labRows = $this->db->table('lab_request lr') + ->select('lr.id, lr.lab_type, lr.charge_id, lr.report_name, lr.Report_Data, lr.report_data_Impression, lr.status, lr.Request_Date, lr.reported_time, lr.collected_time') + ->where('lr.patient_id', $patientId) + ->orderBy('lr.id', 'DESC') + ->limit(30) + ->get() + ->getResultArray(); + + foreach ($labRows as $lr) { + $isImaging = ((int) ($lr['lab_type'] ?? 0) === 6); + $prefix = $isImaging ? 'RAD' : 'LAB'; + $title = trim((string) ($lr['report_name'] ?? '')); + if ($title === '' && ! empty($lr['charge_id']) && $this->db->tableExists('radiology_ultrasound_template')) { + $tpl = $this->db->table('radiology_ultrasound_template')->select('template_name')->where('id', (int) $lr['charge_id'])->get(1)->getRowArray(); + if (! empty($tpl['template_name'])) { + $title = trim((string) $tpl['template_name']); + } + } + if ($title === '') { + $title = $this->mapLabTypeToTitle((int) ($lr['lab_type'] ?? 0)) ?: ($isImaging ? 'Radiology Report' : 'Lab Report'); + } + $reportedDate = ! empty($lr['reported_time']) + ? $lr['reported_time'] + : (! empty($lr['Request_Date']) ? $lr['Request_Date'] : date('Y-m-d')); + $visitDate = date('Y-m-d', strtotime((string) $reportedDate)); + $dateStr = date('d M Y', strtotime($visitDate)); + $ccRef = $prefix . '-' . $lr['id'] . '-' . str_replace('-', '', $visitDate); + $display = ($isImaging ? 'Radiology Report - ' : 'Diagnostic Report - ') . $title . ' (' . $dateStr . ')'; + + $hasFindings = trim((string) ($lr['Report_Data'] ?? '')) !== '' || trim((string) ($lr['report_data_Impression'] ?? '')) !== ''; + $statusVal = (int) ($lr['status'] ?? 0); + $isReady = in_array($statusVal, [1, 2], true) || $hasFindings; + + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'DiagnosticReportRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => $isReady, + 'is_primary' => false, + ]); + } + } + + // 4. Query opd_prescription (OPD consultations & prescriptions) + if ($this->db->tableExists('opd_prescription')) { $prescRows = $this->db->table('opd_prescription') ->select('id, p_id, date_opd_visit, session_id, p_datetime') ->where('p_id', $patientId) ->orderBy('id', 'DESC') - ->limit(50) + ->limit(30) ->get() ->getResultArray(); foreach ($prescRows as $pr) { $visitDate = ! empty($pr['date_opd_visit']) ? $pr['date_opd_visit'] - : date('Y-m-d', strtotime($pr['p_datetime'] ?? 'now')); + : date('Y-m-d', strtotime((string) ($pr['p_datetime'] ?? 'now'))); $dateStr = date('d M Y', strtotime($visitDate)); $sessionId = ! empty($pr['session_id']) ? $pr['session_id'] : $pr['id']; $ccRef = 'OPD-' . $patientId . '-S' . $sessionId . '-' . str_replace('-', '', $visitDate); $display = 'OPConsultRecord - ' . $dateStr; - $careContextsV3[] = [ + $addContext([ + 'careContextId' => $ccRef, 'referenceNumber' => $ccRef, - 'display' => $display, - ]; - $careContextsFull[] = [ - 'careContextId' => $ccRef, - 'referenceNumber' => $ccRef, - 'display' => $display, - 'record_type' => 'OPConsultRecord', - 'patient_id' => $patientId, - ]; + 'display' => $display, + 'record_type' => 'OPConsultRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); } } - // 3. Fallback: default registration file care context + // 5. Query ipd_master (Discharge summaries) + if ($this->db->tableExists('ipd_master')) { + $ipdRows = $this->db->table('ipd_master') + ->select('id, p_id, discharge_date, register_date, ipd_status') + ->where('p_id', $patientId) + ->where('discharge_date IS NOT NULL') + ->where("discharge_date != '0000-00-00'") + ->where("discharge_date > '1970-01-01'") + ->orderBy('id', 'DESC') + ->limit(10) + ->get() + ->getResultArray(); + + foreach ($ipdRows as $ipd) { + $disDate = date('Y-m-d', strtotime((string) $ipd['discharge_date'])); + $dateStr = date('d M Y', strtotime($disDate)); + $ccRef = 'DISCHARGE-' . $ipd['id'] . '-' . str_replace('-', '', $disDate); + $display = 'DischargeSummaryRecord - ' . $dateStr; + + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'DischargeSummaryRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } + + // 6. Query immunization_records / patient_immunization if exists + if ($this->db->tableExists('immunization_records')) { + $immRows = $this->db->table('immunization_records') + ->select('id, patient_id, vaccine_name, given_date, abdm_care_context_reference') + ->where('patient_id', $patientId) + ->orderBy('id', 'DESC') + ->limit(10) + ->get() + ->getResultArray(); + + foreach ($immRows as $imm) { + $admDate = ! empty($imm['given_date']) ? date('Y-m-d', strtotime((string) $imm['given_date'])) : date('Y-m-d'); + $dateStr = date('d M Y', strtotime($admDate)); + $ccRef = ! empty($imm['abdm_care_context_reference']) + ? trim((string) $imm['abdm_care_context_reference']) + : ('IMM-' . $imm['id'] . '-' . str_replace('-', '', $admDate)); + $display = 'ImmunizationRecord - ' . ($imm['vaccine_name'] ?? 'Vaccine') . ' (' . $dateStr . ')'; + + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'ImmunizationRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } elseif ($this->db->tableExists('patient_immunization')) { + $immRows = $this->db->table('patient_immunization') + ->select('id, patient_id, vaccine_name, administration_date') + ->where('patient_id', $patientId) + ->orderBy('id', 'DESC') + ->limit(10) + ->get() + ->getResultArray(); + + foreach ($immRows as $imm) { + $admDate = ! empty($imm['administration_date']) ? date('Y-m-d', strtotime((string) $imm['administration_date'])) : date('Y-m-d'); + $dateStr = date('d M Y', strtotime($admDate)); + $ccRef = 'IMM-' . $imm['id'] . '-' . str_replace('-', '', $admDate); + $display = 'ImmunizationRecord - ' . ($imm['vaccine_name'] ?? 'Vaccine') . ' (' . $dateStr . ')'; + + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'ImmunizationRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } + + // 7. Fallback: default registration file care context if (empty($careContextsV3)) { $ccRef = 'REG-' . $patientRef; $display = 'Patient File - ' . $patientName; - $careContextsV3[] = [ + $addContext([ + 'careContextId' => $ccRef, 'referenceNumber' => $ccRef, - 'display' => $display, - ]; - $careContextsFull[] = [ - 'careContextId' => $ccRef, - 'referenceNumber' => $ccRef, - 'display' => $display, - 'record_type' => 'PatientFile', - 'patient_id' => $patientId, - ]; + 'display' => $display, + 'record_type' => 'PatientFile', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); } return [$careContextsV3, $careContextsFull]; @@ -10629,10 +10864,16 @@ class AbdmGateway extends BaseController $patientId = 0; $abhaAddress = ''; $pCode = ''; + $taskType = ''; + $entityId = ''; + $taskId = 0; if (method_exists($this->request, 'getVar')) { $patientId = (int) ($this->request->getVar('patient_id') ?? 0); $abhaAddress = trim((string) ($this->request->getVar('abha_address') ?? '')); $pCode = trim((string) ($this->request->getVar('p_code') ?? '')); + $taskType = trim((string) ($this->request->getVar('task_type') ?? '')); + $entityId = trim((string) ($this->request->getVar('entity_id') ?? '')); + $taskId = (int) ($this->request->getVar('task_id') ?? 0); } if ($patientId <= 0 && ! empty($_REQUEST['patient_id'])) { $patientId = (int) $_REQUEST['patient_id']; @@ -10643,6 +10884,15 @@ class AbdmGateway extends BaseController if ($pCode === '' && ! empty($_REQUEST['p_code'])) { $pCode = trim((string) $_REQUEST['p_code']); } + if ($taskType === '' && ! empty($_REQUEST['task_type'])) { + $taskType = trim((string) $_REQUEST['task_type']); + } + if ($entityId === '' && ! empty($_REQUEST['entity_id'])) { + $entityId = trim((string) $_REQUEST['entity_id']); + } + if ($taskId <= 0 && ! empty($_REQUEST['task_id'])) { + $taskId = (int) $_REQUEST['task_id']; + } if ($patientId <= 0 && $abhaAddress === '' && $pCode === '') { return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id, abha_address, or p_code is required']); @@ -10695,7 +10945,7 @@ class AbdmGateway extends BaseController $rawPhone = trim((string) ($patient['mphone1'] ?? $patient['mphone2'] ?? '')); $cleanPhone = substr(preg_replace('/\D/', '', $rawPhone), -10); - [$careContextsV3, $careContextsFull] = $this->findCareContextsForPatient($patientId, $patientRef, $patientName); + [$careContextsV3, $careContextsFull] = $this->findCareContextsForPatient($patientId, $patientRef, $patientName, $taskType, $entityId, $taskId); // Fetch already-linked care contexts from Bridge if ABHA address is present $linkedRefs = []; @@ -10723,10 +10973,12 @@ class AbdmGateway extends BaseController $ref = $cc['referenceNumber'] ?? $cc['careContextId'] ?? ''; $isLinked = in_array($ref, $linkedRefs, true); $contextsList[] = [ - 'ref' => $ref, - 'display' => $cc['display'] ?? $ref, - 'hi_type' => $cc['record_type'] ?? 'OPConsultRecord', - 'is_linked' => $isLinked, + 'ref' => $ref, + 'display' => $cc['display'] ?? $ref, + 'hi_type' => $cc['record_type'] ?? 'OPConsultRecord', + 'is_linked' => $isLinked, + 'is_fhir_ready' => (bool) ($cc['is_fhir_ready'] ?? true), + 'is_primary' => (bool) ($cc['is_primary'] ?? false), ]; } diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index 5825743..df6c28c 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -146,6 +146,16 @@ class AbdmTaskBoard extends BaseController } foreach ($tasks as &$task) { + $taskStatus = strtolower(trim((string) ($task['status'] ?? 'pending'))); + if (in_array($taskStatus, ['completed', 'linked', 'cancelled'], true)) { + $task['cooling_active'] = false; + $task['cooling_remaining_minutes'] = 0; + $task['cooling_remaining_seconds'] = 0; + $task['auto_link_at'] = null; + $task['ready_for_autolink'] = false; + continue; + } + $taskType = (string) ($task['task_type'] ?? ''); $lastModified = ! empty($task['updated_at']) ? (string) $task['updated_at'] diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 4849cde..4083104 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -18,17 +18,19 @@ namespace App\Libraries\Abdm; */ class EAtriaBridgeConnector implements AbdmConnectorInterface { - private string $baseUrl; - private string $token; - private string $hfrId; - private string $bridgeHospitalId; - private int $timeoutSec; + private string $baseUrl = ''; + private string $token = ''; + private string $hfrId = ''; + private string $bridgeHospitalId = ''; + private int $timeoutSec = 30; private bool $sslVerify = true; /** @var array */ private array $tokenCandidates = []; /** @var array */ private array $tokenSourceByValue = []; private string $tokenSource = 'config'; + /** @var array */ + protected array $linkTokenRetryDelays = [1.5, 2.0, 2.5]; public function __construct() { @@ -259,12 +261,12 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface * @param array $body * @return array */ - private function post(string $path, array $body): array + protected function post(string $path, array $body): array { return $this->httpCall('POST', $path, $body); } - private function patch(string $path, array $body): array + protected function patch(string $path, array $body): array { return $this->httpCall('PATCH', $path, $body); } @@ -274,7 +276,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface * @param array $extraHeaders * @return array */ - private function get(string $path, array $query = [], array $extraHeaders = []): array + protected function get(string $path, array $query = [], array $extraHeaders = []): array { // e-Atria bridge expects hfr_id alongside Bearer auth for GET endpoints too. if ($this->hfrId !== '' && empty($query['hfr_id'])) { @@ -1781,7 +1783,38 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface if ($this->hfrId !== '' && empty($payload['hfr_id'])) { $payload['hfr_id'] = $this->hfrId; } - return $this->post('/v3/hip/link/carecontext', $payload); + + $hasLinkId = ! empty($payload['link_token_id']); + $delaysSec = $this->linkTokenRetryDelays; + $maxAttempts = $hasLinkId ? count($delaysSec) + 1 : 1; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $result = $this->post('/v3/hip/link/carecontext', $payload); + + if (($result['ok'] ?? 0) === 1) { + return $result; + } + + // Check if error is because ABDM callback hasn't populated link_token yet on the bridge + $err = strtolower((string) ($result['error_text'] ?? $result['error'] ?? '')); + $isTokenPending = $hasLinkId && ( + strpos($err, 'link_token') !== false && + (strpos($err, 'required') !== false || strpos($err, 'link-token first') !== false) + ); + + if (! $isTokenPending || $attempt === $maxAttempts) { + if ($isTokenPending) { + $result['error_text'] = 'ABDM authorization token callback is still pending from the gateway. Please retry in a few seconds.'; + $result['error'] = $result['error_text']; + } + return $result; + } + + $sleepSec = $delaysSec[$attempt - 1] ?? 2.0; + usleep((int) ($sleepSec * 1000000)); + } + + return $result; } public function hipGetPatientLinks(array $filters = []): array diff --git a/app/Views/PathLab_Report/ultrasound_report_edit.php b/app/Views/PathLab_Report/ultrasound_report_edit.php index a98fb2b..d7e89d3 100644 --- a/app/Views/PathLab_Report/ultrasound_report_edit.php +++ b/app/Views/PathLab_Report/ultrasound_report_edit.php @@ -135,10 +135,10 @@
-
-
- - +
+
+ +
@@ -177,8 +177,9 @@ })(); $('#updatereport').click(function() { + var btn = $(this); var repo_id = $('#repo_id').val(); - var input_Reportname = $('#input_Reportname').val(); + var input_Reportname = $.trim($('#input_Reportname').val() || ''); var charge_id = $('#charge_id').val(); var group_id = $('#input_Reporttitle').val(); var keywords = $('#keywords').val(); @@ -188,6 +189,48 @@ var csrf_value = $('input[name=""]').first().val() || ''; var modality = ; + if (input_Reportname === '') { + if (typeof notify === 'function') { + notify('danger', 'Validation', 'Template Name is required.'); + } else { + alert('Template Name is required.'); + } + $('#input_Reportname').focus(); + return; + } + + btn.prop('disabled', true); + var originalBtnText = btn.text(); + btn.text('Saving...'); + + var finishSave = function(successMsg) { + if (typeof notify === 'function') { + notify('success', 'Saved', successMsg || 'Template saved successfully'); + } + if (typeof window.refreshRadiologyTemplateList === 'function') { + window.refreshRadiologyTemplateList(); + return; + } + if (typeof window.closeRadiologyActionModal === 'function') { + window.closeRadiologyActionModal(function() { + if (typeof load_form === 'function') { + load_form('/' + modality, 'Diagnosis Template'); + } else { + window.location.reload(); + } + }); + return; + } + if (typeof window.cleanupRadiologyModalBackdrop === 'function') { + window.cleanupRadiologyModalBackdrop(); + } + if (typeof load_form === 'function') { + load_form('/' + modality, 'Diagnosis Template'); + } else { + window.location.reload(); + } + }; + if (repo_id > 0) { $.post('/' + modality, { "repo_id": repo_id, @@ -200,15 +243,25 @@ "Impression": Impression, "": csrf_value }, function(data) { - if (data && Number(data.update_record || 0) === 1 && typeof window.refreshRadiologyTemplateList === 'function') { - window.refreshRadiologyTemplateList(); + btn.prop('disabled', false).text(originalBtnText); + if (data && Number(data.update_record || 0) === 1) { + finishSave(data.showcontent); return; } - + var errMsg = (data && data.showcontent) ? data.showcontent : 'Unable to update template'; if (typeof notify === 'function') { - notify('success', 'Saved', data.showcontent || 'Saved'); + notify('danger', 'Error', errMsg); + } else { + alert(errMsg); } - }, 'json'); + }, 'json').fail(function() { + btn.prop('disabled', false).text(originalBtnText); + if (typeof notify === 'function') { + notify('danger', 'Error', 'Network or server error while updating template.'); + } else { + alert('Network or server error while updating template.'); + } + }); } else { $.post('/' + modality, { "repo_id": repo_id, @@ -221,14 +274,25 @@ "Impression": Impression, "": csrf_value }, function(data) { - if (data.insertid > 0) { - if (typeof load_form === 'function') { - load_form('/' + modality, 'Diagnosis Template'); - } else { - window.location.href = '/' + modality; - } + btn.prop('disabled', false).text(originalBtnText); + if (data && (Number(data.insertid || 0) > 0 || Number(data.update_record || 0) === 1)) { + finishSave(data.showcontent || 'Report created successfully'); + return; } - }, 'json'); + var errMsg = (data && data.showcontent) ? data.showcontent : 'Unable to create template'; + if (typeof notify === 'function') { + notify('danger', 'Error', errMsg); + } else { + alert(errMsg); + } + }, 'json').fail(function() { + btn.prop('disabled', false).text(originalBtnText); + if (typeof notify === 'function') { + notify('danger', 'Error', 'Network or server error while saving template.'); + } else { + alert('Network or server error while saving template.'); + } + }); } }); diff --git a/app/Views/PathLab_Report/ultrasound_template_list.php b/app/Views/PathLab_Report/ultrasound_template_list.php index da5da70..be1ee5f 100644 --- a/app/Views/PathLab_Report/ultrasound_template_list.php +++ b/app/Views/PathLab_Report/ultrasound_template_list.php @@ -376,6 +376,70 @@ $selectedModality = (int) ($modality ?? 2); }); }); + function cleanupRadiologyModalBackdrop() { + document.querySelectorAll('.modal-backdrop').forEach(function(el) { + try { el.remove(); } catch (e) {} + }); + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('overflow'); + document.body.style.removeProperty('padding-right'); + } + window.cleanupRadiologyModalBackdrop = cleanupRadiologyModalBackdrop; + + function closeRadiologyActionModal(callback) { + destroyRadiologyEditors(); + var modalEl = document.getElementById('radiologyActionModal'); + + if (!modalEl) { + cleanupRadiologyModalBackdrop(); + if (typeof callback === 'function') callback(); + return; + } + + var isVisible = modalEl.classList.contains('show') || document.body.classList.contains('modal-open'); + if (!isVisible) { + cleanupRadiologyModalBackdrop(); + if (typeof callback === 'function') callback(); + return; + } + + var doneCalled = false; + function done() { + if (doneCalled) return; + doneCalled = true; + cleanupRadiologyModalBackdrop(); + if (typeof callback === 'function') { + callback(); + } + } + + modalEl.addEventListener('hidden.bs.modal', function onHidden() { + modalEl.removeEventListener('hidden.bs.modal', onHidden); + done(); + }, { once: true }); + + setTimeout(done, 300); + + try { + if (window.bootstrap && window.bootstrap.Modal) { + var modalInstance = window.bootstrap.Modal.getInstance(modalEl); + if (modalInstance) { + modalInstance.hide(); + } else { + new window.bootstrap.Modal(modalEl).hide(); + } + } else if (window.jQuery && $(modalEl).modal) { + $(modalEl).modal('hide'); + } else { + done(); + } + } catch (err) { + console.warn('Error hiding modal', err); + done(); + } + } + window.closeRadiologyActionModal = closeRadiologyActionModal; + if (actionModalEl) { actionModalEl.addEventListener('hidden.bs.modal', function() { destroyRadiologyEditors(); @@ -383,6 +447,7 @@ $selectedModality = (int) ($modality ?? 2); if (actionModalBodyEl) { actionModalBodyEl.innerHTML = '
Loading...
'; } + cleanupRadiologyModalBackdrop(); }); } else if (window.jQuery && $('#radiologyActionModal').on) { $('#radiologyActionModal').on('hidden.bs.modal', function() { @@ -391,15 +456,18 @@ $selectedModality = (int) ($modality ?? 2); if (actionModalBodyEl) { actionModalBodyEl.innerHTML = '
Loading...
'; } + cleanupRadiologyModalBackdrop(); }); } window.refreshRadiologyTemplateList = function() { - if (typeof load_form === 'function') { - load_form('/', 'Diagnosis Template'); - return; - } - window.location.reload(); + closeRadiologyActionModal(function() { + if (typeof load_form === 'function') { + load_form('/', 'Diagnosis Template'); + return; + } + window.location.reload(); + }); }; })(); diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index d32e1a6..6bbb194 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -164,8 +164,13 @@
- + + LINKED +
BRIDGE SUBMITTED
@@ -3482,8 +3487,27 @@ var abhaId = (abhaInput ? abhaInput.value : (row.getAttribute('data-abha-id') || '')).trim(); var patientCell = row.children[2]; var patientName = patientCell ? (patientCell.querySelector('strong') || patientCell.children[0] || {}).textContent || '' : ''; + var taskId = parseInt(row.getAttribute('data-task-id') || '0', 10); + var taskType = (row.getAttribute('data-task-type') || '').trim(); + var entityId = (row.getAttribute('data-entity-id') || '').trim(); + if (typeof window.openAbdmHipLinkModal === 'function') { - window.openAbdmHipLinkModal(patientId, abhaId, { patient_name: patientName.trim() }); + window.openAbdmHipLinkModal(patientId, abhaId, { + patient_name: patientName.trim(), + task_id: taskId, + task_type: taskType, + entity_id: entityId, + onLinked: function (res) { + var statusCell = row.children[5]; + if (statusCell) { + var refHtml = ''; + if (res && res.care_contexts && res.care_contexts.length > 0) { + refHtml = '
' + (res.care_contexts[0].ref || '') + '
'; + } + statusCell.innerHTML = 'LINKED' + refHtml; + } + } + }); } }); }); @@ -3493,8 +3517,15 @@ var patientId = parseInt(btn.getAttribute('data-patient-id') || '0', 10); var abhaId = (btn.getAttribute('data-abha-id') || '').trim(); var patientName = (btn.getAttribute('data-patient-name') || '').trim(); + var opdId = (btn.getAttribute('data-opd-id') || '').trim(); + var taskType = opdId ? 'opd_prescription_publish' : ''; + var entityId = opdId || ''; if (typeof window.openAbdmHipLinkModal === 'function') { - window.openAbdmHipLinkModal(patientId, abhaId, { patient_name: patientName }); + window.openAbdmHipLinkModal(patientId, abhaId, { + patient_name: patientName, + task_type: taskType, + entity_id: entityId + }); } }); }); diff --git a/app/Views/partials/abdm_hip_link_modal.php b/app/Views/partials/abdm_hip_link_modal.php index 7caed96..5cd831f 100644 --- a/app/Views/partials/abdm_hip_link_modal.php +++ b/app/Views/partials/abdm_hip_link_modal.php @@ -265,12 +265,12 @@ var btnDeselectAll = document.getElementById('btnDeselectAllCareContexts'); if (btnSelectAll) { btnSelectAll.addEventListener('click', function() { - document.querySelectorAll('.care-context-cb').forEach(function(cb) { cb.checked = true; }); + document.querySelectorAll('.care-context-cb:not(:disabled)').forEach(function(cb) { cb.checked = true; }); }); } if (btnDeselectAll) { btnDeselectAll.addEventListener('click', function() { - document.querySelectorAll('.care-context-cb').forEach(function(cb) { cb.checked = false; }); + document.querySelectorAll('.care-context-cb:not(:disabled)').forEach(function(cb) { cb.checked = false; }); }); } @@ -279,8 +279,9 @@ if (btnReload) { btnReload.addEventListener('click', function() { var abha = (document.getElementById('hipAbhaAddress').value || '').trim(); - if (abha) { - loadPatientCareContexts(0, abha); + var pId = (window.currentHipTaskContext && window.currentHipTaskContext.patientId) ? window.currentHipTaskContext.patientId : 0; + if (abha || pId > 0) { + loadPatientCareContexts(pId, abha); } else { alert('Please enter an ABHA Address first.'); } @@ -304,16 +305,27 @@ var display = item.display || ref; var hiType = item.hi_type || 'Record'; var isLinked = !!item.is_linked; + var isFhirReady = (typeof item.is_fhir_ready !== 'undefined') ? !!item.is_fhir_ready : true; + var isPrimary = !!item.is_primary; // Check if already in list var existing = container.querySelector('input[value="' + ref.replace(/"/g, '\\"') + '"]'); if (existing) { - existing.checked = true; + if (isPrimary && !existing.disabled) { + existing.checked = true; + } return; } var wrapper = document.createElement('div'); - wrapper.className = 'border rounded p-2 d-flex align-items-center justify-content-between bg-white'; + var baseClass = 'border rounded p-2 d-flex align-items-center justify-content-between '; + if (isPrimary) { + wrapper.className = baseClass + 'bg-primary-subtle border-primary shadow-sm'; + } else if (!isFhirReady) { + wrapper.className = baseClass + 'bg-light border-warning-subtle text-muted'; + } else { + wrapper.className = baseClass + 'bg-white'; + } var left = document.createElement('div'); left.className = 'form-check mb-0'; @@ -324,10 +336,29 @@ cb.id = 'cc_' + Math.random().toString(36).substring(2, 9); cb.value = ref; cb.dataset.hiType = hiType; - cb.checked = (typeof isChecked === 'boolean') ? isChecked : !isLinked; + cb.dataset.isPrimary = isPrimary ? '1' : '0'; + cb.dataset.isFhirReady = isFhirReady ? '1' : '0'; + cb.dataset.isLinked = isLinked ? '1' : '0'; + + if (isLinked) { + cb.disabled = true; + cb.checked = false; + } else if (!isFhirReady) { + cb.disabled = true; + cb.checked = false; + cb.title = 'Incomplete findings - finalize report before linking'; + } else if (typeof isChecked === 'boolean') { + cb.checked = isChecked; + } else if (isPrimary) { + cb.checked = true; + } else { + // If opened for a specific task, only check the primary task context by default + var hasSpecificTask = !!(window.currentHipTaskContext && window.currentHipTaskContext.taskId); + cb.checked = !hasSpecificTask; + } var label = document.createElement('label'); - label.className = 'form-check-label ms-2 small fw-semibold'; + label.className = 'form-check-label ms-2 small fw-semibold' + (!isFhirReady ? ' text-secondary' : ''); label.htmlFor = cb.id; label.textContent = display; @@ -335,7 +366,14 @@ left.appendChild(label); var badgeSpan = document.createElement('div'); - badgeSpan.className = 'd-flex align-items-center gap-1'; + badgeSpan.className = 'd-flex align-items-center gap-1 flex-wrap justify-content-end'; + + if (isPrimary) { + var primaryBadge = document.createElement('span'); + primaryBadge.className = 'badge bg-primary text-white small'; + primaryBadge.innerHTML = 'Current Task'; + badgeSpan.appendChild(primaryBadge); + } var typeBadge = document.createElement('span'); typeBadge.className = 'badge bg-secondary-subtle text-secondary border small'; @@ -344,13 +382,19 @@ if (isLinked) { var linkedBadge = document.createElement('span'); - linkedBadge.className = 'badge bg-success-subtle text-success border border-success small'; + linkedBadge.className = 'badge bg-success-subtle text-success border border-success small cc-badge-linked'; linkedBadge.innerHTML = 'Linked'; badgeSpan.appendChild(linkedBadge); + } else if (!isFhirReady) { + var draftBadge = document.createElement('span'); + draftBadge.className = 'badge bg-warning-subtle text-dark border border-warning small cc-badge-draft'; + draftBadge.innerHTML = 'Draft / Incomplete'; + draftBadge.title = 'Clinical findings must be documented before linking to ABDM'; + badgeSpan.appendChild(draftBadge); } else { var readyBadge = document.createElement('span'); - readyBadge.className = 'badge bg-primary-subtle text-primary border border-primary small'; - readyBadge.textContent = 'Ready to Link'; + readyBadge.className = 'badge bg-success-subtle text-success border border-success-subtle small cc-badge-ready'; + readyBadge.innerHTML = 'FHIR Ready'; badgeSpan.appendChild(readyBadge); } @@ -362,16 +406,23 @@ /** * Load patient demographics and care contexts from server. */ - window.loadPatientCareContexts = function(patientId, abhaAddress) { + window.loadPatientCareContexts = function(patientId, abhaAddress, taskType, entityId, taskId) { var loading = document.getElementById('hipCareContextsLoading'); var container = document.getElementById('hipCareContextsContainer'); if (loading) loading.classList.remove('d-none'); if (container) container.innerHTML = ''; + taskType = taskType || (window.currentHipTaskContext ? window.currentHipTaskContext.taskType : ''); + entityId = entityId || (window.currentHipTaskContext ? window.currentHipTaskContext.entityId : ''); + taskId = taskId || (window.currentHipTaskContext ? window.currentHipTaskContext.taskId : 0); + var csrf = getCsrfData(); var url = '' + '?patient_id=' + encodeURIComponent(patientId || 0) - + '&abha_address=' + encodeURIComponent(abhaAddress || ''); + + '&abha_address=' + encodeURIComponent(abhaAddress || '') + + '&task_type=' + encodeURIComponent(taskType || '') + + '&entity_id=' + encodeURIComponent(entityId || '') + + '&task_id=' + encodeURIComponent(taskId || 0); fetch(url, { method: 'GET', @@ -385,14 +436,20 @@ if (loading) loading.classList.add('d-none'); updateCsrf(data); - if (data.ok && data.patient) { + if (!data.ok) { + var errTxt = data.error_text || data.error || (data.message || 'Failed to load patient records'); + container.innerHTML = '
' + errTxt + '
'; + return; + } + + if (data.patient) { var p = data.patient; if (p.abha_address) document.getElementById('hipAbhaAddress').value = p.abha_address; var pAbhaNum = (p.abha_number || '').trim(); if (pAbhaNum.indexOf('@') !== -1 || pAbhaNum.replace(/\D/g, '').length !== 14) { pAbhaNum = ''; } - document.getElementById('hipAbhaNumber').value = pAbhaNum; + if (pAbhaNum) document.getElementById('hipAbhaNumber').value = pAbhaNum; if (p.name) document.getElementById('hipPatientName').value = p.name; if (p.gender) document.getElementById('hipGender').value = p.gender; if (p.year_of_birth) document.getElementById('hipYob').value = p.year_of_birth; @@ -407,13 +464,55 @@ } var contexts = data.care_contexts || []; + var currentTaskAlreadyLinked = false; + var currentTaskLinkedRef = ''; if (contexts.length > 0) { contexts.forEach(function(item) { appendCareContextCheckbox(item); + if (item.is_primary && item.is_linked) { + currentTaskAlreadyLinked = true; + currentTaskLinkedRef = item.ref; + } }); } else { container.innerHTML = '
No clinical visit records found for this patient. Click Custom to add a test context.
'; } + + if (currentTaskAlreadyLinked && window.currentHipTaskContext && window.currentHipTaskContext.taskId > 0) { + var tId = window.currentHipTaskContext.taskId; + var alertBox = document.getElementById('hipLinkStatusAlert'); + if (alertBox) { + alertBox.className = 'alert alert-info py-2 px-3 small'; + alertBox.innerHTML = 'Already Linked: The record for this task (' + currentTaskLinkedRef + ') is already linked to patient\'s ABHA in ABDM.'; + alertBox.classList.remove('d-none'); + } + try { + var csrf = getCsrfData(); + var markFormData = new URLSearchParams(); + markFormData.append('task_id', tId); + markFormData.append('status', 'completed'); + markFormData.append('note', 'Care context already linked in ABDM: ' + currentTaskLinkedRef); + markFormData.append(csrf.name || 'csrf_hms', csrf.hash); + + fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: markFormData.toString() + }).then(function(mRes) { return mRes.json(); }).then(function(mResult) { + updateCsrf(mResult); + if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + task_id: tId, + care_contexts: [{ ref: currentTaskLinkedRef }] + }); + } + }).catch(function() {}); + } catch (e) {} + } }) .catch(function(err) { if (loading) loading.classList.add('d-none'); @@ -426,17 +525,27 @@ */ window.openAbdmHipLinkModal = function(patientId, abhaAddress, prefill) { prefill = prefill || {}; + window.currentHipTaskContext = { + patientId: patientId, + abhaAddress: abhaAddress, + taskId: prefill.task_id || 0, + taskType: prefill.task_type || '', + entityId: prefill.entity_id || '', + onLinked: prefill.onLinked || null + }; + var modalEl = document.getElementById('abdmHipLinkModal'); if (!modalEl) return; var modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl); // Reset fields - document.getElementById('hipAbhaAddress').value = abhaAddress || prefill.abha_address || ''; - var rawPrefillNum = (prefill.abha_number || prefill.abha_id || '').trim(); - if (rawPrefillNum.indexOf('@') !== -1 || rawPrefillNum.replace(/\D/g, '').length !== 14) { - rawPrefillNum = ''; - } - document.getElementById('hipAbhaNumber').value = rawPrefillNum; + var rawAbhaInput = (abhaAddress || prefill.abha_address || prefill.abha_id || '').trim(); + var isAbhaAddress = rawAbhaInput.indexOf('@') !== -1; + var clean14Num = rawAbhaInput.replace(/\D/g, ''); + var is14Num = !isAbhaAddress && clean14Num.length === 14; + + document.getElementById('hipAbhaAddress').value = isAbhaAddress ? rawAbhaInput : (prefill.abha_address || ''); + document.getElementById('hipAbhaNumber').value = is14Num ? clean14Num : ((prefill.abha_number || '').indexOf('@') === -1 ? (prefill.abha_number || '') : ''); document.getElementById('hipPatientName').value = prefill.name || prefill.patient_name || ''; document.getElementById('hipGender').value = prefill.gender || 'M'; document.getElementById('hipYob').value = prefill.year_of_birth || prefill.yob || ''; @@ -458,7 +567,7 @@ // Load visits if (patientId > 0 || (abhaAddress && abhaAddress.length > 3)) { - loadPatientCareContexts(patientId, abhaAddress); + loadPatientCareContexts(patientId, abhaAddress, prefill.task_type, prefill.entity_id, prefill.task_id); } else { var container = document.getElementById('hipCareContextsContainer'); if (container) { @@ -505,12 +614,23 @@ var submitBtn = document.getElementById('btnSubmitHipLink'); var selectedContexts = []; + var dominantHiType = ''; + var hasIncomplete = false; + document.querySelectorAll('.care-context-cb:checked').forEach(function(cb) { var ref = cb.value; var display = cb.nextElementSibling ? cb.nextElementSibling.textContent : ref; + var itemHiType = cb.dataset.hiType || ''; + if (!dominantHiType && itemHiType) { + dominantHiType = itemHiType; + } + if (cb.dataset.isFhirReady === '0') { + hasIncomplete = true; + } selectedContexts.push({ ref: ref, - display: display + display: display, + hi_type: itemHiType }); }); @@ -530,16 +650,57 @@ alert('Please select at least one care context to link.'); return; } + if (hasIncomplete) { + alert('One or more selected records are marked as incomplete. Only finalized records with documented findings can be linked to ABDM.'); + return; + } var allAlreadyLinked = selectedContexts.length > 0 && selectedContexts.every(function(c) { var cb = document.querySelector('.care-context-cb[value="' + c.ref.replace(/"/g, '\\"') + '"]'); - return cb && cb.closest('.border') && cb.closest('.border').querySelector('.badge.bg-success-subtle'); + return cb && cb.dataset.isLinked === '1'; }); if (allAlreadyLinked) { alertBox.className = 'alert alert-success py-2 px-3 small'; alertBox.innerHTML = 'Already Linked! ' + 'The selected care context(s) are already linked with this patient\'s ABHA. Patient can view and fetch the records directly in their PHR app.'; alertBox.classList.remove('d-none'); + + // If initiated for a specific task board task, sync task to completed so status isn't stuck on Ready to link! + if (window.currentHipTaskContext && window.currentHipTaskContext.taskId > 0) { + var tId = window.currentHipTaskContext.taskId; + var taskNote = 'Care context already linked to ABHA: ' + selectedContexts.map(function(c) { return c.ref; }).join(', '); + try { + var csrf = getCsrfData(); + var markFormData = new URLSearchParams(); + markFormData.append('task_id', tId); + markFormData.append('status', 'completed'); + markFormData.append('note', taskNote); + markFormData.append(csrf.name || 'csrf_hms', csrf.hash); + + fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: markFormData.toString() + }).then(function(mRes) { return mRes.json(); }).then(function(mResult) { + updateCsrf(mResult); + if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + task_id: tId, + care_contexts: selectedContexts + }); + } + }).catch(function() {}); + } catch (e) {} + } else if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + care_contexts: selectedContexts + }); + } + return; } @@ -581,10 +742,17 @@ } var linkTokenId = tokenData.link_token_id; - alertBox.innerHTML = 'Step 2/2: Link token acquired (ID: ' + linkTokenId + '). Linking ' + selectedContexts.length + ' care context(s)...'; + var isReused = tokenData.status === 'reused'; + alertBox.innerHTML = 'Step 2/2: ' + (isReused ? 'Active link token verified' : 'Link token acquired') + ' (ID: ' + linkTokenId + '). Linking ' + selectedContexts.length + ' care context(s)...'; - // Allow token callback buffer if needed - await new Promise(function(r) { setTimeout(r, 1200); }); + // Allow token callback buffer if needed (if newly generated) + var waitMs = isReused ? 300 : 2000; + await new Promise(function(r) { setTimeout(r, waitMs); }); + + var hiTypeToSend = dominantHiType || 'OPConsultRecord'; + if (hiTypeToSend === 'OPConsultation') { + hiTypeToSend = 'OPConsultRecord'; + } // Step 2: Link Care Contexts csrf = getCsrfData(); @@ -601,7 +769,7 @@ link_token_id: linkTokenId, patient_ref: abhaAddress, display: name, - hi_type: 'OPConsultation', + hi_type: hiTypeToSend, care_contexts: selectedContexts, csrf_hms: csrf.hash }) @@ -614,11 +782,97 @@ alertBox.className = 'alert alert-success py-2 px-3 small'; alertBox.innerHTML = 'Success! ' + 'Care contexts successfully submitted to ABDM for linking. Patient can now discover and access them in their ABHA / PHR app.'; + + // If initiated for a specific task board task, mark the task as completed + if (window.currentHipTaskContext && window.currentHipTaskContext.taskId > 0) { + var tId = window.currentHipTaskContext.taskId; + var taskNote = 'Care context linked to ABHA: ' + selectedContexts.map(function(c) { return c.ref; }).join(', '); + try { + csrf = getCsrfData(); + var markFormData = new URLSearchParams(); + markFormData.append('task_id', tId); + markFormData.append('status', 'completed'); + markFormData.append('note', taskNote); + markFormData.append(csrf.name || 'csrf_hms', csrf.hash); + + fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: markFormData.toString() + }).then(function(mRes) { return mRes.json(); }).then(function(mResult) { + updateCsrf(mResult); + if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + task_id: tId, + care_contexts: selectedContexts + }); + } + }).catch(function() {}); + } catch (e) {} + } else if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + care_contexts: selectedContexts + }); + } // Refresh care contexts list - loadPatientCareContexts(0, abhaAddress); + loadPatientCareContexts( + window.currentHipTaskContext ? window.currentHipTaskContext.patientId : 0, + abhaAddress + ); } else { var errDetail = linkData.error_text || linkData.error || (linkData.detail && linkData.detail.error && linkData.detail.error.message) || 'Care context linking request failed'; + var isAlreadyLinkedErr = /already\s+(linked|registered|added|exists)/i.test(errDetail) || /1017/i.test(errDetail); + if (isAlreadyLinkedErr) { + alertBox.className = 'alert alert-success py-2 px-3 small'; + alertBox.innerHTML = 'Already Linked! ' + + 'ABDM reports this care context is already linked to patient\'s ABHA. Task status updated to Linked.'; + + if (window.currentHipTaskContext && window.currentHipTaskContext.taskId > 0) { + var tId = window.currentHipTaskContext.taskId; + var taskNote = 'Care context confirmed already linked in ABDM: ' + selectedContexts.map(function(c) { return c.ref; }).join(', '); + try { + csrf = getCsrfData(); + var markFormData = new URLSearchParams(); + markFormData.append('task_id', tId); + markFormData.append('status', 'completed'); + markFormData.append('note', taskNote); + markFormData.append(csrf.name || 'csrf_hms', csrf.hash); + + fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: markFormData.toString() + }).then(function(mRes) { return mRes.json(); }).then(function(mResult) { + updateCsrf(mResult); + if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + task_id: tId, + care_contexts: selectedContexts + }); + } + }).catch(function() {}); + } catch (e) {} + } else if (window.currentHipTaskContext && typeof window.currentHipTaskContext.onLinked === 'function') { + window.currentHipTaskContext.onLinked({ + care_contexts: selectedContexts + }); + } + + loadPatientCareContexts( + window.currentHipTaskContext ? window.currentHipTaskContext.patientId : 0, + abhaAddress + ); + return; + } throw new Error(errDetail); } } catch (err) { diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index 9a79ee6..08020dc 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -304,6 +304,31 @@ return false; } + function cleanupBootstrapModalState() { + try { + document.querySelectorAll('.modal.show').forEach(function(modalEl) { + if (window.bootstrap && window.bootstrap.Modal) { + var instance = window.bootstrap.Modal.getInstance(modalEl); + if (instance) { + try { instance.dispose(); } catch (e) {} + } + } + modalEl.classList.remove('show'); + modalEl.style.display = 'none'; + modalEl.setAttribute('aria-hidden', 'true'); + }); + document.querySelectorAll('.modal-backdrop').forEach(function(backdrop) { + try { backdrop.remove(); } catch (e) {} + }); + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('overflow'); + document.body.style.removeProperty('padding-right'); + } catch (e) { + console.warn('cleanupBootstrapModalState failed', e); + } + } + window.cleanupBootstrapModalState = cleanupBootstrapModalState; + window.load_form = function(ourl, top_title = '') { if (!requireJquery()) return; var normalizedUrl = String(ourl || ''); @@ -319,12 +344,14 @@ console.warn('pageCleanup failed', e); } } + cleanupBootstrapModalState(); $.ajax({ url: normalizedUrl, dataType: "html", async: true, timeout: REQUEST_TIMEOUT_MS, beforeSend: function() { + cleanupBootstrapModalState(); $('#main').html('loading...'); $("#wait").css("display", "block"); } @@ -338,6 +365,7 @@ if (mainEl) { mainEl.innerHTML = html; } + cleanupBootstrapModalState(); executeInjectedScripts('main'); if (typeof window.showPageNotifications === 'function') { window.showPageNotifications(mainEl); diff --git a/tests/unit/Abdm/CareContextDiscoveryTest.php b/tests/unit/Abdm/CareContextDiscoveryTest.php new file mode 100644 index 0000000..d53d154 --- /dev/null +++ b/tests/unit/Abdm/CareContextDiscoveryTest.php @@ -0,0 +1,283 @@ +query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('lab_request') . " ( + id INTEGER PRIMARY KEY, + patient_id INTEGER, + lab_type INTEGER, + charge_id INTEGER, + report_name TEXT, + Report_Data TEXT, + report_data_Impression TEXT, + status INTEGER, + Request_Date TEXT, + reported_time TEXT, + collected_time TEXT + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('charge_master') . " ( + id INTEGER PRIMARY KEY, + charge_name TEXT + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('opd_prescription') . " ( + id INTEGER PRIMARY KEY, + p_id INTEGER, + date_opd_visit TEXT, + session_id INTEGER, + p_datetime TEXT + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('health_records') . " ( + id INTEGER PRIMARY KEY, + patient_id INTEGER, + abha_id TEXT, + hi_type TEXT, + care_context_reference TEXT, + created_at TEXT, + updated_at TEXT + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('patient_master') . " ( + id INTEGER PRIMARY KEY, + p_code TEXT, + p_fname TEXT, + dob TEXT, + age TEXT, + gender INTEGER, + abha_id TEXT, + abha_address TEXT, + mphone1 TEXT + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('ipd_master') . " ( + id INTEGER PRIMARY KEY, + p_id INTEGER, + discharge_date TEXT, + register_date TEXT, + ipd_status INTEGER + )"); + + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('immunization_records') . " ( + id INTEGER PRIMARY KEY, + patient_id INTEGER, + vaccine_name TEXT, + given_date TEXT, + abdm_care_context_reference TEXT + )"); + + $db->table('charge_master')->emptyTable(); + $db->table('lab_request')->emptyTable(); + $db->table('opd_prescription')->emptyTable(); + $db->table('health_records')->emptyTable(); + $db->table('patient_master')->emptyTable(); + $db->table('ipd_master')->emptyTable(); + $db->table('immunization_records')->emptyTable(); + + $db->table('patient_master')->insert([ + 'id' => 12, + 'p_code' => 'P26071000012', + 'p_fname' => 'KESHAV SINGH', + 'dob' => '1976-06-30', + 'age' => null, + 'gender' => 1, + 'abha_id' => '91747451787143', + 'abha_address' => 'singhkeshav301976@sbx', + 'mphone1' => '7817828379', + ]); + + $db->table('charge_master')->insert([ + 'id' => 19, + 'charge_name' => 'USG WHOLE ABDOMEN', + ]); + + $db->table('lab_request')->insert([ + 'id' => 31, + 'patient_id' => 12, + 'lab_type' => 6, + 'charge_id' => 19, + 'report_name' => 'USG WHOLE ABDOMEN', + 'Report_Data' => 'Liver is normal. Gall bladder normal.', + 'report_data_Impression' => 'Normal study.', + 'status' => 1, + 'Request_Date' => '2026-09-29', + 'reported_time' => '2026-09-29 11:00:00', + ]); + + $db->table('opd_prescription')->insert([ + 'id' => 101, + 'p_id' => 12, + 'date_opd_visit' => '2026-09-28', + 'session_id' => 50, + 'p_datetime' => '2026-09-28 10:00:00', + ]); + + $this->gateway = new AbdmGateway(); + $this->gateway->initController( + \Config\Services::request(), + \Config\Services::response(), + \Config\Services::logger() + ); + } + + public function testCareContextsDiscoveryWithTargetTask(): void + { + $reflector = new ReflectionClass($this->gateway); + $method = $reflector->getMethod('findCareContextsForPatient'); + $method->setAccessible(true); + + // Test with patient 12 and task_type='radiology_report_publish', entity_id='31' + [$v3List, $fullList] = $method->invoke( + $this->gateway, + 12, + 'P-12', + 'KESHAV SINGH', + 'radiology_report_publish', + '31', + 591 + ); + + $this->assertNotEmpty($fullList, 'Care contexts list should not be empty for patient 12'); + + // Verify the primary context is the radiology report #31 + $primaryCtx = $fullList[0]; + $this->assertTrue($primaryCtx['is_primary'] ?? false, 'First care context should be marked as primary'); + $this->assertSame('DiagnosticReportRecord', $primaryCtx['record_type']); + $this->assertStringStartsWith('RAD-31-', $primaryCtx['careContextId']); + $this->assertTrue($primaryCtx['is_fhir_ready']); + + // Verify that v3List also contains referenceNumber + $this->assertSame($primaryCtx['careContextId'], $v3List[0]['referenceNumber']); + + // Verify secondary contexts (e.g. OPD prescription) are also discovered + $hasOpd = false; + foreach ($fullList as $ctx) { + if ($ctx['record_type'] === 'OPConsultRecord') { + $hasOpd = true; + $this->assertFalse($ctx['is_primary']); + } + } + $this->assertTrue($hasOpd, 'Patient OPD consultations should also be included in care contexts'); + } + + public function testCareContextsDiscoveryDraftStatus(): void + { + $db = \Config\Database::connect(); + // Insert incomplete lab request (status = 0 and empty findings) + $db->table('lab_request')->insert([ + 'id' => 99, + 'patient_id' => 12, + 'lab_type' => 1, + 'charge_id' => 0, + 'report_name' => 'Draft Blood Test', + 'Report_Data' => '', + 'report_data_Impression' => '', + 'status' => 0, + 'Request_Date' => '2026-09-29', + ]); + + $reflector = new ReflectionClass($this->gateway); + $method = $reflector->getMethod('findCareContextsForPatient'); + $method->setAccessible(true); + + [$v3List, $fullList] = $method->invoke( + $this->gateway, + 12, + 'P-12', + 'KESHAV SINGH' + ); + + $draftCtx = null; + foreach ($fullList as $ctx) { + if (str_starts_with($ctx['careContextId'], 'LAB-99-')) { + $draftCtx = $ctx; + break; + } + } + + $this->assertNotNull($draftCtx); + $this->assertFalse($draftCtx['is_fhir_ready'], 'Incomplete lab report should not be marked FHIR ready'); + } + + public function testHipPatientCareContextsEndpoint(): void + { + $_SERVER['HTTP_X_REQUESTED_WITH'] = 'xmlhttprequest'; + $_REQUEST['patient_id'] = '12'; + $_REQUEST['abha_address'] = '91747451787143'; + $_REQUEST['task_type'] = 'radiology_report_publish'; + $_REQUEST['entity_id'] = '31'; + $_REQUEST['task_id'] = '591'; + + $req = service('request'); + $req->setHeader('X-Requested-With', 'XMLHttpRequest'); + $this->gateway->initController($req, service('response'), service('logger')); + + $res = $this->gateway->hipPatientCareContexts(); + $body = json_decode($res->getBody(), true); + + $this->assertSame(1, $body['ok'] ?? 0); + $this->assertNotEmpty($body['care_contexts'] ?? []); + } + + public function testCareContextsDiscoveryWithIpdAndImmunization(): void + { + $db = \Config\Database::connect(); + $db->table('ipd_master')->insert([ + 'id' => 55, + 'p_id' => 12, + 'discharge_date' => '2026-09-20', + 'register_date' => '2026-09-15', + 'ipd_status' => 1, + ]); + $db->table('immunization_records')->insert([ + 'id' => 77, + 'patient_id' => 12, + 'vaccine_name' => 'Covaxin', + 'given_date' => '2026-09-18', + 'abdm_care_context_reference' => 'IMM-77-20260918', + ]); + + $reflector = new ReflectionClass($this->gateway); + $method = $reflector->getMethod('findCareContextsForPatient'); + $method->setAccessible(true); + + [$v3List, $fullList] = $method->invoke( + $this->gateway, + 12, + 'P-12', + 'KESHAV SINGH' + ); + + $foundIpd = false; + $foundImm = false; + foreach ($fullList as $ctx) { + if ($ctx['record_type'] === 'DischargeSummaryRecord') { + $foundIpd = true; + $this->assertStringContainsString('DISCHARGE-55-', $ctx['careContextId']); + } + if ($ctx['record_type'] === 'ImmunizationRecord') { + $foundImm = true; + $this->assertSame('IMM-77-20260918', $ctx['careContextId']); + } + } + + $this->assertTrue($foundIpd, 'IPD DischargeSummaryRecord should be discovered'); + $this->assertTrue($foundImm, 'ImmunizationRecord should be discovered'); + } +} diff --git a/tests/unit/Abdm/EAtriaBridgeConnectorLinkTest.php b/tests/unit/Abdm/EAtriaBridgeConnectorLinkTest.php new file mode 100644 index 0000000..4f363a9 --- /dev/null +++ b/tests/unit/Abdm/EAtriaBridgeConnectorLinkTest.php @@ -0,0 +1,79 @@ +getMockBuilder(EAtriaBridgeConnector::class) + ->disableOriginalConstructor() + ->onlyMethods(['post']) + ->getMock(); + + // 1st call returns token pending error, 2nd call returns success + $connector->expects($this->exactly(2)) + ->method('post') + ->willReturnOnConsecutiveCalls( + [ + 'ok' => 0, + 'http_code' => 400, + 'error' => 'link_token or link_token_id is required (call /api/v3/hip/link-token first)', + ], + [ + 'ok' => 1, + 'http_code' => 202, + 'message' => 'Care-context link request sent to ABDM.', + ] + ); + + $payload = [ + 'abha_address' => 'user@sbx', + 'link_token_id' => 12345, + 'care_contexts' => [['ref' => 'CC-1', 'display' => 'Visit 1']], + ]; + + $refProp = new ReflectionProperty($connector, 'linkTokenRetryDelays'); + $refProp->setAccessible(true); + $refProp->setValue($connector, [0]); + + $res = $connector->hipLinkCareContext($payload); + + $this->assertSame(1, $res['ok']); + $this->assertSame(202, $res['http_code']); + $this->assertSame('Care-context link request sent to ABDM.', $res['message']); + } + + public function testHipLinkCareContextReturnsErrorAfterMaxRetries(): void + { + $connector = $this->getMockBuilder(EAtriaBridgeConnector::class) + ->disableOriginalConstructor() + ->onlyMethods(['post']) + ->getMock(); + + $refProp = new ReflectionProperty($connector, 'linkTokenRetryDelays'); + $refProp->setAccessible(true); + $refProp->setValue($connector, [0, 0, 0]); + + // All 4 attempts return pending error + $connector->expects($this->exactly(4)) + ->method('post') + ->willReturn([ + 'ok' => 0, + 'http_code' => 400, + 'error' => 'link_token or link_token_id is required (call /api/v3/hip/link-token first)', + ]); + + $payload = [ + 'abha_address' => 'user@sbx', + 'link_token_id' => 99999, + 'care_contexts' => [['ref' => 'CC-1', 'display' => 'Visit 1']], + ]; + + $res = $connector->hipLinkCareContext($payload); + + $this->assertSame(0, $res['ok']); + $this->assertStringContainsString('ABDM authorization token callback is still pending', $res['error_text']); + } +}