OPD consult: add floating draggable section bookmarks with persistent position

This commit is contained in:
DevSoftTech Server 2026-07-07 15:17:58 +05:30
parent 6407db9dbc
commit fd8e96747d
2 changed files with 806 additions and 44 deletions

View File

@ -390,8 +390,14 @@ class Opd_prescription extends BaseController
$fields = $this->db->getFieldNames('opd_prescription');
$patientRow = $this->db->table('patient_master')->where('id', (int) ($opdRow->p_id ?? 0))->get(1)->getRowArray() ?? [];
$abhaField = $this->resolvePatientAbhaField();
$currentAbha = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
$abhaAddress = trim((string) $this->request->getPost('abha_address'));
if ($abhaAddress !== '' && ! $this->isValidAbhaAddress($abhaAddress)) {
// Keep existing patient ABHA as-is even if it doesn't pass current runtime
// validator pattern; only validate when user enters/changes the value.
$abhaChanged = $abhaAddress !== '' && $abhaAddress !== $currentAbha;
if ($abhaChanged && ! $this->isValidAbhaAddress($abhaAddress)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Invalid ABHA Address format.',
@ -400,9 +406,7 @@ class Opd_prescription extends BaseController
]);
}
$abhaField = $this->resolvePatientAbhaField();
if ($abhaField !== null && $abhaAddress !== '') {
$currentAbha = trim((string) ($patientRow[$abhaField] ?? ''));
if ($currentAbha !== $abhaAddress) {
$this->db->table('patient_master')->where('id', (int) ($opdRow->p_id ?? 0))->update([$abhaField => $abhaAddress]);
$this->auditClinicalUpdate('patient_master', $abhaField, (int) ($opdRow->p_id ?? 0), $currentAbha, $abhaAddress);
@ -7235,6 +7239,7 @@ class Opd_prescription extends BaseController
$q = trim((string) $this->request->getGet('q'));
$favOnly = (int) $this->request->getGet('fav_only');
$specId = (int) $this->request->getGet('spec_id');
$category = strtolower(trim((string) $this->request->getGet('category')));
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['rows' => []]);
@ -7244,6 +7249,7 @@ class Opd_prescription extends BaseController
$codeField = in_array('Code', $fields, true) ? 'Code' : (in_array('code', $fields, true) ? 'code' : null);
$nameField = in_array('Name', $fields, true) ? 'Name' : (in_array('name', $fields, true) ? 'name' : null);
$shortField = in_array('short_name', $fields, true) ? 'short_name' : null;
$categoryField = in_array('category_name', $fields, true) ? 'category_name' : null;
$hasFav = in_array('is_favourite', $fields, true);
$hasSpec = in_array('spec_ids', $fields, true);
@ -7252,11 +7258,17 @@ class Opd_prescription extends BaseController
}
// When no search term but fav/spec filter applied, still return results
if ($q === '' && $favOnly === 0 && $specId === 0) {
if ($q === '' && $favOnly === 0 && $specId === 0 && $category === '') {
return $this->response->setJSON(['rows' => []]);
}
$selectCols = $codeField . ' as code, ' . $nameField . ' as name';
if ($shortField !== null) {
$selectCols .= ', ' . $shortField . ' as short_name';
}
if ($categoryField !== null) {
$selectCols .= ', ' . $categoryField . ' as category_name';
}
if ($hasFav) {
$selectCols .= ', is_favourite';
}
@ -7264,10 +7276,31 @@ class Opd_prescription extends BaseController
$builder = $this->db->table('investigation')->select($selectCols);
if ($q !== '') {
$builder->groupStart()->like($nameField, $q);
if ($shortField !== null) {
$builder->orLike($shortField, $q);
$qLen = mb_strlen($q);
$builder->groupStart();
if ($qLen <= 2) {
// 2-char search: prefix only (AB*) for tighter result set.
$builder->like($nameField, $q, 'after');
if ($shortField !== null) {
$builder->orLike($shortField, $q, 'after');
}
} else {
// 3+ chars: prefix (ABC*) OR contains (*ABC*).
$builder->groupStart()
->like($nameField, $q, 'after');
if ($shortField !== null) {
$builder->orLike($shortField, $q, 'after');
}
$builder->groupEnd()
->orGroupStart()
->like($nameField, $q, 'both');
if ($shortField !== null) {
$builder->orLike($shortField, $q, 'both');
}
$builder->groupEnd();
}
$builder->groupEnd();
}
@ -7284,7 +7317,35 @@ class Opd_prescription extends BaseController
}
$builder->orderBy('LOWER(' . $this->db->protectIdentifiers($nameField) . ')', 'ASC', false);
$rows = $builder->limit(40)->get()->getResultArray();
$rows = $builder->limit($category !== '' ? 200 : 40)->get()->getResultArray();
if ($category !== '') {
$normalizeCategory = static function (array $row): string {
$txt = strtolower(trim((string) (($row['category_name'] ?? '') . ' ' . ($row['short_name'] ?? '') . ' ' . ($row['name'] ?? ''))));
if ($txt === '') {
return 'pathology';
}
if (preg_match('/\bx\s*[-]?\s*ray\b|\bradiology\b|\bradiograph\b|\bct\b|\bmri\b/', $txt) === 1) {
return 'radiology';
}
if (preg_match('/\bultra\s*sound\b|\bultrasound\b|\busg\b|\bsonography\b/', $txt) === 1) {
return 'ultrasound';
}
if (preg_match('/\bcardi\b|\becg\b|\becho\b|\btmt\b/', $txt) === 1) {
return 'cardic';
}
return 'pathology';
};
$rows = array_values(array_filter($rows, static function (array $row) use ($normalizeCategory, $category): bool {
return $normalizeCategory($row) === $category;
}));
if (count($rows) > 40) {
$rows = array_slice($rows, 0, 40);
}
}
return $this->response->setJSON(['rows' => $rows]);
}
@ -7341,6 +7402,9 @@ class Opd_prescription extends BaseController
$jId = $this->resolveFirstField($jFields, ['id']);
$iCode = $this->resolveFirstField($iFields, ['Code', 'code']);
$iName = $this->resolveFirstField($iFields, ['Name', 'name']);
$iShort = $this->resolveFirstField($iFields, ['short_name', 'shortName', 'short']);
$iCategory = $this->resolveFirstField($iFields, ['category_name', 'category']);
$iSpecIds = $this->resolveFirstField($iFields, ['spec_ids']);
if ($pCode !== null && $pName !== null && $jProfileCode !== null && $jInvestCode !== null && $iCode !== null && $iName !== null) {
$orderExpr = 'p.`' . $pName . '` ASC';
@ -7356,7 +7420,19 @@ class Opd_prescription extends BaseController
. 'p.`' . $pCode . '` AS profile_code, '
. 'p.`' . $pName . '` AS profile_name, '
. 'i.`' . $iCode . '` AS investigation_code, '
. 'i.`' . $iName . '` AS investigation_name '
. 'i.`' . $iName . '` AS investigation_name';
if ($iShort !== null) {
$sql .= ', i.`' . $iShort . '` AS short_name';
}
if ($iCategory !== null) {
$sql .= ', i.`' . $iCategory . '` AS category_name';
}
if ($iSpecIds !== null) {
$sql .= ', i.`' . $iSpecIds . '` AS spec_ids';
}
$sql .= ' '
. 'FROM `invprofiles` p '
. 'JOIN `invtprofiles` j ON p.`' . $pCode . '` = j.`' . $jProfileCode . '` '
. 'JOIN `investigation` i ON i.`' . $iCode . '` = j.`' . $jInvestCode . '` '
@ -7370,6 +7446,9 @@ class Opd_prescription extends BaseController
$profileName = trim((string) ($row['profile_name'] ?? ''));
$testCode = trim((string) ($row['investigation_code'] ?? ''));
$testName = trim((string) ($row['investigation_name'] ?? ''));
$shortName = trim((string) ($row['short_name'] ?? ''));
$categoryName = trim((string) ($row['category_name'] ?? ''));
$specIds = trim((string) ($row['spec_ids'] ?? ''));
if ($profileCode === '' || $profileName === '' || $testName === '') {
continue;
@ -7393,6 +7472,9 @@ class Opd_prescription extends BaseController
$profileMap[$profileCode]['tests'][] = [
'code' => $testCode,
'name' => $testName,
'short_name' => $shortName,
'category_name' => $categoryName,
'spec_ids' => $specIds,
];
}

View File

@ -359,6 +359,68 @@
border-color: #86b7fe !important;
box-shadow: 0 0 0 0.15rem rgba(13, 110, 253, 0.12);
}
.rx-jump-bookmarks {
position: fixed;
right: 14px;
top: 52%;
transform: translateY(-50%);
z-index: 1100;
display: flex;
flex-direction: column;
gap: .35rem;
border: 1px solid #d7e3f4;
background: rgba(248, 251, 255, .96);
border-radius: .6rem;
padding: .45rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, .12);
}
.rx-jump-bookmarks .rx-jump-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
border: 1px dashed #b9cff4;
border-radius: .45rem;
background: #eef4ff;
color: #355f98;
font-size: .72rem;
padding: .12rem .35rem;
margin-bottom: .1rem;
cursor: grab;
user-select: none;
touch-action: none;
}
.rx-jump-bookmarks.is-dragging .rx-jump-drag-handle {
cursor: grabbing;
background: #dce9ff;
}
.rx-jump-bookmarks .rx-jump-link {
display: inline-flex;
align-items: center;
gap: .35rem;
border-radius: 999px;
padding: .2rem .6rem;
font-size: .8rem;
justify-content: flex-start;
white-space: nowrap;
}
.rx-jump-bookmarks .rx-jump-link i {
font-size: .82rem;
}
@media (max-width: 991.98px) {
.rx-jump-bookmarks {
right: 8px;
top: auto;
bottom: 10px;
transform: none;
max-height: 62vh;
overflow-y: auto;
}
.rx-jump-bookmarks .rx-jump-link {
padding: .2rem .45rem;
font-size: .75rem;
}
}
.rx-prefilled-morbidity {
background: #f8fbff;
border: 1px solid #d6e8ff;
@ -424,13 +486,6 @@
<span id="rx_status_text" class="ms-2 text-muted">No local changes</span>
</div>
<div>
<select class="form-select form-select-sm d-inline-block" id="speech_mode_select" style="width:auto;min-width:150px;">
<option value="auto">Mic Mode: Auto</option>
<option value="browser">Mic Mode: Browser</option>
<option value="server">Mic Mode: Server</option>
</select>
<button type="button" class="btn btn-outline-warning btn-sm" id="btn_new_opd_session_reset" title="Start clean OPD session view (clear Advice/Investigation/Medicine list)">New OPD Session</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_restore_draft">Restore Draft</button>
<?php
$historyBackUrl = base_url('Opd_prescription/Prescription') . '/' . (int) ($opd_id ?? 0);
$historyUrl = base_url('billing/patient/show_profile_opd') . '/' . (int) ($patient_master[0]->id ?? 0) . '/1?' . http_build_query([
@ -457,9 +512,28 @@
aria-label="Patient History">
<i class="bi bi-clock-history"></i>
</a>
<button type="button" class="btn btn-outline-success btn-sm" id="btn_local_clinical_assist" title="Local rule-based support using complaints + vitals">Clinical Assist (Local)</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_ai_full_draft" title="Use complete OPD data to generate draft notes">AI Draft (Full Form)</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">More Tools</button>
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:280px;">
<div class="mb-2">
<select class="form-select form-select-sm" id="speech_mode_select">
<option value="auto">Mic Mode: Auto</option>
<option value="browser">Mic Mode: Browser</option>
<option value="server">Mic Mode: Server</option>
</select>
</div>
<div class="d-grid gap-1">
<button type="button" class="btn btn-outline-warning btn-sm text-start" id="btn_new_opd_session_reset" title="Start clean OPD session view (clear Advice/Investigation/Medicine list)">New OPD Session</button>
<button type="button" class="btn btn-outline-secondary btn-sm text-start" id="btn_restore_draft">Restore Draft</button>
<button type="button" class="btn btn-outline-success btn-sm text-start" id="btn_local_clinical_assist" title="Local rule-based support using complaints + vitals">Clinical Assist (Local)</button>
<button type="button" class="btn btn-outline-primary btn-sm text-start" id="btn_ai_full_draft" title="Use complete OPD data to generate draft notes">AI Draft (Full Form)</button>
<button type="button" class="btn btn-outline-primary btn-sm text-start" id="btn_quick_preview_fhir">Preview FHIR JSON</button>
</div>
</div>
</div>
<button type="button" class="btn btn-primary btn-sm" id="btn_save_rx">Save Consult</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_toggle_consult_view_settings">Customize Sections</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_manage_autotype_keywords">Manage Keywords</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">🖨 Print</button>
<ul class="dropdown-menu dropdown-menu-end">
@ -477,6 +551,27 @@
</div>
</div>
<div id="rx_scan_banner" class="rx-scan-banner"></div>
<div class="rx-jump-bookmarks" aria-label="Consult section bookmarks">
<div class="rx-jump-drag-handle" title="Drag to move bookmark panel"><i class="bi bi-grip-vertical"></i>&nbsp;Move</div>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#rx_sec_vitals" title="Jump to Vitals">
<i class="bi bi-bookmark-fill"></i><span>Vitals</span>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#rx_sec_complaints" title="Jump to Complaints">
<i class="bi bi-bookmark-fill"></i><span>Complaints</span>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#rx_sec_diagnosis" title="Jump to Diagnosis">
<i class="bi bi-bookmark-fill"></i><span>Diagnosis</span>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#panel_medicine" title="Jump to Prescribe Medicine">
<i class="bi bi-bookmark-fill"></i><span>Prescribe Medicine</span>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#panel_investigation" title="Jump to Advise Investigation">
<i class="bi bi-bookmark-fill"></i><span>Advise Investigation</span>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-jump-link" data-target="#panel_advice" title="Jump to Advice">
<i class="bi bi-bookmark-fill"></i><span>Advice</span>
</button>
</div>
<div class="row g-3 rx-two-panel">
<div class="col-lg-3 rx-left-panel">
@ -490,10 +585,10 @@
<div class="rx-meta-line"><strong>UHID:</strong> <?= esc($patient_master[0]->p_code ?? '') ?></div>
<div class="rx-meta-line"><strong>Age:</strong> <?= esc($patient_master[0]->str_age ?? '') ?></div>
<div class="rx-meta-line"><strong>Gender:</strong> <?= esc($patient_master[0]->xgender ?? '') ?></div>
<div class="rx-meta-line">
<label for="abha_address" class="form-label mb-1"><strong>ABHA Address:</strong></label>
<input type="text" class="form-control form-control-sm rx-instant" id="abha_address" value="<?= esc($patientAbhaAddress) ?>" placeholder="name@abdm" autocomplete="off">
</div>
<?php if ($patientAbhaAddress !== '') : ?>
<div class="rx-meta-line"><strong>ABHA Address:</strong> <?= esc($patientAbhaAddress) ?></div>
<?php endif; ?>
<input type="hidden" id="abha_address" value="<?= esc($patientAbhaAddress) ?>">
<div class="rx-meta-line"><strong>OPD:</strong> <?= esc($opd_master[0]->opd_code ?? '') ?></div>
<div class="rx-meta-line"><strong>Date:</strong> <?= esc($opd_master[0]->apointment_date ?? '') ?></div>
<div class="rx-meta-line mb-0"><strong>Doctor:</strong> <?= esc($opd_master[0]->doc_name ?? '') ?></div>
@ -606,19 +701,6 @@
<div class="col-lg-9 rx-right-panel">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<strong>Single Screen Consult</strong>
<small class="text-muted ms-2">(Old familiar fields + AI assist)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border">Doctor View</span>
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_quick_preview_fhir">Preview FHIR JSON</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_toggle_consult_view_settings">Customize Sections</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_manage_autotype_keywords">Manage Keywords</button>
</div>
</div>
<div class="card-body">
<div id="rx_consult_visibility_panel" class="rx-consult-visibility-panel">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
@ -1028,7 +1110,7 @@
</div>
</div>
<div class="row g-2 mb-3">
<div class="row g-2 mb-3 d-none">
<div class="col-md-10">
<input type="text" class="form-control" id="advice_text" list="advice_suggest" placeholder="Type advice (e.g., Rest, Hydration, Follow-up)">
<datalist id="advice_suggest"></datalist>
@ -1068,9 +1150,9 @@
<div id="panel_investigation" class="rx-panel">
<div class="mb-2">
<label class="form-label"><strong>Advise Investigation</strong></label>
</div>
<div class="d-none">
<textarea class="form-control form-control-sm" id="advise_investigation_notes" rows="2" placeholder="Optional notes for investigation advice..."></textarea>
<div class="rx-recent-chip-label">Recent investigations</div>
<div class="rx-recent-chip-box" id="recent_chips_investigation"><span class="text-muted small">Loading...</span></div>
</div>
<div class="mb-2">
@ -1086,12 +1168,35 @@
</div>
</div>
<div class="border rounded p-2 mb-3 bg-light-subtle" id="investigation_category_box">
<div class="row g-2 mb-2">
<div class="col-md-3"><label class="form-label mb-1">Pathology Test</label></div>
<div class="col-md-9"><select class="form-select form-select-sm inv-cat-select" id="inv_cat_pathology" data-category="pathology" multiple="multiple" style="width:100%;"></select></div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-3"><label class="form-label mb-1">Radiology</label></div>
<div class="col-md-9"><select class="form-select form-select-sm inv-cat-select" id="inv_cat_radiology" data-category="radiology" multiple="multiple" style="width:100%;"></select></div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-3"><label class="form-label mb-1">UltraSound</label></div>
<div class="col-md-9"><select class="form-select form-select-sm inv-cat-select" id="inv_cat_ultrasound" data-category="ultrasound" multiple="multiple" style="width:100%;"></select></div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-3"><label class="form-label mb-1">Cardic Test</label></div>
<div class="col-md-9"><select class="form-select form-select-sm inv-cat-select" id="inv_cat_cardic" data-category="cardic" multiple="multiple" style="width:100%;"></select></div>
</div>
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label mb-1">Others</label></div>
<div class="col-md-9"><textarea class="form-control form-control-sm" id="inv_cat_others" rows="3" placeholder="Type other investigations, comma separated"></textarea></div>
</div>
</div>
<div id="legacy_inv_shortcuts_panel" class="border rounded p-2 mb-3" style="display:none;">
<div class="small fw-semibold mb-2">Legacy Investigation Shortcuts</div>
<div id="legacy_inv_shortcuts_grouped"></div>
</div>
<div class="row g-2 mb-3">
<div class="row g-2 mb-3 d-none">
<div class="col-md-10">
<select class="form-select" id="investigation_name_select2" style="width:100%;">
<option value="">Search investigation...</option>
@ -1113,7 +1218,7 @@
<button type="button" class="btn btn-outline-danger" id="btn_clear_investigation">Remove All</button>
</div>
</div>
<div class="table-responsive">
<div class="table-responsive d-none">
<table class="table table-bordered table-sm rx-list-table" id="tbl_investigation">
<thead><tr><th>Test Name</th><th>Code</th><th width="90">Action</th></tr></thead>
<tbody><tr><td colspan="3" class="text-muted">No investigation added</td></tr></tbody>
@ -1605,6 +1710,7 @@
};
var patientId = <?= (int) ($patient_master[0]->id ?? 0) ?>;
var doctorViewId = <?= (int) ($doctorId ?? 0) ?>;
var jumpBookmarkPositionKey = 'opd_jump_bookmark_position_' + doctorViewId;
autotypeSuggestCacheLocalKey = autotypeSuggestCacheLocalKey + '_' + doctorViewId;
var consultSectionPreferenceKey = 'opd_consult_section_pref_' + doctorViewId;
var consultSectionMeta = [
@ -1755,6 +1861,129 @@
});
}
function clampJumpBookmarkPosition(left, top, panelWidth, panelHeight) {
var viewportWidth = Math.max($(window).width() || 0, 320);
var viewportHeight = Math.max($(window).height() || 0, 320);
var safeLeft = Math.min(Math.max(left, 6), Math.max(6, viewportWidth - panelWidth - 6));
var safeTop = Math.min(Math.max(top, 6), Math.max(6, viewportHeight - panelHeight - 6));
return { left: safeLeft, top: safeTop };
}
function saveJumpBookmarkPosition(pos) {
try {
localStorage.setItem(jumpBookmarkPositionKey, JSON.stringify(pos || {}));
} catch (e) {
// Ignore private mode/quota errors.
}
}
function applySavedJumpBookmarkPosition($panel) {
if (!$panel || !$panel.length) {
return;
}
try {
var raw = localStorage.getItem(jumpBookmarkPositionKey);
if (!raw) {
return;
}
var parsed = JSON.parse(raw);
var left = Number(parsed && parsed.left);
var top = Number(parsed && parsed.top);
if (!isFinite(left) || !isFinite(top)) {
return;
}
var panelWidth = $panel.outerWidth() || 180;
var panelHeight = $panel.outerHeight() || 220;
var pos = clampJumpBookmarkPosition(left, top, panelWidth, panelHeight);
$panel.css({ left: pos.left + 'px', top: pos.top + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
} catch (e) {
// Ignore parse failures.
}
}
function initJumpBookmarkDrag() {
var $panel = $('.rx-jump-bookmarks').first();
if (!$panel.length) {
return;
}
applySavedJumpBookmarkPosition($panel);
var dragState = { active: false, pointerId: null, startX: 0, startY: 0, baseLeft: 0, baseTop: 0 };
function getPointerCoords(ev) {
if (ev && ev.originalEvent && ev.originalEvent.touches && ev.originalEvent.touches.length) {
return { x: ev.originalEvent.touches[0].clientX, y: ev.originalEvent.touches[0].clientY };
}
if (ev && ev.originalEvent && ev.originalEvent.changedTouches && ev.originalEvent.changedTouches.length) {
return { x: ev.originalEvent.changedTouches[0].clientX, y: ev.originalEvent.changedTouches[0].clientY };
}
return { x: ev.clientX, y: ev.clientY };
}
function beginDrag(ev) {
var coords = getPointerCoords(ev);
var panelOffset = $panel.offset() || { left: 14, top: 100 };
dragState.active = true;
dragState.pointerId = (ev.originalEvent && typeof ev.originalEvent.pointerId !== 'undefined') ? ev.originalEvent.pointerId : null;
dragState.startX = coords.x;
dragState.startY = coords.y;
dragState.baseLeft = panelOffset.left;
dragState.baseTop = panelOffset.top;
$panel.addClass('is-dragging').css({ left: panelOffset.left + 'px', top: panelOffset.top + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
ev.preventDefault();
}
function moveDrag(ev) {
if (!dragState.active) {
return;
}
if (dragState.pointerId !== null && ev.originalEvent && typeof ev.originalEvent.pointerId !== 'undefined' && ev.originalEvent.pointerId !== dragState.pointerId) {
return;
}
var coords = getPointerCoords(ev);
var nextLeft = dragState.baseLeft + (coords.x - dragState.startX);
var nextTop = dragState.baseTop + (coords.y - dragState.startY);
var panelWidth = $panel.outerWidth() || 180;
var panelHeight = $panel.outerHeight() || 220;
var pos = clampJumpBookmarkPosition(nextLeft, nextTop, panelWidth, panelHeight);
$panel.css({ left: pos.left + 'px', top: pos.top + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
ev.preventDefault();
}
function endDrag() {
if (!dragState.active) {
return;
}
dragState.active = false;
dragState.pointerId = null;
$panel.removeClass('is-dragging');
var panelOffset = $panel.offset() || { left: 14, top: 100 };
saveJumpBookmarkPosition({ left: panelOffset.left, top: panelOffset.top });
}
$panel.off('pointerdown.rxJumpDrag touchstart.rxJumpDrag mousedown.rxJumpDrag', '.rx-jump-drag-handle');
$panel.on('pointerdown.rxJumpDrag touchstart.rxJumpDrag mousedown.rxJumpDrag', '.rx-jump-drag-handle', beginDrag);
$(document).off('pointermove.rxJumpDrag touchmove.rxJumpDrag mousemove.rxJumpDrag');
$(document).on('pointermove.rxJumpDrag touchmove.rxJumpDrag mousemove.rxJumpDrag', moveDrag);
$(document).off('pointerup.rxJumpDrag pointercancel.rxJumpDrag touchend.rxJumpDrag touchcancel.rxJumpDrag mouseup.rxJumpDrag');
$(document).on('pointerup.rxJumpDrag pointercancel.rxJumpDrag touchend.rxJumpDrag touchcancel.rxJumpDrag mouseup.rxJumpDrag', endDrag);
$(window).off('resize.rxJumpDrag').on('resize.rxJumpDrag', function() {
var panelOffset = $panel.offset();
if (!panelOffset) {
return;
}
var panelWidth = $panel.outerWidth() || 180;
var panelHeight = $panel.outerHeight() || 220;
var pos = clampJumpBookmarkPosition(panelOffset.left, panelOffset.top, panelWidth, panelHeight);
$panel.css({ left: pos.left + 'px', top: pos.top + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
saveJumpBookmarkPosition({ left: pos.left, top: pos.top });
});
}
function setAbdmStatus(msg, level) {
var $box = $('#abdm_action_status');
if (!$box.length) {
@ -6661,6 +6890,332 @@
});
}
function normalizeInvestigationCategoryLabel(label) {
var txt = (label || '').toString().trim().toLowerCase();
if (!txt) {
return 'Pathology';
}
if (/\bx\s*[-]?\s*ray\b/i.test(txt) || txt.indexOf('xray') !== -1 || txt.indexOf('radiology') !== -1 || txt === 'x') {
return 'X-Ray';
}
if (txt.indexOf('ultra') !== -1 || txt.indexOf('ultra sound') !== -1 || txt.indexOf('ultrasound') !== -1 || txt.indexOf('usg') !== -1 || txt.indexOf('sonography') !== -1) {
return 'Ultra Sound';
}
if (txt.indexOf('card') !== -1 || txt.indexOf('cardio') !== -1 || txt.indexOf('ecg') !== -1 || txt.indexOf('echo') !== -1 || txt.indexOf('tmt') !== -1) {
return 'Cardic Test';
}
return 'Pathology';
}
function normalizeInvestigationTextKey(value) {
return (value || '')
.toString()
.toLowerCase()
.replace(/[()\[\]{}.,]/g, ' ')
.replace(/[-_/]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildInvestigationCategoryMap() {
var map = {};
(legacyInvestigationShortTests || []).forEach(function(row) {
var name = (row && row.name ? row.name : '').toString().trim().toLowerCase();
var code = (row && row.code ? row.code : '').toString().trim().toLowerCase();
var category = normalizeInvestigationCategoryLabel(row && row.short_name ? row.short_name : '');
if (name) {
map['n:' + name] = category;
map['n:' + normalizeInvestigationTextKey(name)] = category;
}
if (code) {
map['c:' + code] = category;
}
});
return map;
}
function detectInvestigationCategory(row, categoryMap) {
var name = (row && row.name ? row.name : '').toString().trim();
var code = (row && row.code ? row.code : '').toString().trim();
var shortName = (row && row.short_name ? row.short_name : '').toString().trim();
var categoryName = (row && row.category_name ? row.category_name : '').toString().trim();
var nameNorm = normalizeInvestigationTextKey(name);
// Name-based category must win for explicit tests like X-Ray Chest,
// even when master short/category fields are generic.
if (/\bx\s*[-]?\s*ray\b|\bpa\s*view\b|\bradiograph\b/i.test(nameNorm)) {
return 'X-Ray';
}
if (/\bultra\s*sound\b|\bultrasound\b|\busg\b|\bsonography\b/i.test(nameNorm)) {
return 'Ultra Sound';
}
if (/\becg\b|\becho\b|\btmt\b|\bcardi/i.test(nameNorm)) {
return 'Cardic Test';
}
if (categoryName) {
return normalizeInvestigationCategoryLabel(categoryName);
}
if (shortName) {
return normalizeInvestigationCategoryLabel(shortName);
}
var byCodeKey = 'c:' + code.toLowerCase();
if (code && categoryMap[byCodeKey]) {
return categoryMap[byCodeKey];
}
var byNameKey = 'n:' + name.toLowerCase();
if (name && categoryMap[byNameKey]) {
return categoryMap[byNameKey];
}
var byNameNormKey = 'n:' + nameNorm;
if (nameNorm && categoryMap[byNameNormKey]) {
return categoryMap[byNameNormKey];
}
return normalizeInvestigationCategoryLabel(name);
}
function clearInvestigationCategorySelections() {
['#inv_cat_pathology', '#inv_cat_radiology', '#inv_cat_ultrasound', '#inv_cat_cardic'].forEach(function(sel) {
var $sel = $(sel);
if (!$sel.length) {
return;
}
$sel.val(null).trigger('change.select2');
$sel.find('option').remove();
});
$('#inv_cat_others').val('');
}
function appendInvestigationProfileToNotes(rows) {
rows = Array.isArray(rows) ? rows : [];
if (!rows.length) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('No tests found in selected profile.');
return;
}
clearInvestigationCategorySelections();
var categoryOrder = ['Pathology', 'X-Ray', 'Ultra Sound', 'Cardic Test'];
var grouped = {
'Pathology': [],
'X-Ray': [],
'Ultra Sound': [],
'Cardic Test': []
};
var seen = {};
var categoryMap = buildInvestigationCategoryMap();
rows.forEach(function(row) {
var name = (row && row.name ? row.name : row).toString().trim();
var code = (row && row.code ? row.code : '').toString().trim();
if (!name) {
return;
}
var uniq = (code + '|' + name).toLowerCase();
if (seen[uniq]) {
return;
}
seen[uniq] = true;
var category = detectInvestigationCategory({
name: name,
code: code,
short_name: (row && row.short_name ? row.short_name : ''),
category_name: (row && row.category_name ? row.category_name : '')
}, categoryMap);
if (!grouped[category]) {
grouped[category] = [];
categoryOrder.push(category);
}
grouped[category].push(name);
});
fillCategorySelectorsFromRows(rows, categoryMap, categoryOrder, grouped);
buildInvestigationNotesFromCategories();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Investigation profile applied to category builder.');
markDirty('Investigation profile applied');
}
function ensureSelect2Option($sel, name, code) {
if (!$sel.length || !name) {
return;
}
var optionId = (name + ' [' + (code || '') + ']').trim();
var label = code ? (name + ' [' + code + ']') : name;
var exists = $sel.find('option').filter(function() {
return ($(this).val() || '') === optionId;
}).length > 0;
if (!exists) {
var opt = new Option(label, optionId, true, true);
$(opt).attr('data-name', name).attr('data-code', code || '');
$sel.append(opt);
}
}
function getSelectedCategoryNames(selector) {
var $sel = $(selector);
if (!$sel.length) {
return [];
}
var out = [];
var seen = {};
$sel.find('option:selected').each(function() {
var name = ($(this).attr('data-name') || $(this).text() || '').toString().trim();
name = name.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
if (!name) {
return;
}
var key = name.toLowerCase();
if (seen[key]) {
return;
}
seen[key] = true;
out.push(name);
});
return out;
}
function buildInvestigationNotesFromCategories() {
var pathology = getSelectedCategoryNames('#inv_cat_pathology');
var radiology = getSelectedCategoryNames('#inv_cat_radiology');
var ultrasound = getSelectedCategoryNames('#inv_cat_ultrasound');
var cardic = getSelectedCategoryNames('#inv_cat_cardic');
var othersRaw = ($('#inv_cat_others').val() || '').toString().trim();
var lines = [];
if (pathology.length) {
lines.push('Pathology : ' + pathology.join(', '));
}
if (radiology.length) {
lines.push('X-Ray : ' + radiology.join(', '));
}
if (ultrasound.length) {
lines.push('Ultra Sound : ' + ultrasound.join(', '));
}
if (cardic.length) {
lines.push('Cardic Test : ' + cardic.join(', '));
}
if (othersRaw !== '') {
lines.push('Others : ' + othersRaw);
}
$('#advise_investigation_notes').val(lines.join('\n').trim()).trigger('input');
}
function fillCategorySelectorsFromRows(rows, categoryMap) {
var idMap = {
'Pathology': '#inv_cat_pathology',
'X-Ray': '#inv_cat_radiology',
'Ultra Sound': '#inv_cat_ultrasound',
'Cardic Test': '#inv_cat_cardic'
};
rows.forEach(function(row) {
var name = (row && row.name ? row.name : row).toString().trim();
var code = (row && row.code ? row.code : '').toString().trim();
if (!name) {
return;
}
var category = detectInvestigationCategory({
name: name,
code: code,
short_name: (row && row.short_name ? row.short_name : ''),
category_name: (row && row.category_name ? row.category_name : '')
}, categoryMap || buildInvestigationCategoryMap());
var target = idMap[category] || '#inv_cat_pathology';
ensureSelect2Option($(target), name, code);
});
Object.keys(idMap).forEach(function(k) {
var sel = idMap[k];
if ($(sel).length) {
$(sel).trigger('change.select2');
}
});
}
function parseInvestigationCategoryLabel(rawLabel) {
var label = (rawLabel || '').toString().trim().toLowerCase();
if (!label) {
return '';
}
if (label.indexOf('pathology') === 0) {
return 'pathology';
}
if (label.indexOf('x-ray') === 0 || label.indexOf('radiology') === 0 || label.indexOf('xray') === 0) {
return 'radiology';
}
if (label.indexOf('ultra sound') === 0 || label.indexOf('ultrasound') === 0 || label.indexOf('usg') === 0) {
return 'ultrasound';
}
if (label.indexOf('cardic') === 0 || label.indexOf('cardiac') === 0 || label.indexOf('cardio') === 0) {
return 'cardic';
}
if (label.indexOf('other') === 0) {
return 'others';
}
return '';
}
function hydrateCategoryBuilderFromNotes(textRaw) {
var text = (textRaw || '').toString();
if (text.trim() === '') {
return;
}
clearInvestigationCategorySelections();
var mapSel = {
pathology: '#inv_cat_pathology',
radiology: '#inv_cat_radiology',
ultrasound: '#inv_cat_ultrasound',
cardic: '#inv_cat_cardic'
};
var lines = text.split(/\r?\n/);
lines.forEach(function(line) {
var m = /^\s*([^:]+)\s*:\s*(.*)$/.exec(line || '');
if (!m) {
return;
}
var key = parseInvestigationCategoryLabel(m[1] || '');
var body = (m[2] || '').toString().trim();
if (key === 'others') {
$('#inv_cat_others').val(body);
return;
}
var target = mapSel[key] || '';
if (!target || body === '') {
return;
}
body.split(',').map(function(v) {
return (v || '').toString().trim();
}).filter(function(v) {
return v !== '';
}).forEach(function(name) {
ensureSelect2Option($(target), name, '');
});
});
Object.keys(mapSel).forEach(function(k) {
var sel = mapSel[k];
if ($(sel).length) {
$(sel).trigger('change.select2');
}
});
}
function loadInvestigationList() {
var opdId = $('#opd_id').val();
var sid = parseInt($('#opd_session_id').val() || '0', 10);
@ -6791,7 +7346,7 @@
$(document).off('click.invProfileChip', '.inv-profile-chip').on('click.invProfileChip', '.inv-profile-chip', function() {
var key = ($(this).data('profile') || '').toString().trim();
if (legacyInvestigationProfiles[key] && legacyInvestigationProfiles[key].length) {
batchAddInvestigationRows(legacyInvestigationProfiles[key]);
appendInvestigationProfileToNotes(legacyInvestigationProfiles[key]);
return;
}
@ -6800,7 +7355,9 @@
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('No tests found in selected profile.');
return;
}
batchAddInvestigations(tests);
appendInvestigationProfileToNotes(tests.map(function(name) {
return { name: (name || '').toString().trim(), code: '' };
}));
});
$('#btn_create_inv_profile').on('click', function() {
@ -6848,13 +7405,93 @@
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Selected profile has no tests.');
return;
}
batchAddInvestigationRows(legacyRows);
appendInvestigationProfileToNotes(legacyRows);
return;
}
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Invalid profile selection.');
});
function initInvestigationCategoryBuilders() {
if (!($.fn && $.fn.select2)) {
return;
}
$('.inv-cat-select').each(function() {
var $sel = $(this);
var category = ($sel.data('category') || '').toString();
$sel.select2({
width: '100%',
multiple: true,
placeholder: 'Search and select...',
minimumInputLength: 2,
allowClear: true,
language: {
inputTooShort: function() {
return 'Type at least 2 characters';
}
},
ajax: {
delay: 250,
transport: function(params, success, failure) {
var term = (params.data && params.data.term) ? params.data.term : '';
if ((term || '').trim().length < 2) {
success({ rows: [] });
return;
}
$.ajax({
url: '<?= base_url('Opd_prescription/investigation_search') ?>',
dataType: 'json',
data: { q: term, category: category },
success: success,
error: failure
});
},
processResults: function(data) {
var rows = (data && data.rows) ? data.rows : [];
return {
results: rows.map(function(row) {
var name = (row.name || '').toString();
var code = (row.code || '').toString();
var text = code ? (name + ' [' + code + ']') : name;
return { id: text, text: text, name: name, code: code };
})
};
}
},
templateSelection: function(item) {
return item.text || item.id || '';
}
}).on('select2:select', function(e) {
var d = (e && e.params && e.params.data) ? e.params.data : null;
if (!d) {
return;
}
var name = (d.name || d.text || '').toString().replace(/\s*\[[^\]]*\]\s*$/, '').trim();
var code = (d.code || '').toString().trim();
var val = (d.id || d.text || '').toString();
$sel.find('option').each(function() {
if (($(this).val() || '') === val) {
$(this).attr('data-name', name).attr('data-code', code);
}
});
buildInvestigationNotesFromCategories();
}).on('change', function() {
buildInvestigationNotesFromCategories();
});
});
$('#btn_build_inv_notes').on('click', function() {
buildInvestigationNotesFromCategories();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Investigation advice text updated from category builder.');
markDirty('Investigation advice rebuilt');
});
$('#inv_cat_others').on('input', function() {
buildInvestigationNotesFromCategories();
});
}
$('#btn_toggle_legacy_inv_panel').on('click', function() {
var $panel = $('#legacy_inv_shortcuts_panel');
if (!$panel.length) {
@ -6906,6 +7543,7 @@
loadInvestigationShortcutsFromLegacy();
initCustomInvestigationModal();
initInvestigationPicker();
initInvestigationCategoryBuilders();
loadInvSpecDropdown();
$('#btn_inv_fav_toggle').on('click', function() {
@ -6926,7 +7564,9 @@
});
loadMedicineDoseMasters();
$('#advise_investigation_notes').val($('#investigation').val() || '');
var savedInvestigationNotes = ($('#investigation').val() || '').toString();
$('#advise_investigation_notes').val(savedInvestigationNotes);
hydrateCategoryBuilderFromNotes(savedInvestigationNotes);
$(document).off('click.investRemoveSingle', '.btn-del-invest').on('click.investRemoveSingle', '.btn-del-invest', function() {
var id = $(this).data('id');
@ -7949,6 +8589,45 @@
}
});
$(document).on('click', '.rx-jump-link', function() {
var targetSelector = ($(this).data('target') || '').toString();
if (targetSelector === '') {
return;
}
var $target = $(targetSelector);
if (!$target.length) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Jump target not found on this consult page.');
return;
}
var $foldWrap = $target.closest('.rx-foldable');
if ($foldWrap.length && $foldWrap.hasClass('is-collapsed')) {
$foldWrap.removeClass('is-collapsed');
}
var targetEl = $target.get(0);
if (targetEl && typeof targetEl.scrollIntoView === 'function') {
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
}
setTimeout(function() {
var $scrollWrap = $target.closest('.rx-right-panel');
if ($scrollWrap.length) {
$scrollWrap.stop().animate({ scrollTop: Math.max(0, ($scrollWrap.scrollTop() || 0) - 18) }, 120);
} else {
var stickyHeight = $('.rx-sticky-actions:visible').outerHeight() || 0;
var jumpTop = ($target.offset() ? $target.offset().top : 0) - stickyHeight - 10;
$('html, body').stop().animate({ scrollTop: Math.max(0, jumpTop) }, 120);
}
}, 180);
$target.addClass('rx-scan-highlight');
setTimeout(function() {
$target.removeClass('rx-scan-highlight');
}, 1300);
});
$(document).on('click', '.btn-substitute-add', function() {
var medId = parseInt($(this).data('id') || '0', 10);
var medName = ($(this).data('name') || '').toString().trim();
@ -8248,6 +8927,7 @@
});
initConsultSectionDesigner();
initJumpBookmarkDrag();
bindLegacyCommaAutocomplete('#complaints', '<?= base_url('Opd_prescription/complaints_search') ?>', function(row) {
return (row && row.name) ? row.name : '';