feat: FHIR form view — inline complaint SNOMED edit with live search and save
This commit is contained in:
parent
a0989d3a4b
commit
4250000d43
|
|
@ -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_complaint_recode', 'Opd_prescription::fhir_complaint_recode');
|
||||
$routes->get('Opd_prescription/complaints_search', 'Opd_prescription::complaints_search');
|
||||
$routes->get('Opd_prescription/complaints_extract', 'Opd_prescription::complaints_extract');
|
||||
$routes->get('Opd_prescription/section_recent_entries', 'Opd_prescription::section_recent_entries');
|
||||
|
|
|
|||
|
|
@ -4640,7 +4640,93 @@ class Opd_prescription extends BaseController
|
|||
return $this->response->setHeader('Content-Type', 'text/html; charset=UTF-8')->setBody($html);
|
||||
}
|
||||
|
||||
public function advice_search()
|
||||
public function fhir_complaint_recode()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
|
||||
}
|
||||
|
||||
$opdId = (int) $this->request->getPost('opd_id');
|
||||
$sessionId = (int) $this->request->getPost('opd_session_id');
|
||||
$complaintText = trim((string) $this->request->getPost('complaint_text'));
|
||||
$snomedId = trim((string) $this->request->getPost('snomed_concept_id'));
|
||||
$snomedTerm = trim((string) $this->request->getPost('snomed_term'));
|
||||
|
||||
if ($opdId <= 0 || $complaintText === '') {
|
||||
return $this->response->setJSON(['update' => 0, 'error_text' => 'Missing required fields']);
|
||||
}
|
||||
if (! $this->db->tableExists('opd_prescription')) {
|
||||
return $this->response->setJSON(['update' => 0, 'error_text' => 'Table not found']);
|
||||
}
|
||||
|
||||
// Resolve the prescription row
|
||||
$prescRow = null;
|
||||
if ($sessionId > 0) {
|
||||
$prescRow = $this->db->table('opd_prescription')
|
||||
->where('id', $sessionId)->where('opd_id', $opdId)->get(1)->getRowArray();
|
||||
}
|
||||
if (empty($prescRow)) {
|
||||
$prescRow = $this->db->table('opd_prescription')
|
||||
->where('opd_id', $opdId)->orderBy('id', 'DESC')->get(1)->getRowArray();
|
||||
}
|
||||
if (empty($prescRow)) {
|
||||
return $this->response->setJSON(['update' => 0, 'error_text' => 'Prescription record not found']);
|
||||
}
|
||||
|
||||
$prescId = (int) $prescRow['id'];
|
||||
$snomedJson = trim((string) ($prescRow['complaint_snomed_json'] ?? ''));
|
||||
$items = [];
|
||||
if ($snomedJson !== '' && $snomedJson !== '[]') {
|
||||
$decoded = json_decode($snomedJson, true);
|
||||
if (is_array($decoded)) {
|
||||
$items = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap from free-text if no structured items yet
|
||||
if (empty($items)) {
|
||||
$parts = preg_split('/[,;\n\r]+/', (string) ($prescRow['complaints'] ?? '')) ?: [];
|
||||
foreach ($parts as $part) {
|
||||
$part = trim((string) $part);
|
||||
if ($part !== '' && preg_match('/^Complaint\s*(Onset|Duration|Severity)\s*:/i', $part) !== 1) {
|
||||
$items[] = ['term' => $part, 'concept_id' => '', 'source' => 'local',
|
||||
'hierarchy' => '', 'severity' => '', 'duration' => '', 'frequency' => '', 'date' => ''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find and update matching complaint (case-insensitive)
|
||||
$found = false;
|
||||
foreach ($items as &$item) {
|
||||
if (strtolower(trim((string) ($item['term'] ?? ''))) === strtolower($complaintText)) {
|
||||
$item['concept_id'] = $snomedId;
|
||||
$item['term'] = $snomedTerm ?: $complaintText;
|
||||
$item['source'] = $snomedId !== '' ? 'snomed' : ($item['source'] ?? 'local');
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
if (! $found) {
|
||||
$items[] = ['term' => $snomedTerm ?: $complaintText, 'concept_id' => $snomedId,
|
||||
'source' => $snomedId !== '' ? 'snomed' : 'local',
|
||||
'hierarchy' => '', 'severity' => '', 'duration' => '', 'frequency' => '', 'date' => ''];
|
||||
}
|
||||
|
||||
$newJson = (string) json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
|
||||
$this->db->table('opd_prescription')->where('id', $prescId)->update(['complaint_snomed_json' => $newJson]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'update' => 1,
|
||||
'error_text' => 'Saved',
|
||||
'presc_id' => $prescId,
|
||||
'snomed_concept_id'=> $snomedId,
|
||||
'snomed_term' => $snomedTerm,
|
||||
'csrfName' => csrf_token(),
|
||||
'csrfHash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
{
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
if (! $this->db->tableExists('opd_advice')) {
|
||||
|
|
|
|||
|
|
@ -988,6 +988,134 @@
|
|||
fhirFormView.style.display = 'none';
|
||||
});
|
||||
|
||||
// ── Track current bundle context for edits ──────────────────────
|
||||
var _fhirOpdId = 0, _fhirSessionId = 0;
|
||||
|
||||
// ── Inline complaint SNOMED edit (event delegation on fhirFormView) ─
|
||||
var _editTimer = null;
|
||||
fhirFormView.addEventListener('click', function(e) {
|
||||
var editBtn = e.target.closest('.fhir-complaint-edit');
|
||||
var cancelBtn = e.target.closest('.fhir-complaint-cancel');
|
||||
var saveBtn = e.target.closest('.fhir-complaint-save');
|
||||
var ddItem = e.target.closest('.fhir-snomed-dd-item');
|
||||
|
||||
if (editBtn) {
|
||||
var panel = fhirFormView.querySelector('.fhir-edit-panel[data-idx="' + editBtn.dataset.idx + '"]');
|
||||
if (!panel) return;
|
||||
// Close others
|
||||
fhirFormView.querySelectorAll('.fhir-edit-panel').forEach(function(p) { if (p !== panel) p.style.display = 'none'; });
|
||||
panel.style.display = panel.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
if (cancelBtn) {
|
||||
cancelBtn.closest('.fhir-edit-panel').style.display = 'none';
|
||||
}
|
||||
if (ddItem) {
|
||||
var panel2 = ddItem.closest('.fhir-edit-panel');
|
||||
panel2.querySelector('.fhir-snomed-id').value = ddItem.dataset.id || '';
|
||||
panel2.querySelector('.fhir-snomed-term').value = ddItem.dataset.term || '';
|
||||
panel2.querySelector('.fhir-snomed-search').value = ddItem.dataset.term || '';
|
||||
panel2.querySelector('.fhir-snomed-dd').style.display = 'none';
|
||||
var sel = panel2.querySelector('.fhir-snomed-selected');
|
||||
sel.textContent = '✓ ' + (ddItem.dataset.id || '') + ' — ' + (ddItem.dataset.term || '');
|
||||
sel.style.display = '';
|
||||
}
|
||||
if (saveBtn) {
|
||||
var panel3 = saveBtn.closest('.fhir-edit-panel');
|
||||
var sId = panel3.querySelector('.fhir-snomed-id').value.trim();
|
||||
var sTerm = panel3.querySelector('.fhir-snomed-term').value.trim();
|
||||
var cText = panel3.dataset.complaintText || '';
|
||||
if (!sId && !sTerm) {
|
||||
// Try the search field text as the term if nothing selected
|
||||
sTerm = panel3.querySelector('.fhir-snomed-search').value.trim();
|
||||
}
|
||||
var csrfN = document.querySelector('meta[name="csrf-token-name"]');
|
||||
var csrfH = document.querySelector('meta[name="csrf-token-hash"]');
|
||||
var body = new URLSearchParams({
|
||||
opd_id: _fhirOpdId,
|
||||
opd_session_id: _fhirSessionId,
|
||||
complaint_text: cText,
|
||||
snomed_concept_id: sId,
|
||||
snomed_term: sTerm
|
||||
});
|
||||
body.append(csrfName, csrfHash);
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
fetch('<?= base_url('Opd_prescription/fhir_complaint_recode') ?>', {
|
||||
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.update == 1) {
|
||||
// Update the CSRF token for next request
|
||||
if (res.csrfName && res.csrfHash) { csrfName = res.csrfName; csrfHash = res.csrfHash; }
|
||||
// Update the displayed complaint badge inline
|
||||
var li = panel3.closest('li');
|
||||
if (li && sId) {
|
||||
var existing = li.querySelector('.fhir-snomed-badge');
|
||||
if (existing) { existing.textContent = 'SNOMED\u00a0' + sId; }
|
||||
else {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-info text-dark ms-1 fhir-snomed-badge';
|
||||
badge.style.fontSize = '.7rem';
|
||||
badge.textContent = 'SNOMED\u00a0' + sId;
|
||||
var strong = li.querySelector('strong');
|
||||
if (strong) strong.insertAdjacentElement('afterend', badge);
|
||||
}
|
||||
}
|
||||
panel3.style.display = 'none';
|
||||
// Refresh quality badges
|
||||
} else {
|
||||
alert(res.error_text || 'Save failed');
|
||||
}
|
||||
})
|
||||
.catch(function(e){ alert('Error: ' + e.message); })
|
||||
.finally(function(){ saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
||||
}
|
||||
});
|
||||
fhirFormView.addEventListener('input', function(e) {
|
||||
var searchInput = e.target.closest('.fhir-snomed-search');
|
||||
if (!searchInput) return;
|
||||
var panel = searchInput.closest('.fhir-edit-panel');
|
||||
var dd = panel.querySelector('.fhir-snomed-dd');
|
||||
var q = searchInput.value.trim();
|
||||
if (q.length < 2) { dd.style.display = 'none'; return; }
|
||||
clearTimeout(_editTimer);
|
||||
_editTimer = setTimeout(function() {
|
||||
fetch('<?= base_url('Opd_prescription/complaints_search') ?>?q=' + encodeURIComponent(q), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(res){
|
||||
dd.innerHTML = '';
|
||||
var rows = (res.rows || res.results || []);
|
||||
if (!rows.length) { dd.style.display = 'none'; return; }
|
||||
rows.slice(0, 10).forEach(function(row) {
|
||||
var id = row.concept_id || row.snomed_concept_id || '';
|
||||
var name = row.name || row.term || row.snomed_term || '';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'list-group-item list-group-item-action py-1 px-2 fhir-snomed-dd-item';
|
||||
btn.dataset.id = id;
|
||||
btn.dataset.term = name;
|
||||
btn.innerHTML = '<small>' + hesc(name) + (id ? ' <span class="text-muted">' + hesc(id) + '</span>' : '') + '</small>';
|
||||
dd.appendChild(btn);
|
||||
});
|
||||
dd.style.display = '';
|
||||
})
|
||||
.catch(function(){ dd.style.display = 'none'; });
|
||||
}, 280);
|
||||
});
|
||||
fhirFormView.addEventListener('focusout', function(e) {
|
||||
if (!e.target.closest('.fhir-edit-panel')) return;
|
||||
// Delay hiding so click on dd item registers first
|
||||
setTimeout(function() {
|
||||
var dd = e.target.closest('.fhir-edit-panel');
|
||||
if (dd) { var d = dd.querySelector('.fhir-snomed-dd'); if (d) d.style.display = 'none'; }
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// ── Render FHIR bundle as human-readable form ──────────────────────
|
||||
function hesc(s) {
|
||||
return String(s || '')
|
||||
|
|
@ -1046,7 +1174,7 @@
|
|||
if (complaints.length) {
|
||||
html += '<div class="card mb-2"><div class="card-header py-1 bg-light"><small class="fw-bold text-uppercase text-secondary">Chief Complaints</small></div><div class="card-body py-2">';
|
||||
html += '<ul class="mb-0 ps-3">';
|
||||
complaints.forEach(function(c) {
|
||||
complaints.forEach(function(c, idx) {
|
||||
var codeText = (c.code || {}).text || '';
|
||||
var coding = (((c.code || {}).coding) || [])[0] || {};
|
||||
var snomed = coding.code || '';
|
||||
|
|
@ -1054,11 +1182,29 @@
|
|||
var sev = ((c.severity || {}).coding || [{}])[0];
|
||||
var sevText = (sev && sev.display) ? sev.display : ((c.severity || {}).text || '');
|
||||
var note = ((c.note || [{}])[0] || {}).text || '';
|
||||
html += '<li class="mb-1">';
|
||||
html += '<li class="mb-2">';
|
||||
html += '<div class="d-flex align-items-start gap-2">';
|
||||
html += '<div class="flex-grow-1">';
|
||||
html += '<strong>' + hesc(display || codeText) + '</strong>';
|
||||
if (snomed) html += ' <span class="badge bg-info text-dark" style="font-size:.7rem;">SNOMED ' + hesc(snomed) + '</span>';
|
||||
if (snomed) html += ' <span class="badge bg-info text-dark fhir-snomed-badge" style="font-size:.7rem;">SNOMED ' + hesc(snomed) + '</span>';
|
||||
if (sevText) html += ' <span class="badge bg-secondary" style="font-size:.7rem;">' + hesc(sevText) + '</span>';
|
||||
if (note) html += ' <small class="text-muted">(' + hesc(note) + ')</small>';
|
||||
html += '</div>';
|
||||
html += '<button type="button" class="btn btn-outline-secondary fhir-complaint-edit flex-shrink-0" data-idx="' + idx + '" style="font-size:.7rem;padding:1px 6px;">Edit</button>';
|
||||
html += '</div>';
|
||||
// inline edit panel
|
||||
html += '<div class="fhir-edit-panel border rounded p-2 mt-1 bg-light" data-idx="' + idx + '" data-complaint-text="' + hesc(display || codeText) + '" style="display:none;">';
|
||||
html += '<div class="mb-1 small text-muted">Search SNOMED CT and assign code:</div>';
|
||||
html += '<div class="position-relative mb-1">';
|
||||
html += '<input type="text" class="form-control form-control-sm fhir-snomed-search" placeholder="Type to search SNOMED…" value="' + hesc(display || codeText) + '">';
|
||||
html += '<div class="list-group shadow position-absolute w-100 fhir-snomed-dd" style="z-index:2000;display:none;max-height:180px;overflow-y:auto;"></div>';
|
||||
html += '</div>';
|
||||
html += '<div class="fhir-snomed-selected small text-success mb-1" style="display:none;"></div>';
|
||||
html += '<input type="hidden" class="fhir-snomed-id" value="' + hesc(snomed) + '">';
|
||||
html += '<input type="hidden" class="fhir-snomed-term" value="' + hesc(display || codeText) + '">';
|
||||
html += '<div class="d-flex gap-2"><button type="button" class="btn btn-sm btn-primary fhir-complaint-save">Save</button>';
|
||||
html += '<button type="button" class="btn btn-sm btn-outline-secondary fhir-complaint-cancel">Cancel</button></div>';
|
||||
html += '</div>';
|
||||
html += '</li>';
|
||||
});
|
||||
html += '</ul></div></div>';
|
||||
|
|
@ -1176,6 +1322,7 @@
|
|||
}
|
||||
|
||||
function openFhirModal(url, title) {
|
||||
_fhirOpdId = 0; _fhirSessionId = 0;
|
||||
if (fhirModalTitle) fhirModalTitle.textContent = title || 'FHIR Preview';
|
||||
if (fhirModalMeta) { fhirModalMeta.textContent = 'Loading...'; fhirModalMeta.className = 'flex-grow-1 small text-muted'; }
|
||||
if (fhirModalQuality) { fhirModalQuality.innerHTML = ''; fhirModalQuality.style.display = 'none'; }
|
||||
|
|
@ -1195,6 +1342,8 @@
|
|||
try { parsed = JSON.parse(res.body || '{}'); } catch (e) { parsed = { raw: res.body || '' }; }
|
||||
var bundle = (parsed && parsed.resourceType === 'Bundle') ? parsed : ((parsed && parsed.bundle) ? parsed.bundle : null);
|
||||
var hasFhir = bundle !== null;
|
||||
_fhirOpdId = parseInt(parsed.opd_id || '0', 10) || 0;
|
||||
_fhirSessionId = parseInt(parsed.opd_session_id || '0', 10) || 0;
|
||||
if (fhirModalJson) fhirModalJson.textContent = JSON.stringify(parsed, null, 2);
|
||||
if (fhirFormView) fhirFormView.innerHTML = hasFhir ? renderFhirBundleForm(bundle) : '<p class="text-muted p-3">No FHIR bundle generated yet.</p>';
|
||||
if (fhirModalMeta) { fhirModalMeta.textContent = res.ok ? 'Loaded (' + res.status + ')' : 'Error (' + res.status + ')'; fhirModalMeta.className = 'flex-grow-1 small ' + (res.ok ? 'text-success' : 'text-danger'); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user