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
This commit is contained in:
DevSoftTech Server 2026-09-29 14:02:22 +05:30
parent 61206784f3
commit 41d89a1969
10 changed files with 1211 additions and 109 deletions

View File

@ -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),
];
}

View File

@ -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']

View File

@ -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<int, string> */
private array $tokenCandidates = [];
/** @var array<string, string> */
private array $tokenSourceByValue = [];
private string $tokenSource = 'config';
/** @var array<int, float> */
protected array $linkTokenRetryDelays = [1.5, 2.0, 2.5];
public function __construct()
{
@ -259,12 +261,12 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
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<string, string> $extraHeaders
* @return array<string, mixed>
*/
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

View File

@ -135,10 +135,10 @@
</div>
<hr/>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label>Template Action</label>
<button id="updatereport" type="button" class="btn btn-primary">Update</button>
<div class="col-md-12">
<div class="form-group d-flex align-items-center gap-2">
<button id="updatereport" type="button" class="btn btn-primary"><?= ((int) ($repo_id ?? 0) > 0) ? 'Update Template' : 'Save Template' ?></button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" onclick="if(typeof window.closeRadiologyActionModal==='function'){window.closeRadiologyActionModal();}">Cancel</button>
</div>
</div>
</div>
@ -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="<?= csrf_token() ?>"]').first().val() || '<?= csrf_hash() ?>';
var modality = <?= (int) ($modality ?? 2) ?>;
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('<?= base_url('Lab_Admin/report_ultrasound_list') ?>/' + modality, 'Diagnosis Template');
} else {
window.location.reload();
}
});
return;
}
if (typeof window.cleanupRadiologyModalBackdrop === 'function') {
window.cleanupRadiologyModalBackdrop();
}
if (typeof load_form === 'function') {
load_form('<?= base_url('Lab_Admin/report_ultrasound_list') ?>/' + modality, 'Diagnosis Template');
} else {
window.location.reload();
}
};
if (repo_id > 0) {
$.post('<?= base_url('Lab_Admin/report_ultrasound_update') ?>/' + modality, {
"repo_id": repo_id,
@ -200,15 +243,25 @@
"Impression": Impression,
"<?= csrf_token() ?>": 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('<?= base_url('Lab_Admin/report_ultrasound_insert') ?>/' + modality, {
"repo_id": repo_id,
@ -221,14 +274,25 @@
"Impression": Impression,
"<?= csrf_token() ?>": csrf_value
}, function(data) {
if (data.insertid > 0) {
if (typeof load_form === 'function') {
load_form('<?= base_url('Lab_Admin/report_ultrasound_list') ?>/' + modality, 'Diagnosis Template');
} else {
window.location.href = '<?= base_url('Lab_Admin/report_ultrasound_list') ?>/' + 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.');
}
});
}
});
</script>

View File

@ -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 = '<div class="text-muted">Loading...</div>';
}
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 = '<div class="text-muted">Loading...</div>';
}
cleanupRadiologyModalBackdrop();
});
}
window.refreshRadiologyTemplateList = function() {
if (typeof load_form === 'function') {
load_form('<?= base_url('Lab_Admin/report_ultrasound_list') ?>/<?= $selectedModality ?>', 'Diagnosis Template');
return;
}
window.location.reload();
closeRadiologyActionModal(function() {
if (typeof load_form === 'function') {
load_form('<?= base_url('Lab_Admin/report_ultrasound_list') ?>/<?= $selectedModality ?>', 'Diagnosis Template');
return;
}
window.location.reload();
});
};
})();
</script>

View File

@ -164,8 +164,13 @@
<div class="text-muted small"><?= esc((string) ($t['entity_id'] ?? '')) ?></div>
</td>
<td>
<?php if ($bridgeSubmitted && strtolower((string) ($t['bridge_push_status'] ?? '')) === 'linked'): ?>
<?php
$rawTaskStatus = strtolower(trim((string) ($t['status'] ?? 'pending')));
$isTaskLinked = in_array($rawTaskStatus, ['completed', 'linked'], true) || ($bridgeSubmitted && strtolower((string) ($t['bridge_push_status'] ?? '')) === 'linked');
?>
<?php if ($isTaskLinked): ?>
<span class="badge bg-success status-pill">LINKED</span>
<?php if ($bridgeCareContext !== ''): ?><div class="small"><code><?= esc($bridgeCareContext) ?></code></div><?php endif; ?>
<?php elseif ($bridgeSubmitted): ?>
<span class="badge bg-info status-pill">BRIDGE SUBMITTED</span>
<?php if ($bridgeCareContext !== ''): ?><div class="small"><code><?= esc($bridgeCareContext) ?></code></div><?php endif; ?>
@ -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 = '<div class="small"><code>' + (res.care_contexts[0].ref || '') + '</code></div>';
}
statusCell.innerHTML = '<span class="badge bg-success status-pill">LINKED</span>' + 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
});
}
});
});

View File

@ -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 = '<i class="bi bi-star-fill me-1"></i>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 = '<i class="bi bi-check-circle me-1"></i>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 = '<i class="bi bi-clock me-1"></i>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 = '<i class="bi bi-check2-circle me-1"></i>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 = '<?= base_url('AbdmGateway/hip_patient_care_contexts') ?>'
+ '?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 = '<div class="alert alert-warning py-2 px-3 small mb-0"><i class="bi bi-exclamation-triangle me-1"></i>' + errTxt + '</div>';
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 = '<div class="text-muted small p-3 text-center" id="hipCareContextsEmpty">No clinical visit records found for this patient. Click <b>Custom</b> to add a test context.</div>';
}
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 = '<strong><i class="bi bi-info-circle-fill me-1"></i>Already Linked:</strong> The record for this task (<code>' + currentTaskLinkedRef + '</code>) 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('<?= base_url('AbdmTaskBoard/mark_status') ?>', {
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 = '<strong><i class="bi bi-check-circle-fill me-1"></i>Already Linked!</strong> '
+ '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('<?= base_url('AbdmTaskBoard/mark_status') ?>', {
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 = '<strong>Step 2/2:</strong> Link token acquired (ID: ' + linkTokenId + '). Linking ' + selectedContexts.length + ' care context(s)...';
var isReused = tokenData.status === 'reused';
alertBox.innerHTML = '<strong>Step 2/2:</strong> ' + (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 = '<strong><i class="bi bi-check-circle-fill me-1"></i>Success!</strong> '
+ '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('<?= base_url('AbdmTaskBoard/mark_status') ?>', {
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 = '<strong><i class="bi bi-check-circle-fill me-1"></i>Already Linked!</strong> '
+ '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('<?= base_url('AbdmTaskBoard/mark_status') ?>', {
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) {

View File

@ -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);

View File

@ -0,0 +1,283 @@
<?php
namespace Tests\Unit\Abdm;
use App\Controllers\AbdmGateway;
use CodeIgniter\Test\CIUnitTestCase;
use ReflectionClass;
final class CareContextDiscoveryTest extends CIUnitTestCase
{
private AbdmGateway $gateway;
protected function setUp(): void
{
parent::setUp();
$db = \Config\Database::connect();
$forge = \Config\Database::forge();
$db->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');
}
}

View File

@ -0,0 +1,79 @@
<?php
use App\Libraries\Abdm\EAtriaBridgeConnector;
use CodeIgniter\Test\CIUnitTestCase;
final class EAtriaBridgeConnectorLinkTest extends CIUnitTestCase
{
public function testHipLinkCareContextRetriesWhenTokenPending(): void
{
$connector = $this->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']);
}
}