feat: Add Submit to ABDM button on FHIR bundle preview

- POST Opd_prescription/fhir_bundle_submit endpoint:
  loads stored FHIR bundle, fetches patient/OPD/doctor context,
  calls EAtriaBridgeConnector::pushRecord to push to bridge
- Standalone preview page (/Opd_prescription/fhir_bundle_preview/N):
  added Submit to ABDM Bridge button with CSRF-safe AJAX + inline
  success/error alert
- FHIR modal in task_board.php: added Submit to ABDM button next
  to Copy JSON; JS reads _fhirOpdId/_fhirSessionId and posts to
  fhir_bundle_submit with CSRF token refresh
This commit is contained in:
DevSoftTech Server 2026-05-22 15:54:13 +05:30
parent 6d851f0e9e
commit 1b6f2c2db7
3 changed files with 281 additions and 8 deletions

View File

@ -590,6 +590,7 @@ $routes->get('Opd_prescription/fhir_bundle_history/(:num)', 'Opd_prescription::f
$routes->get('Opd_prescription/fhir_bundle_history/(:num)/(:num)', 'Opd_prescription::fhir_bundle_history/$1/$2');
$routes->get('Opd_prescription/fhir_bundle_preview/(:num)', 'Opd_prescription::fhir_bundle_preview/$1');
$routes->get('Opd_prescription/fhir_bundle_preview/(:num)/(:num)', 'Opd_prescription::fhir_bundle_preview/$1/$2');
$routes->post('Opd_prescription/fhir_bundle_submit', 'Opd_prescription::fhir_bundle_submit');
$routes->post('Opd_prescription/fhir_complaint_recode', 'Opd_prescription::fhir_complaint_recode');
$routes->post('Opd_prescription/fhir_diagnosis_recode', 'Opd_prescription::fhir_diagnosis_recode');
$routes->get('Opd_prescription/complaints_search', 'Opd_prescription::complaints_search');
@ -667,6 +668,7 @@ $routes->get('Opd_prescription/opd_medicince_data', 'Opd_prescription::opd_medic
$routes->get('Opd_prescription/opd_medicince_export', 'Opd_prescription::opd_medicince_export');
$routes->get('Opd_prescription/opd_medicince_company_suggest', 'Opd_prescription::opd_medicince_company_suggest');
$routes->get('Opd_prescription/opd_medicince_get/(:num)', 'Opd_prescription::opd_medicince_get/$1');
$routes->get('Opd_prescription/opd_medicince_snomed_lookup', 'Opd_prescription::opd_medicince_snomed_lookup');
$routes->post('Opd_prescription/opd_medicince_save', 'Opd_prescription::opd_medicince_save');
$routes->post('Opd_prescription/opd_medicince_ai_details', 'Opd_prescription::opd_medicince_ai_details');
$routes->post('Opd_prescription/opd_medicince_remove/(:num)', 'Opd_prescription::opd_medicince_remove/$1');

View File

@ -2670,6 +2670,68 @@ class Opd_prescription extends BaseController
return $this->response->setJSON(['update' => 1, 'row' => $row]);
}
/**
* SNOMED CT substance lookup for the medicine master form.
* Searches CSNOtk (substance/product semantic tags) for the given drug name term.
* Returns a ranked list of SNOMED CT concepts to pick from.
*
* GET /Opd_prescription/opd_medicince_snomed_lookup?term=amoxicillin&limit=10
*/
public function opd_medicince_snomed_lookup()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'rows' => []]);
}
$term = trim((string) $this->request->getGet('term'));
$limit = max(1, min(20, (int) ($this->request->getGet('limit') ?: 10)));
if ($term === '') {
return $this->response->setJSON(['update' => 1, 'rows' => [], 'message' => 'No term provided']);
}
try {
$svc = new \App\Libraries\CsnotkTerminologyService();
$rows = $svc->searchSubstance($term, $limit);
} catch (\Throwable $e) {
return $this->response->setJSON(['update' => 0, 'rows' => [], 'message' => 'SNOMED service error: ' . $e->getMessage()]);
}
// Normalise each row: extract clean display name + substance/product hint
$out = [];
foreach ($rows as $r) {
$conceptId = trim((string) ($r['concept_id'] ?? ''));
$fsn = trim((string) ($r['fsn'] ?? ''));
$term_ = trim((string) ($r['term'] ?? $fsn));
$hierarchy = trim((string) ($r['hierarchy'] ?? ''));
$source = trim((string) ($r['source'] ?? 'local'));
if ($conceptId === '' || $term_ === '') {
continue;
}
// Derive a clean INN-like name (term without parenthetical tag)
$displayTerm = $fsn !== '' ? preg_replace('/\s*\([^)]+\)\s*$/', '', $fsn) : $term_;
$displayTerm = trim((string) $displayTerm);
$out[] = [
'concept_id' => $conceptId,
'term' => $term_,
'fsn' => $fsn,
'display_term' => $displayTerm ?: $term_,
'hierarchy' => $hierarchy,
'source' => $source,
];
}
return $this->response->setJSON([
'update' => 1,
'rows' => $out,
'count' => count($out),
'message' => count($out) === 0 ? 'No SNOMED CT substance concepts found for "' . $term . '"' : '',
]);
}
public function opd_medicince_save()
{
if (! $this->request->isAJAX()) {
@ -4658,26 +4720,199 @@ class Opd_prescription extends BaseController
$bundleType = htmlspecialchars((string) ($row['bundle_type'] ?? ''), ENT_QUOTES, 'UTF-8');
$entryCount = isset($bundle['entry']) && is_array($bundle['entry']) ? count($bundle['entry']) : 0;
$csrfTokName = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
$csrfTokHash = htmlspecialchars(csrf_hash(), ENT_QUOTES, 'UTF-8');
$submitUrl = base_url('Opd_prescription/fhir_bundle_submit');
$html = '<!DOCTYPE html><html lang="en"><head>'
. '<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
. '<meta name="csrf-token-name" content="' . $csrfTokName . '">'
. '<meta name="csrf-token-hash" content="' . $csrfTokHash . '">'
. '<title>FHIR Bundle Preview &mdash; OPD #' . $opdId . '</title>'
. '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">'
. '<style>pre{background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem;font-size:.8rem;max-height:80vh;overflow:auto}</style>'
. '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">'
. '<style>pre{background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem;font-size:.8rem;max-height:75vh;overflow:auto}</style>'
. '</head><body class="p-3"><div class="container-fluid">'
. '<div class="d-flex align-items-center justify-content-between mb-3">'
. '<h5 class="mb-0">FHIR Bundle Preview</h5>'
. '<div><span class="badge bg-secondary me-1">' . $bundleType . '</span>'
. '<span class="badge bg-info text-dark me-1">' . $entryCount . ' entries</span>'
. '<span class="text-muted small">Generated: ' . $generatedAt
. ' &nbsp;|&nbsp; Doc #' . $docId . ' &nbsp;|&nbsp; OPD #' . $opdId . ' / Session #' . $sessionId . '</span>'
. '<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">'
. '<h5 class="mb-0">FHIR Bundle Preview &mdash; OPD #' . $opdId . '</h5>'
. '<div class="d-flex align-items-center gap-2 flex-wrap">'
. '<span class="badge bg-secondary">' . $bundleType . '</span>'
. '<span class="badge bg-info text-dark">' . $entryCount . ' entries</span>'
. '<span class="text-muted small">Generated: ' . $generatedAt . ' | Doc #' . $docId . '</span>'
. '<button class="btn btn-sm btn-success" id="btnSubmitAbdm"><i class="bi bi-cloud-upload"></i> Submit to ABDM Bridge</button>'
. '</div></div>'
. '<div id="submitResult"></div>'
. '<pre id="bj"></pre>'
. '</div><script>document.getElementById("bj").textContent=' . $prettyJsonJs . ';</script>'
. '</div>'
. '<script>'
. 'document.getElementById("bj").textContent=' . $prettyJsonJs . ';'
. 'document.getElementById("btnSubmitAbdm").addEventListener("click",function(){'
. ' var btn=this,origHtml=btn.innerHTML;'
. ' if(!confirm("Submit FHIR bundle for OPD #' . $opdId . ' to ABDM bridge?"))return;'
. ' btn.disabled=true;btn.textContent="Submitting\u2026";'
. ' var csrfN=document.querySelector("meta[name=\'csrf-token-name\']").content;'
. ' var csrfH=document.querySelector("meta[name=\'csrf-token-hash\']").content;'
. ' var body=new URLSearchParams({opd_id:' . $opdId . ',opd_session_id:' . $sessionId . '});'
. ' body.append(csrfN,csrfH);'
. ' fetch("' . $submitUrl . '",{method:"POST",headers:{"X-Requested-With":"XMLHttpRequest","Content-Type":"application/x-www-form-urlencoded"},body:body.toString()})'
. ' .then(function(r){return r.json();})'
. ' .then(function(res){'
. ' if(res.ok==1){'
. ' btn.className="btn btn-sm btn-success";btn.innerHTML=\'<i class="bi bi-check-circle"></i> Submitted \u2713\';'
. ' var qInfo=res.queue_id?" | Queue ID: "+res.queue_id:"";'
. ' document.getElementById("submitResult").innerHTML=\'<div class="alert alert-success mt-2">\u2705 \''
. ' +(res.message||"Bundle submitted to ABDM bridge successfully.")+qInfo+\'</div>\';'
. ' }else{'
. ' btn.disabled=false;btn.innerHTML=origHtml;'
. ' document.getElementById("submitResult").innerHTML=\'<div class="alert alert-danger mt-2">\u274c \''
. ' +(res.message||res.error_text||"Submission failed.")+(res.http_code?" (HTTP "+res.http_code+")" :"")+\'</div>\';'
. ' }'
. ' })'
. ' .catch(function(e){btn.disabled=false;btn.innerHTML=origHtml;'
. ' document.getElementById("submitResult").innerHTML=\'<div class="alert alert-danger mt-2">\u274c Error: \'+e.message+\'</div>\';});'
. '});'
. '</script>'
. '</body></html>';
return $this->response->setHeader('Content-Type', 'text/html; charset=UTF-8')->setBody($html);
}
public function fhir_bundle_submit()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'AJAX only']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
if ($opdId <= 0) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'OPD ID required', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash()]);
}
if (! $this->db->tableExists('opd_fhir_documents')) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'FHIR bundle storage not available', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash()]);
}
$builder = $this->db->table('opd_fhir_documents')
->where('opd_id', $opdId)
->where('bundle_type', 'MedicationRequestBundle');
if ($sessionId > 0) {
$builder->where('opd_session_id', $sessionId);
}
$bundleRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
if (empty($bundleRow)) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'No FHIR bundle found. Save the prescription first.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$bundleJson = (string) ($bundleRow['bundle_json'] ?? '{}');
$bundle = json_decode($bundleJson, true);
if (! is_array($bundle)) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Stored FHIR bundle JSON is invalid.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
// Gather patient / OPD context for the push payload
$patientId = 0;
$patientName = '';
$abhaId = '';
$doctorName = '';
$visitDate = date('Y-m-d');
if ($this->db->tableExists('opd_master')) {
$opdRow = $this->db->table('opd_master')->where('opd_id', $opdId)->get(1)->getRowArray();
if (! empty($opdRow)) {
$patientId = (int) ($opdRow['p_id'] ?? 0);
$rawDate = (string) ($opdRow['opd_date'] ?? '');
if ($rawDate !== '') {
$ts = strtotime($rawDate);
if ($ts !== false) {
$visitDate = date('Y-m-d', $ts);
}
}
$docId2 = (int) ($opdRow['doc_id'] ?? 0);
if ($docId2 > 0 && $this->db->tableExists('doctor_master')) {
$dRow = $this->db->table('doctor_master')->where('id', $docId2)->get(1)->getRowArray();
if (! empty($dRow)) {
$doctorName = trim((string) ($dRow['p_fname'] ?? ''));
}
}
}
}
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$pFields = $this->db->getFieldNames('patient_master');
$pRow = $this->db->table('patient_master')->where('p_id', $patientId)->get(1)->getRowArray();
if (! empty($pRow)) {
$fname = trim((string) ($pRow['p_fname'] ?? ''));
$lname = trim((string) ($pRow['p_lname'] ?? ''));
$patientName = trim($fname . ' ' . $lname);
foreach (['abha_number', 'abha_id', 'abha_address'] as $abhaField) {
if (in_array($abhaField, $pFields, true)) {
$val = trim((string) ($pRow[$abhaField] ?? ''));
if ($val !== '') {
$abhaId = $val;
break;
}
}
}
}
}
try {
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
$pushData = [
'patient_id' => (string) $patientId,
'patient_name' => $patientName,
'hi_type' => 'OPConsultRecord',
'visit_date' => $visitDate,
'care_context_reference' => 'OPD-' . $opdId,
'record_data' => $bundle,
];
if ($abhaId !== '') {
$pushData['abha_address'] = $abhaId;
}
if ($doctorName !== '') {
$pushData['doctor_name'] = $doctorName;
}
$result = $connector->pushRecord($pushData);
$ok = (int) ($result['ok'] ?? 0);
$queueId = (string) ($result['queue_id'] ?? $result['record_id'] ?? $result['id'] ?? '');
$msg = (string) ($result['message'] ?? ($ok === 1
? 'FHIR bundle submitted to ABDM bridge successfully.'
: ($result['error_text'] ?? 'Submission failed. Check bridge configuration.')));
return $this->response->setJSON([
'ok' => $ok,
'queue_id' => $queueId,
'message' => $msg,
'http_code' => (int) ($result['http_code'] ?? 0),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
} catch (\Throwable $e) {
log_message('error', '[fhir_bundle_submit] OPD#' . $opdId . ': ' . $e->getMessage());
return $this->response->setJSON([
'ok' => 0,
'message' => 'Bridge connection failed: ' . $e->getMessage(),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
public function fhir_complaint_recode()
{
if (! $this->request->isAJAX()) {

View File

@ -316,6 +316,7 @@
</li>
</ul>
</div>
<button type="button" class="btn btn-sm btn-outline-success" id="btnSubmitFhirToAbdm" title="Push FHIR bundle to ABDM bridge"><i class="bi bi-cloud-upload"></i> Submit to ABDM</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnCopyFhirModal">Copy JSON</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
@ -1600,6 +1601,41 @@
});
})();
document.getElementById('btnSubmitFhirToAbdm').addEventListener('click', function () {
var btn = this;
if (_fhirOpdId <= 0) { alert('No FHIR bundle loaded.'); return; }
if (!confirm('Submit FHIR bundle for OPD #' + _fhirOpdId + ' to ABDM bridge?')) return;
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.textContent = 'Submitting…';
var body = new URLSearchParams({ opd_id: _fhirOpdId, opd_session_id: _fhirSessionId });
body.append(csrfName, csrfHash);
fetch('<?= base_url('Opd_prescription/fhir_bundle_submit') ?>', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.csrfName && res.csrfHash) { csrfName = res.csrfName; csrfHash = res.csrfHash; }
if (res.ok == 1) {
btn.className = 'btn btn-sm btn-success';
btn.innerHTML = '<i class="bi bi-check-circle"></i> Submitted ✓';
var qInfo = res.queue_id ? ' Queue ID: ' + res.queue_id : '';
alert('✅ Bundle submitted to ABDM bridge.\n' + (res.message || '') + qInfo);
} else {
btn.disabled = false;
btn.innerHTML = origHtml;
alert('❌ Submission failed:\n' + (res.message || res.error_text || 'Check bridge configuration.') + (res.http_code ? ' (HTTP ' + res.http_code + ')' : ''));
}
})
.catch(function (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
alert('❌ Error: ' + e.message);
});
});
document.getElementById('btnCopyFhirModal').addEventListener('click', function () {
var btn = this;
var jsonText = fhirModalJson ? (fhirModalJson.textContent || '{}') : '{}';