diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 074d89a..6d76a59 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -534,6 +534,7 @@ $routes->get('Opd_prescription/finding_exam_search', 'Opd_prescription::finding_ $routes->get('Opd_prescription/get_complaints', 'Opd_prescription::get_complaints'); $routes->get('Opd_prescription/get_disease', 'Opd_prescription::get_disease'); $routes->get('Opd_prescription/get_finding_exam', 'Opd_prescription::get_finding_exam'); +$routes->post('Opd_prescription/autotype_keyword_save', 'Opd_prescription::autotype_keyword_save'); $routes->post('Opd_prescription/complaints_parse', 'Opd_prescription::complaints_parse'); $routes->post('Opd_prescription/complaints_ai_draft', 'Opd_prescription::complaints_ai_draft'); $routes->post('Opd_prescription/clinical_autotype', 'Opd_prescription::clinical_autotype'); diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index d385478..d3019a9 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -3862,25 +3862,65 @@ class Opd_prescription extends BaseController public function complaints_search() { $q = trim((string) $this->request->getGet('q')); - if ($q === '' || ! $this->db->tableExists('complaints_master')) { + if ($q === '') { return $this->response->setJSON(['rows' => []]); } - $rows = $this->db->table('complaints_master') - ->select('Code as code, Name as name, name_hinglish, show_in_short, ai_hint') - ->where('is_active', 1) - ->groupStart() - ->like('Name', $q) - ->orLike('name_hinglish', $q) - ->orLike('keywords', $q) - ->groupEnd() - ->orderBy('show_in_short', 'DESC') - ->orderBy('Name', 'ASC') - ->limit(20) - ->get() - ->getResultArray(); + $rows = []; + if ($this->db->tableExists('complaints_master')) { + $rows = $this->db->table('complaints_master') + ->select('Code as code, Name as name, name_hinglish, show_in_short, ai_hint') + ->where('is_active', 1) + ->groupStart() + ->like('Name', $q) + ->orLike('name_hinglish', $q) + ->orLike('keywords', $q) + ->groupEnd() + ->orderBy('show_in_short', 'DESC') + ->orderBy('Name', 'ASC') + ->limit(20) + ->get() + ->getResultArray(); + } - return $this->response->setJSON(['rows' => $rows]); + $keywordRows = $this->fetchAutotypeKeywords( + 'complaints', + $q, + max(0, (int) ($this->getCurrentUserId() ?? 0)), + 20 + ); + + $out = []; + $seen = []; + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; + } + $k = strtoupper($name); + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = $row; + } + + foreach ($keywordRows as $keyword) { + $k = strtoupper($keyword); + if ($keyword === '' || isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = [ + 'code' => '', + 'name' => $keyword, + 'name_hinglish' => '', + 'show_in_short' => 0, + 'ai_hint' => '', + ]; + } + + return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); } public function provisional_diagnosis_search() @@ -3908,7 +3948,37 @@ class Opd_prescription extends BaseController ->get() ->getResultArray(); - return $this->response->setJSON(['rows' => $rows]); + $keywordRows = $this->fetchAutotypeKeywords( + 'provisional_diagnosis', + $q, + max(0, (int) ($this->getCurrentUserId() ?? 0)), + 20 + ); + + $out = []; + $seen = []; + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; + } + $k = strtoupper($name); + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = ['name' => $name]; + } + foreach ($keywordRows as $keyword) { + $k = strtoupper($keyword); + if ($keyword === '' || isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = ['name' => $keyword]; + } + + return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); } public function finding_exam_search() @@ -3933,7 +4003,112 @@ class Opd_prescription extends BaseController ->get() ->getResultArray(); - return $this->response->setJSON(['rows' => $rows]); + $keywordRows = $this->fetchAutotypeKeywords( + 'finding_examinations', + $q, + max(0, (int) ($this->getCurrentUserId() ?? 0)), + 20 + ); + + $out = []; + $seen = []; + foreach ($rows as $row) { + $text = trim((string) ($row['finding_examinations'] ?? '')); + if ($text === '') { + continue; + } + $k = strtoupper($text); + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = ['finding_examinations' => $text]; + } + foreach ($keywordRows as $keyword) { + $k = strtoupper($keyword); + if ($keyword === '' || isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = ['finding_examinations' => $keyword]; + } + + return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); + } + + public function autotype_keyword_save() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->canAccessPrescription()) { + return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); + } + + $section = trim((string) $this->request->getPost('section')); + $keyword = trim((string) $this->request->getPost('keyword')); + $scope = strtolower(trim((string) $this->request->getPost('scope'))); + if ($scope === '') { + $scope = 'doctor'; + } + + if (! $this->isAutotypeKeywordSectionAllowed($section)) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported section']); + } + + $keyword = preg_replace('/\s+/', ' ', $keyword) ?? $keyword; + if (mb_strlen($keyword) < 2) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword must be at least 2 characters']); + } + + if (! $this->ensureAutotypeKeywordTable()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword storage unavailable']); + } + + $docId = max(0, (int) ($this->getCurrentUserId() ?? 0)); + if ($scope === 'master') { + $user = auth()->user(); + $canMaster = $user && method_exists($user, 'can') && ($user->can('doctor_work.template_workspace.access') || $user->can('doctor_work.access')); + if (! $canMaster) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'No permission to save master keyword']); + } + $docId = 0; + } + + $table = $this->db->table('opd_autotype_keyword_master'); + $existing = $table + ->where('doc_id', $docId) + ->where('section_key', $section) + ->where('keyword', $keyword) + ->get(1) + ->getRowArray(); + + if (! empty($existing)) { + $table->where('id', (int) ($existing['id'] ?? 0))->update([ + 'is_active' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } else { + $table->insert([ + 'doc_id' => $docId, + 'section_key' => $section, + 'keyword' => $keyword, + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + return $this->response->setJSON([ + 'update' => 1, + 'error_text' => $docId === 0 ? 'Keyword saved in master list' : 'Keyword saved for current doctor', + 'keyword' => $keyword, + 'section' => $section, + 'scope' => $docId === 0 ? 'master' : 'doctor', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); } // Legacy-compatible wrappers used by older autocomplete clients. @@ -6132,6 +6307,88 @@ class Opd_prescription extends BaseController return $this->db->tableExists('opd_clinical_template_usage'); } + private function ensureAutotypeKeywordTable(): bool + { + if ($this->db->tableExists('opd_autotype_keyword_master')) { + return true; + } + + try { + $sql = "CREATE TABLE IF NOT EXISTS opd_autotype_keyword_master ( + id INT AUTO_INCREMENT PRIMARY KEY, + doc_id INT NOT NULL DEFAULT 0, + section_key VARCHAR(80) NOT NULL, + keyword VARCHAR(255) NOT NULL, + is_active TINYINT NOT NULL DEFAULT 1, + created_at DATETIME NULL, + updated_at DATETIME NULL, + UNIQUE KEY uniq_doc_section_keyword (doc_id, section_key, keyword), + INDEX idx_section_keyword (section_key, keyword), + INDEX idx_doc_active (doc_id, is_active) + )"; + $this->db->query($sql); + } catch (\Throwable $e) { + return false; + } + + return $this->db->tableExists('opd_autotype_keyword_master'); + } + + private function isAutotypeKeywordSectionAllowed(string $section): bool + { + return in_array($section, ['complaints', 'provisional_diagnosis', 'finding_examinations'], true); + } + + /** + * @return array + */ + private function fetchAutotypeKeywords(string $section, string $q, int $docId, int $limit = 20): array + { + if ($q === '' || ! $this->isAutotypeKeywordSectionAllowed($section) || ! $this->ensureAutotypeKeywordTable()) { + return []; + } + + $builder = $this->db->table('opd_autotype_keyword_master') + ->select('keyword,doc_id') + ->where('is_active', 1) + ->where('section_key', $section) + ->like('keyword', $q) + ->orderBy('doc_id', 'DESC') + ->orderBy('updated_at', 'DESC') + ->limit(max(1, $limit)); + + if ($docId > 0) { + $builder->groupStart() + ->where('doc_id', 0) + ->orWhere('doc_id', $docId) + ->groupEnd(); + } else { + $builder->where('doc_id', 0); + } + + $rows = $builder->get()->getResultArray(); + if (empty($rows)) { + return []; + } + + $out = []; + $seen = []; + foreach ($rows as $row) { + $keyword = trim((string) ($row['keyword'] ?? '')); + if ($keyword === '') { + continue; + } + $k = strtoupper($keyword); + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = $keyword; + } + + return $out; + } + private function isTemplateSectionAllowed(string $section): bool { return in_array($section, ['finding_examinations', 'diagnosis', 'provisional_diagnosis', 'prescriber_remarks', 'advice'], true); diff --git a/app/Views/billing/opd_prescription_basic.php b/app/Views/billing/opd_prescription_basic.php index d393a28..08a072c 100644 --- a/app/Views/billing/opd_prescription_basic.php +++ b/app/Views/billing/opd_prescription_basic.php @@ -656,6 +656,7 @@ @@ -670,6 +671,7 @@ + @@ -698,6 +700,7 @@ + @@ -1192,6 +1195,11 @@ var medicineSearchLocalDbKey = 'opd_medicine_search_cache_v1'; var medicineSearchCacheMaxEntries = 250; var medicineSearchLocalDbReady = false; + var autotypeSuggestCache = {}; + var autotypeSuggestCacheTtlMs = 300000; + var autotypeSuggestCacheMaxEntries = 240; + var autotypeSuggestCacheLocalKey = 'opd_autotype_suggest_cache_v1'; + var autotypeSuggestCacheLocalReady = false; var medInputTimer = null; var crgcMedInputTimer = null; var medicineDoseMasterCache = { dose: [], when: [], freq: [], where: [] }; @@ -1201,6 +1209,7 @@ }; var patientId = id ?? 0) ?>; var doctorViewId = ; + autotypeSuggestCacheLocalKey = autotypeSuggestCacheLocalKey + '_' + doctorViewId; var consultSectionPreferenceKey = 'opd_consult_section_pref_' + doctorViewId; var consultSectionMeta = [ { key: 'pain_scale', selector: '#rx_sec_pain_scale', title: 'Pain Measurement Scale' }, @@ -1581,6 +1590,106 @@ return splitCommaTerms(term).pop() || ''; } + function getAutotypeSuggestCacheKey(endpointUrl, term) { + return (endpointUrl || '') + '|' + (term || '').toString().trim().toLowerCase(); + } + + function pruneAutotypeSuggestCache(nowMs) { + var now = nowMs || Date.now(); + var keys = Object.keys(autotypeSuggestCache || {}); + if (!keys.length) { + autotypeSuggestCache = {}; + return; + } + + var rows = []; + keys.forEach(function(k) { + var item = autotypeSuggestCache[k] || {}; + var ts = Number(item.ts || 0); + if (!ts || (now - ts) > autotypeSuggestCacheTtlMs) { + return; + } + rows.push({ key: k, ts: ts, rows: Array.isArray(item.rows) ? item.rows : [] }); + }); + + rows.sort(function(a, b) { return b.ts - a.ts; }); + if (rows.length > autotypeSuggestCacheMaxEntries) { + rows = rows.slice(0, autotypeSuggestCacheMaxEntries); + } + + var next = {}; + rows.forEach(function(item) { + next[item.key] = { ts: item.ts, rows: item.rows }; + }); + autotypeSuggestCache = next; + } + + function ensureAutotypeSuggestCacheLoaded() { + if (autotypeSuggestCacheLocalReady) { + return; + } + autotypeSuggestCacheLocalReady = true; + try { + var raw = localStorage.getItem(autotypeSuggestCacheLocalKey); + if (!raw) { + return; + } + var parsed = JSON.parse(raw); + var entries = parsed && parsed.entries ? parsed.entries : {}; + if (entries && typeof entries === 'object') { + autotypeSuggestCache = entries; + pruneAutotypeSuggestCache(Date.now()); + } + } catch (e) { + autotypeSuggestCache = {}; + } + } + + function persistAutotypeSuggestCache() { + try { + pruneAutotypeSuggestCache(Date.now()); + localStorage.setItem(autotypeSuggestCacheLocalKey, JSON.stringify({ + saved_at: Date.now(), + entries: autotypeSuggestCache + })); + } catch (e) { + // Ignore localStorage write failures. + } + } + + function getLastKeywordFromField(targetId) { + var text = ($('#' + targetId).val() || '').toString(); + var token = extractLastCommaTerm(text).toString().trim(); + if (!token) { + var parts = text.split(/[;,\n\r]+/); + token = (parts.pop() || '').toString().trim(); + } + token = token.replace(/^[-*\s]+/, '').trim(); + return token; + } + + function saveAutotypeKeyword(section, keyword, done) { + var scope = 'doctor'; + var useMaster = window.confirm('Save keyword to Master list for all doctors?\nChoose Cancel to save only for current doctor.'); + if (useMaster) { + scope = 'master'; + } + + apiPost('', { + section: section, + keyword: keyword, + scope: scope + }, function(data) { + if (data && Number(data.update || 0) === 1) { + autotypeSuggestCache = {}; + persistAutotypeSuggestCache(); + } + if (typeof done === 'function') { + done(data || {}); + } + }); + } + function bindLegacyCommaAutocomplete(selector, endpointUrl, extractValue) { var $field = $(selector); if (!$field.length || !($.ui && $.ui.autocomplete)) { @@ -1602,6 +1711,15 @@ return; } + ensureAutotypeSuggestCacheLoaded(); + var cacheKey = getAutotypeSuggestCacheKey(endpointUrl, term); + var hit = autotypeSuggestCache[cacheKey]; + var now = Date.now(); + if (hit && (now - Number(hit.ts || 0) < autotypeSuggestCacheTtlMs)) { + response(hit.rows || []); + return; + } + $.getJSON(endpointUrl, { q: term }, function(data) { var rows = (data && data.rows) ? data.rows : []; var out = []; @@ -1616,6 +1734,8 @@ out.push({ label: value, value: value }); } }); + autotypeSuggestCache[cacheKey] = { ts: Date.now(), rows: out }; + persistAutotypeSuggestCache(); response(out); }).fail(function() { response([]); @@ -5827,6 +5947,28 @@ loadOldPrescribedPanel(); }); + $(document).on('click', '.btn-save-autotype-keyword', function() { + var section = ($(this).data('section') || '').toString(); + var targetId = ($(this).data('target') || '').toString(); + if (!section || !targetId) { + return; + } + + var keyword = getLastKeywordFromField(targetId); + if (!keyword || keyword.length < 2) { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Type at least 2 characters in this field, then click Save keyword.'); + return; + } + + saveAutotypeKeyword(section, keyword, function(data) { + if (Number(data.update || 0) === 1) { + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text((data.error_text || 'Keyword saved') + ': ' + keyword); + } else { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text(data.error_text || 'Unable to save keyword.'); + } + }); + }); + initConsultSectionDesigner(); bindLegacyCommaAutocomplete('#complaints', '', function(row) {