Redesign Consent Request History table columns and add Fetch/Show Data modal with FHIR section accordion

This commit is contained in:
DevSoftTech Server 2026-07-29 03:16:24 +05:30
parent 7129f6e17b
commit 37dba1948e
3 changed files with 272 additions and 15 deletions

View File

@ -1168,12 +1168,17 @@ class Patient extends BaseController
}
$q = trim((string) ($this->request->getGet('q') ?? ''));
$filterConsentRequestId = trim((string) ($this->request->getGet('consent_request_id') ?? ''));
$includeSummary = ((int) ($this->request->getGet('include_summary') ?? 0)) === 1;
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
$selectCols = ['d.id', 'd.patient_id', 'd.abha_address', 'd.document_title', 'd.document_date', 'd.care_context_reference', 'd.practitioner_name', 'd.organization_name', 'd.bundle_type', 'd.created_at'];
if (in_array('consent_request_id', $docFields, true)) {
$selectCols[] = 'd.consent_request_id';
}
if ($includeSummary) {
$selectCols[] = 'd.summary_json';
}
$builder = $this->db->table('abdm_hiu_documents d')
->select(implode(', ', $selectCols))
@ -1188,6 +1193,10 @@ class Patient extends BaseController
}
$builder->groupEnd();
if ($filterConsentRequestId !== '' && in_array('consent_request_id', $docFields, true)) {
$builder->where('d.consent_request_id', $filterConsentRequestId);
}
if ($q !== '') {
$builder->groupStart()
->like('d.document_title', $q)
@ -1199,6 +1208,22 @@ class Patient extends BaseController
$rows = $builder->get()->getResultArray();
if ($includeSummary) {
foreach ($rows as &$rowRef) {
$summary = json_decode((string) ($rowRef['summary_json'] ?? ''), true);
if (! is_array($summary)) {
$summary = [];
}
// Attachments carry base64 image/PDF data — strip them from this
// list payload to keep it light; use abdm_document_detail for the
// full attachment content of a single document.
unset($summary['attachments']);
$rowRef['summary'] = $summary;
unset($rowRef['summary_json']);
}
unset($rowRef);
}
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
@ -1208,6 +1233,7 @@ class Patient extends BaseController
]);
}
public function abdm_document_detail(int $pno, int $docId)
{
if ($docId <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
@ -4027,6 +4053,17 @@ class Patient extends BaseController
}
}
// A short, human-friendly identifier for this session (the "Request ID"
// guid is too long to display in a table column) -- use the underlying
// abdm_hiu_workflows row id of this session's own CONSENT_REQUEST row
// (falling back to whichever row scored best) since it's already a
// small, unique, DB-backed integer.
$displayId = (int) (is_array($consentRequestRow) ? ($consentRequestRow['id'] ?? 0) : 0);
if ($displayId <= 0) {
$displayId = (int) ($best['id'] ?? 0);
}
$requestedHiTypes = [];
$requestedOn = '';
$purpose = '';
@ -4129,6 +4166,7 @@ class Patient extends BaseController
return [
'ok' => 1,
'consent' => [
'id' => $displayId,
'consent_id' => $consentId,
'consent_request_id' => $consentRequestId,
'abha_address' => $abhaAddress,

View File

@ -270,6 +270,10 @@ class M3HiuDocumentRepository
$conditions = [];
$vitals = [];
$medications = [];
// Indexed by "ResourceType/id" and by fullUrl, so Composition.section[].entry
// references (e.g. {"reference": "Condition/xyz"}) can be resolved back to
// their actual resource below.
$resourcesByRef = [];
foreach ($entries as $entry) {
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
@ -278,6 +282,15 @@ class M3HiuDocumentRepository
continue;
}
$resourceId = trim((string) ($resource['id'] ?? ''));
if ($resourceId !== '') {
$resourcesByRef[$type . '/' . $resourceId] = $resource;
}
$fullUrl = trim((string) ($entry['fullUrl'] ?? ''));
if ($fullUrl !== '') {
$resourcesByRef[$fullUrl] = $resource;
}
if ($type === 'Patient' && empty($patient)) {
$patient = $resource;
} elseif ($type === 'Composition' && empty($composition)) {
@ -336,9 +349,121 @@ class M3HiuDocumentRepository
'vitals' => $vitals,
'medications' => $medications,
'attachments' => $this->extractAttachments($bundle),
// Mirrors the Composition's own section breakdown (Chief Complaints,
// Physical Examination, Allergies, Investigations, Medications,
// Procedures, Medical History, Care Plan, Documents, etc.) so the UI
// can render an accordion per record exactly like the source document
// is organized, instead of a fixed hard-coded set of categories.
'sections' => $this->buildCompositionSections($composition, $resourcesByRef),
];
}
/**
* Resolves each Composition.section[].entry[] reference to its underlying
* resource and produces a short display label for it, falling back to the
* section's own narrative text (section.text.div) when there are no
* resolvable entries (some HIPs only populate the narrative).
*
* @return array<int, array{title: string, items: array<int, string>, narrative: string}>
*/
private function buildCompositionSections(array $composition, array $resourcesByRef): array
{
$sections = [];
foreach ((array) ($composition['section'] ?? []) as $section) {
if (! is_array($section)) {
continue;
}
$title = trim((string) (($section['title'] ?? '') ?: ($section['code']['text'] ?? '')));
if ($title === '') {
continue;
}
$items = [];
foreach ((array) ($section['entry'] ?? []) as $ref) {
if (! is_array($ref)) {
continue;
}
$refStr = trim((string) ($ref['reference'] ?? ''));
if ($refStr === '' || ! isset($resourcesByRef[$refStr])) {
continue;
}
$label = $this->describeResourceForSection((array) $resourcesByRef[$refStr]);
if ($label !== '') {
$items[] = $label;
}
}
$narrative = '';
if ($items === []) {
$div = trim((string) ($section['text']['div'] ?? ''));
if ($div !== '') {
$div = preg_replace('/<li[^>]*>/i', "\n- ", $div) ?? $div;
$div = preg_replace('/<br\s*\/?>/i', "\n", $div) ?? $div;
$narrative = trim(html_entity_decode(strip_tags($div), ENT_QUOTES | ENT_HTML5));
}
}
$sections[] = [
'title' => $title,
'items' => $items,
'narrative' => $narrative,
];
}
return $sections;
}
/**
* Produces a short, human-readable one-line label for a FHIR resource, for
* display inside a Composition section's accordion body.
*/
private function describeResourceForSection(array $resource): string
{
$type = trim((string) ($resource['resourceType'] ?? ''));
switch ($type) {
case 'Condition':
case 'AllergyIntolerance':
case 'Procedure':
case 'ServiceRequest':
return trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
case 'Observation':
$name = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
$val = '';
if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) {
$val = trim((string) (($resource['valueQuantity']['value'] ?? '') . ' ' . ($resource['valueQuantity']['unit'] ?? '')));
} elseif (isset($resource['valueString'])) {
$val = trim((string) $resource['valueString']);
} elseif (isset($resource['valueCodeableConcept'])) {
$val = trim((string) (($resource['valueCodeableConcept']['text'] ?? '') ?: ($resource['valueCodeableConcept']['coding'][0]['display'] ?? '')));
}
return $val !== '' ? ($name . ': ' . $val) : $name;
case 'MedicationRequest':
case 'MedicationStatement':
$med = trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? '')));
$dose = trim((string) ($resource['dosageInstruction'][0]['text'] ?? ($resource['dosage'][0]['text'] ?? '')));
return $dose !== '' ? ($med . ' — ' . $dose) : $med;
case 'DiagnosticReport':
$name = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
$conclusion = trim((string) ($resource['conclusion'] ?? ''));
return $conclusion !== '' ? ($name . ' — ' . $conclusion) : $name;
case 'CarePlan':
return trim((string) (($resource['description'] ?? '') ?: ($resource['title'] ?? '')));
case 'DocumentReference':
return trim((string) (($resource['description'] ?? '') ?: ($resource['type']['text'] ?? '') ?: ($resource['type']['coding'][0]['display'] ?? '')));
default:
return '';
}
}
/**
* Extracts attached scanned images/PDFs/generic documents (DocumentReference
* resources, whose content may carry the base64 attachment data inline or

View File

@ -347,13 +347,13 @@ if ($patientPhotoPath === '') {
<table class="table table-sm table-hover mb-0" id="abdmRequestsTable">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Request ID</th>
<th>HI Types</th>
<th>Requested By</th>
<th>Requested On</th>
<th>Expiry</th>
<th class="text-end">Actions</th>
<th>Expiry Date</th>
<th>Date From</th>
<th>Date To</th>
<th>View</th>
<th class="text-end">Show Data</th>
</tr>
</thead>
<tbody>
@ -446,6 +446,20 @@ if ($patientPhotoPath === '') {
</div>
</div>
<div class="modal fade" id="abdmFetchResultModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fetched Health Records</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="abdmFetchResultModalBody">
<div class="text-muted small text-center">Loading...</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="abdmCustomConsentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
@ -687,21 +701,18 @@ $(function() {
var html = '';
requests.forEach(function(consent, idx) {
var status = (consent.status || '').toString().toUpperCase();
var hiTypes = (consent.requested_hi_types && consent.requested_hi_types.length) ? consent.requested_hi_types : (consent.granted_hi_types || []);
var hiTypesText = hiTypes.length ? hiTypes.join(', ') : '-';
var canFetch = (status === 'GRANTED' || status === 'COMPLETED');
var requestId = consent.consent_request_id || '-';
var rowId = consent.id || (idx + 1);
html += '<tr>'
+ '<td class="small">' + escHtml(rowId) + '</td>'
+ '<td><span class="badge ' + consentStatusBadgeClass(status) + '">' + escHtml(status || 'UNKNOWN') + '</span></td>'
+ '<td class="small text-truncate" style="max-width:160px;" title="' + escHtml(requestId) + '">' + escHtml(requestId) + '</td>'
+ '<td class="small">' + escHtml(hiTypesText) + '</td>'
+ '<td class="small">' + escHtml(consent.requested_by || 'HMS') + '</td>'
+ '<td class="small">' + fmtConsentTs(consent.requested_on) + '</td>'
+ '<td class="small">' + fmtConsentTs(consent.erase_at) + '</td>'
+ '<td class="small">' + fmtConsentTs(consent.valid_from) + '</td>'
+ '<td class="small">' + fmtConsentTs(consent.valid_to) + '</td>'
+ '<td><button type="button" class="btn btn-link btn-sm p-0 abdm-view-request-btn" data-idx="' + idx + '">View</button></td>'
+ '<td class="text-end">'
+ '<button type="button" class="btn btn-link btn-sm p-0 me-2 abdm-view-request-btn" data-idx="' + idx + '">View</button>'
+ (canFetch ? ('<button type="button" class="btn btn-link btn-sm p-0 abdm-fetch-request-btn" data-idx="' + idx + '">Fetch Records</button>') : '')
+ (canFetch ? ('<button type="button" class="btn btn-link btn-sm p-0 abdm-fetch-request-btn" data-idx="' + idx + '">Show Data</button>') : '<span class="text-muted small">-</span>')
+ '</td>'
+ '</tr>';
});
@ -736,6 +747,8 @@ $(function() {
fetchOnlyRunningIdx = idx;
applyNewRequestButtonState();
setAbdmStatus('Fetching latest records using existing granted consent...', false);
openAbdmFetchResultModal();
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center">Fetching latest records...</div>');
var url = abdmFetchOnlyUrl + '?consent_id=' + encodeURIComponent(consent.consent_id || '') + '&consent_request_id=' + encodeURIComponent(consent.consent_request_id || '');
fetch(url, { credentials: 'same-origin' })
@ -749,14 +762,95 @@ $(function() {
applyNewRequestButtonState();
loadAbdmDocs();
loadAbdmConsentRequests();
loadAbdmFetchResultModalData(consent);
})
.catch(function(err) {
fetchOnlyRunningIdx = -1;
applyNewRequestButtonState();
setAbdmStatus('Fetch failed: ' + (err.message || err), true);
// Even if the fetch call itself failed, still show whatever records
// are already saved locally for this consent request.
loadAbdmFetchResultModalData(consent);
});
}
function openAbdmFetchResultModal() {
var bsModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('abdmFetchResultModal'));
bsModal.show();
}
function loadAbdmFetchResultModalData(consent) {
var url = abdmDocumentsUrl + '?limit=200&include_summary=1';
var reqId = (consent && consent.consent_request_id) ? consent.consent_request_id : '';
if (reqId !== '') {
url += '&consent_request_id=' + encodeURIComponent(reqId);
}
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load fetched records.');
}
renderAbdmFetchResultModal(Array.isArray(data.items) ? data.items : []);
})
.catch(function(err) {
$('#abdmFetchResultModalBody').html('<div class="text-danger small text-center">' + escHtml('Failed to load records: ' + (err.message || err)) + '</div>');
});
}
function renderAbdmFetchResultModal(docs) {
if (!docs || !docs.length) {
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center">No fetched records found for this consent request yet.</div>');
return;
}
var html = '';
docs.forEach(function(doc, di) {
var summary = doc.summary || {};
var sections = Array.isArray(summary.sections) ? summary.sections : [];
var accId = 'abdmFetchAcc' + di;
html += '<div class="card mb-3">'
+ '<div class="card-body">'
+ '<div class="fw-semibold">' + escHtml(doc.document_title || 'ABDM Document') + '</div>'
+ '<div class="small text-muted mb-2">'
+ escHtml(doc.organization_name || '-') + ' &nbsp;|&nbsp; '
+ escHtml(doc.practitioner_name || '-') + ' &nbsp;|&nbsp; '
+ 'Date: ' + escHtml(fmtDateTime(doc.document_date || doc.created_at))
+ '</div>';
if (sections.length) {
html += '<div class="accordion" id="' + accId + '">';
sections.forEach(function(sec, si) {
var itemId = accId + '_' + si;
var body = '';
if (sec.items && sec.items.length) {
body = '<ul class="mb-0 small">' + sec.items.map(function(it) { return '<li>' + escHtml(it) + '</li>'; }).join('') + '</ul>';
} else if (sec.narrative) {
body = '<div class="small" style="white-space:pre-line;">' + escHtml(sec.narrative) + '</div>';
} else {
body = '<div class="small text-muted">No details recorded.</div>';
}
html += '<div class="accordion-item">'
+ '<h2 class="accordion-header">'
+ '<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#' + itemId + '">' + escHtml(sec.title || 'Section') + '</button>'
+ '</h2>'
+ '<div id="' + itemId + '" class="accordion-collapse collapse" data-bs-parent="#' + accId + '">'
+ '<div class="accordion-body">' + body + '</div>'
+ '</div>'
+ '</div>';
});
html += '</div>';
} else {
html += '<div class="small text-muted">No structured sections available for this record.</div>';
}
html += '</div></div>';
});
$('#abdmFetchResultModalBody').html(html);
}
var zoom = 1;
var minZoom = 0.5;
var maxZoom = 3;