diff --git a/.gitignore b/.gitignore index d9ca413..7f9355a 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,8 @@ writable/uploads/* writable/debugbar/* !writable/debugbar/index.html +writable/ayushman/* + php_errors.log #------------------------- @@ -124,3 +126,6 @@ _modules/* /results/ /phpunit*.xml + +public/uploads/* +!public/uploads/.gitkeep diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 623fbe5..396d23b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -9,6 +9,7 @@ $routes->get('/', 'Home::index'); $routes->get('dashboard', 'Home::dashboard'); $routes->get('my-profile', 'Home::myProfile'); $routes->post('my-profile/save', 'Home::myProfileSave'); +$routes->get('speech-test', 'Home::speechTest'); // Pharmacy (Medical) - phase 1 $routes->get('Medical', 'Medical::index'); diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 3e54cd2..15f4870 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -236,6 +236,16 @@ class Home extends BaseController ]); } + public function speechTest() + { + $authRedirect = $this->ensureAuthenticated(); + if ($authRedirect !== null) { + return $authRedirect; + } + + return view('tools/speech_test'); + } + public function myProfileSave() { $authRedirect = $this->ensureAuthenticated(); diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index 9780bb5..f3aab31 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -1745,14 +1745,21 @@ class Opd extends BaseController $i = 0; foreach ($rxMeds as $med) { $i++; - $name = trim((string) ($med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? '')); - $dose = trim((string) ($med['drug_dose'] ?? $med['dose'] ?? '')); - $freq = trim((string) ($med['drug_freq'] ?? $med['frequency'] ?? '')); - $days = trim((string) ($med['drug_day'] ?? $med['days'] ?? '')); - $inst = trim((string) ($med['drug_instruction'] ?? $med['instruction'] ?? '')); + $name = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? '')); + $generic = trim((string) ($med['genericname'] ?? ($med['generic_name'] ?? ($med['salt_name'] ?? '')))); + $dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? '')); + $freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? '')); + $days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? '')); + $when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? '')); + $remark = trim((string) ($med['remark'] ?? '')); + $inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : '')); + $nameHtml = esc($name); + if ($generic !== '') { + $nameHtml .= '
Salt/Generic: ' . esc($generic) . '
'; + } $medicalHtml .= '' . '' . $i . '' - . '' . esc($name) . '' + . '' . $nameHtml . '' . '' . esc(trim($dose . ' ' . $freq)) . '' . '' . esc($days) . '' . '' . esc($inst) . '' @@ -1785,6 +1792,26 @@ class Opd extends BaseController $rxAdvice = implode(', ', $parts); } + $complaintText = (string) ($rx['complaints'] ?? ''); + $diagnosisText = (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? '')); + $complaintLocal = $this->translateToLocalPatientText($complaintText); + $diagnosisLocal = $this->translateToLocalPatientText($diagnosisText); + $investigationLocal = $this->translateToLocalPatientText($rxInvestigation); + $rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice); + + if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) { + $complaintLocal = ''; + } + if (strtolower(trim($diagnosisLocal)) === strtolower(trim($diagnosisText))) { + $diagnosisLocal = ''; + } + if (strtolower(trim($investigationLocal)) === strtolower(trim($rxInvestigation))) { + $investigationLocal = ''; + } + if (strtolower(trim($rxAdviceLocal)) === strtolower(trim($rxAdvice))) { + $rxAdviceLocal = ''; + } + $bp = trim((string) ($rx['bp'] ?? '')); $diastolic = trim((string) ($rx['diastolic'] ?? '')); $pulse = trim((string) ($rx['pulse'] ?? '')); @@ -1856,10 +1883,13 @@ class Opd extends BaseController 'total_no_visit' => $totalNoVisit, 'last_opdvisit_date' => $lastVisitText, 'str_opd_book_date' => $bookTime, - 'Complaint' => (string) ($rx['complaints'] ?? ''), - 'diagnosis' => (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? '')), + 'Complaint' => $complaintText, + 'Complaint_local' => $complaintLocal, + 'diagnosis' => $diagnosisText, + 'diagnosis_local' => $diagnosisLocal, 'Provisional_diagnosis' => (string) ($rx['Provisional_diagnosis'] ?? ''), 'investigation' => $rxInvestigation, + 'investigation_local' => $investigationLocal, 'medical' => $medicalHtml, 'doctor' => '

Dr. ' . esc((string) ($opd->doc_name ?? '')) . '

', 'top_content' => '', @@ -1867,6 +1897,7 @@ class Opd extends BaseController 'Finding_Examinations' => (string) ($rx['Finding_Examinations'] ?? ''), 'Prescriber_Remarks' => (string) ($rx['Prescriber_Remarks'] ?? ''), 'advice' => $rxAdvice, + 'advice_local' => $rxAdviceLocal, 'next_visit' => (string) ($rx['next_visit'] ?? ''), 'refer_to' => (string) ($rx['refer_to'] ?? ''), 'painscale' => '', @@ -1877,6 +1908,69 @@ class Opd extends BaseController ]; } + private function translateToLocalPatientText(string $input): string + { + $text = trim($input); + if ($text === '') { + return ''; + } + + $map = [ + 'before food' => 'भोजन से पहले', + 'after food' => 'भोजन के बाद', + 'after breakfast' => 'नाश्ते के बाद', + 'before breakfast' => 'नाश्ते से पहले', + 'after lunch' => 'दोपहर के भोजन के बाद', + 'before lunch' => 'दोपहर के भोजन से पहले', + 'after dinner' => 'रात के भोजन के बाद', + 'before dinner' => 'रात के भोजन से पहले', + 'morning' => 'सुबह', + 'afternoon' => 'दोपहर', + 'evening' => 'शाम', + 'night' => 'रात', + 'once daily' => 'दिन में एक बार', + 'twice daily' => 'दिन में दो बार', + 'thrice daily' => 'दिन में तीन बार', + 'daily' => 'रोज', + 'weekly' => 'साप्ताहिक', + 'for' => 'के लिए', + 'days' => 'दिन', + 'day' => 'दिन', + 'tab' => 'टैबलेट', + 'cap' => 'कैप्सूल', + 'syp' => 'सिरप', + 'inj' => 'इंजेक्शन', + 'apply' => 'लगाएं', + 'continue' => 'जारी रखें', + 'rest' => 'आराम करें', + 'hydrate' => 'पर्याप्त पानी लें', + 'avoid oily food' => 'तैलीय भोजन से बचें', + 'avoid spicy food' => 'मसालेदार भोजन से बचें', + 'follow up' => 'फॉलो-अप करें', + 'review' => 'पुनः जांच', + 'advice' => 'सलाह', + 'investigation' => 'जांच', + 'diagnosis' => 'निदान', + 'fever' => 'बुखार', + 'cough' => 'खांसी', + 'cold' => 'जुकाम', + 'headache' => 'सिर दर्द', + 'abdominal pain' => 'पेट दर्द', + ]; + + uksort($map, static function ($a, $b) { + return strlen((string) $b) <=> strlen((string) $a); + }); + + $translated = $text; + foreach ($map as $en => $local) { + $translated = preg_replace('/\b' . preg_quote($en, '/') . '\b/i', $local, $translated) ?? $translated; + } + + $translated = preg_replace('/\s+/', ' ', trim($translated)) ?? trim($translated); + return $translated; + } + /** * @param array $vars */ @@ -2221,6 +2315,11 @@ class Opd extends BaseController $tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? '')); $tokens['content'] = (string) ($tokens['content'] ?? ''); + $adviceLocal = trim((string) ($tokens['advice_local'] ?? '')); + $tokens['advice_local_line'] = $adviceLocal !== '' + ? ('
सलाह: ' . $adviceLocal) + : ''; + return $tokens; } @@ -2499,6 +2598,115 @@ class Opd extends BaseController $medBuilder->orderBy('id', 'ASC'); } $data['rx_medicines'] = $medBuilder->get()->getResultArray(); + + if (!empty($data['rx_medicines'])) { + $mapDose = []; + $mapWhen = []; + $mapFreq = []; + $medGenericMap = []; + $missingGenericMedIds = []; + + if ($this->db->tableExists('opd_dose_shed')) { + $f = $this->db->getFieldNames('opd_dose_shed'); + $idF = $this->resolveFirstField($f, ['dose_shed_id', 'id']); + $labelF = $this->resolveFirstField($f, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']); + if ($idF !== null && $labelF !== null) { + $rows = $this->db->table('opd_dose_shed')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray(); + foreach ($rows as $r) { + $id = trim((string) ($r['id'] ?? '')); + $label = trim((string) ($r['label'] ?? '')); + if ($id !== '' && $label !== '') { + $mapDose[$id] = $label; + } + } + } + } + + if ($this->db->tableExists('opd_dose_when')) { + $f = $this->db->getFieldNames('opd_dose_when'); + $idF = $this->resolveFirstField($f, ['dose_when_id', 'id']); + $labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']); + if ($idF !== null && $labelF !== null) { + $rows = $this->db->table('opd_dose_when')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray(); + foreach ($rows as $r) { + $id = trim((string) ($r['id'] ?? '')); + $label = trim((string) ($r['label'] ?? '')); + if ($id !== '' && $label !== '') { + $mapWhen[$id] = $label; + } + } + } + } + + if ($this->db->tableExists('opd_dose_frequency')) { + $f = $this->db->getFieldNames('opd_dose_frequency'); + $idF = $this->resolveFirstField($f, ['dose_freq_id', 'id']); + $labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']); + if ($idF !== null && $labelF !== null) { + $rows = $this->db->table('opd_dose_frequency')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray(); + foreach ($rows as $r) { + $id = trim((string) ($r['id'] ?? '')); + $label = trim((string) ($r['label'] ?? '')); + if ($id !== '' && $label !== '') { + $mapFreq[$id] = $label; + } + } + } + } + + foreach ($data['rx_medicines'] as &$medRow) { + $doseRaw = trim((string) ($medRow['dosage'] ?? '')); + $whenRaw = trim((string) ($medRow['dosage_when'] ?? '')); + $freqRaw = trim((string) ($medRow['dosage_freq'] ?? '')); + $medRow['dosage_label'] = ($doseRaw !== '' && isset($mapDose[$doseRaw])) ? $mapDose[$doseRaw] : $doseRaw; + $medRow['dosage_when_label'] = ($whenRaw !== '' && isset($mapWhen[$whenRaw])) ? $mapWhen[$whenRaw] : $whenRaw; + $medRow['dosage_freq_label'] = ($freqRaw !== '' && isset($mapFreq[$freqRaw])) ? $mapFreq[$freqRaw] : $freqRaw; + + $genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? ''))); + if ($genericInline === '') { + $medId = (int) ($medRow['med_id'] ?? 0); + if ($medId > 0) { + $missingGenericMedIds[$medId] = true; + } + } + } + unset($medRow); + + if (!empty($missingGenericMedIds) && $this->db->tableExists('opd_med_master')) { + $masterFields = $this->db->getFieldNames('opd_med_master'); + $masterIdField = $this->resolveFirstField($masterFields, ['id']); + $masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']); + $masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']); + $labelField = $masterSaltField ?? $masterGenericField; + + if ($masterIdField !== null && $labelField !== null) { + $rows = $this->db->table('opd_med_master') + ->select($masterIdField . ' as med_id,' . $labelField . ' as generic_fallback') + ->whereIn($masterIdField, array_map('intval', array_keys($missingGenericMedIds))) + ->get() + ->getResultArray(); + + foreach ($rows as $r) { + $id = (int) ($r['med_id'] ?? 0); + $label = trim((string) ($r['generic_fallback'] ?? '')); + if ($id > 0 && $label !== '') { + $medGenericMap[$id] = $label; + } + } + } + } + + foreach ($data['rx_medicines'] as &$medRow) { + $genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? ''))); + if ($genericInline === '') { + $fallback = $medGenericMap[(int) ($medRow['med_id'] ?? 0)] ?? ''; + if ($fallback !== '') { + $medRow['genericname'] = $fallback; + } + } + } + unset($medRow); + } } else { $data['rx_medicines'] = []; } diff --git a/app/Views/billing/opd_prescription_basic.php b/app/Views/billing/opd_prescription_basic.php index ff8ce7b..93e2d6d 100644 --- a/app/Views/billing/opd_prescription_basic.php +++ b/app/Views/billing/opd_prescription_basic.php @@ -58,6 +58,20 @@ } .rx-save-status { font-size: .875rem; + display: flex; + align-items: center; + gap: .4rem; + min-width: 0; + max-width: 320px; + white-space: nowrap; + } + #rx_status_text { + display: inline-block; + min-width: 0; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .rx-tab-btn.active { background: #0d6efd; @@ -123,6 +137,56 @@ float: right; margin-top: -2px; } + .rx-label-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + flex-wrap: wrap; + margin-bottom: .25rem; + } + .rx-label-actions .rx-label-title { + font-weight: 600; + color: #344054; + } + .rx-complaint-actions { + display: inline-flex; + align-items: center; + gap: .35rem; + } + .rx-complaint-actions .rx-ai-btn { + float: none; + margin-top: 0; + } + .btn-complaints-rewrite { + border-color: #2b6cb0; + color: #2b6cb0; + background: #eef6ff; + } + .btn-complaints-rewrite:hover { + background: #2b6cb0; + color: #fff; + } + .btn-complaints-mic { + border-color: #13795b; + color: #13795b; + background: #ecfdf3; + min-width: 120px; + } + .btn-medical-stt { + border-color: #0f766e; + color: #0f766e; + background: #ecfeff; + min-width: 120px; + } + .btn-medical-stt:hover { + background: #0f766e; + color: #fff; + } + .btn-complaints-mic:hover { + background: #13795b; + color: #fff; + } .rx-scan-banner { display: none; border: 1px solid #b6d4fe; @@ -222,8 +286,6 @@
  • - - Back to Invoice
    @@ -499,18 +561,19 @@
    +
    0/4000
    -
    AI Assist is optional. Use it only if you want cleaner clinical wording; manual typing works as usual.
    - + @@ -524,7 +587,7 @@
    - + @@ -538,7 +601,7 @@
    - + @@ -552,7 +615,6 @@
    - @@ -572,7 +634,6 @@
    - @@ -717,6 +778,7 @@
    +
    @@ -777,6 +839,8 @@
    No suggestion available.
    + + @@ -883,6 +947,20 @@ var latestLocalAssist = null; var complaintSuggestions = []; var selectedComplaints = []; + var complaintsMicMode = 'off'; + var complaintsSpeechRecognition = null; + var complaintsMicActive = false; + var complaintsMediaRecorder = null; + var complaintsMediaStream = null; + var complaintsMediaChunks = []; + var complaintsBaseText = ''; + var medicalSttTarget = ''; + var medicalSttRecorder = null; + var medicalSttStream = null; + var medicalSttChunks = []; + var medicalSttActive = false; + var complaintsSttServerHealthUrl = 'http://139.59.13.39:8000/health'; + var complaintsSttServerTranscribeUrl = 'http://139.59.13.39:8000/stt/transcribe'; var draftKey = 'opd_rx_draft_' + ($('#opd_id').val() || '0'); var templateLoadState = { target: '', section: '', rows: [] }; var templateSaveState = { section: '', text: '', scope: 'doctor' }; @@ -894,6 +972,7 @@ var invCustomProfileKey = 'opd_inv_custom_profiles'; var legacyInvestigationProfiles = {}; var legacyInvestigationProfileList = []; + var investigationBatchActive = false; var activeMedicineScope = 'active'; var medicineSuggestRows = []; var investigationProfiles = { @@ -950,6 +1029,421 @@ } } + function setComplaintsMicMode(mode, note) { + complaintsMicMode = mode; + var $btn = $('#btn_complaints_mic'); + if (!$btn.length) { + return; + } + + $btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary btn-danger'); + + if (mode === 'server') { + $btn.addClass('btn-outline-success').prop('disabled', false).text('🎙 Server Mic'); + } else if (mode === 'browser') { + $btn.addClass('btn-outline-primary').prop('disabled', false).text('🎤 Browser Mic'); + } else { + $btn.addClass('btn-outline-secondary').prop('disabled', true).text('⏸ Mic Off'); + } + + if (note) { + $('.jsError').removeClass('text-danger text-success').addClass('text-muted').text(note); + } + } + + function setComplaintsMicListening(listening) { + var $btn = $('#btn_complaints_mic'); + if (!$btn.length) { + return; + } + + complaintsMicActive = listening; + if (listening) { + $btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary').addClass('btn-danger').text('⏹ Stop Mic'); + } else { + setComplaintsMicMode(complaintsMicMode); + } + } + + function appendTranscriptToComplaints(text) { + var transcript = (text || '').toString().trim(); + if (!transcript) { + return; + } + + var current = ($('#complaints').val() || '').toString().trim(); + var nextVal = current ? (current + '\n' + transcript) : transcript; + $('#complaints').val(nextVal); + refreshCounters(); + markDirty('Speech text added in complaints'); + scheduleAutoSave(); + } + + function appendTranscriptToField(targetId, text) { + var transcript = (text || '').toString().trim(); + if (!transcript || !targetId || !$('#' + targetId).length) { + return; + } + + var current = ($('#' + targetId).val() || '').toString().trim(); + var nextVal = current ? (current + '\n' + transcript) : transcript; + $('#' + targetId).val(nextVal); + refreshCounters(); + markDirty('Medical speech text added'); + scheduleAutoSave(); + } + + function getMedicalContextForField(targetId) { + if (targetId === 'finding_examinations') { + return 'examination findings'; + } + if (targetId === 'diagnosis') { + return 'diagnosis notes'; + } + if (targetId === 'provisional_diagnosis') { + return 'provisional diagnosis notes'; + } + return 'clinical notes'; + } + + function setMedicalMicButtonState(activeTarget) { + $('.btn-medical-stt').each(function() { + var $btn = $(this); + var target = ($btn.data('target') || '').toString(); + var baseText = ($btn.data('label') || '🎙 Med Mic').toString(); + + $btn.removeClass('btn-danger').addClass('btn-medical-stt').prop('disabled', false).text(baseText); + + if (activeTarget && target === activeTarget) { + $btn.removeClass('btn-medical-stt').addClass('btn-danger').text('⏹ Stop Mic'); + } + }); + } + + function stopMedicalSttStream() { + if (medicalSttStream && medicalSttStream.getTracks) { + medicalSttStream.getTracks().forEach(function(track) { + track.stop(); + }); + } + medicalSttStream = null; + medicalSttRecorder = null; + medicalSttChunks = []; + } + + function sendMedicalAudioToServer(audioBlob, targetId) { + var formData = new FormData(); + formData.append('audio', audioBlob, (targetId || 'clinical') + '.webm'); + formData.append('lang', 'en-IN'); + formData.append('medical_context', getMedicalContextForField(targetId)); + + return fetch(complaintsSttServerTranscribeUrl, { + method: 'POST', + mode: 'cors', + body: formData + }).then(function(response) { + if (!response.ok) { + return response.json().catch(function() { + return {}; + }).then(function(payload) { + var err = new Error(payload.detail || ('Server response: ' + response.status)); + err.statusCode = response.status; + throw err; + }); + } + return response.json(); + }).then(function(payload) { + var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim(); + if (!text) { + throw new Error('Empty transcript from server'); + } + return text; + }); + } + + function startMedicalServerStt(targetId) { + if (!targetId || !$('#' + targetId).length) { + return; + } + + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech is not available in this browser.'); + return; + } + + if (medicalSttActive && medicalSttRecorder) { + if (medicalSttTarget === targetId) { + medicalSttRecorder.stop(); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing medical transcript...'); + return; + } + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical mic is already recording another field. Stop it first.'); + return; + } + + navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) { + medicalSttTarget = targetId; + medicalSttStream = stream; + medicalSttRecorder = new MediaRecorder(stream); + medicalSttChunks = []; + + medicalSttRecorder.ondataavailable = function(event) { + if (event.data && event.data.size > 0) { + medicalSttChunks.push(event.data); + } + }; + + medicalSttRecorder.onstop = function() { + var blob = new Blob(medicalSttChunks, { + type: (medicalSttRecorder && medicalSttRecorder.mimeType) ? medicalSttRecorder.mimeType : 'audio/webm' + }); + var targetForTranscript = medicalSttTarget; + + stopMedicalSttStream(); + medicalSttActive = false; + medicalSttTarget = ''; + setMedicalMicButtonState(''); + + sendMedicalAudioToServer(blob, targetForTranscript).then(function(transcript) { + appendTranscriptToField(targetForTranscript, transcript); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Medical speech text added.'); + }).catch(function(error) { + if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech input error: ' + (error.message || 'Invalid request')); + return; + } + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech server unavailable. Please try again.'); + }); + }; + + medicalSttActive = true; + setMedicalMicButtonState(targetId); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording medical speech... click same Mic to stop.'); + medicalSttRecorder.start(); + }).catch(function() { + medicalSttActive = false; + medicalSttTarget = ''; + setMedicalMicButtonState(''); + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Microphone permission denied for medical speech.'); + }); + } + + function checkComplaintsSttServer() { + if (!window.fetch || !window.AbortController) { + return Promise.resolve(false); + } + + var controller = new AbortController(); + var timeoutId = window.setTimeout(function() { + controller.abort(); + }, 3500); + + return fetch(complaintsSttServerHealthUrl, { + method: 'GET', + mode: 'cors', + signal: controller.signal + }).then(function(response) { + return !!(response && response.ok); + }).catch(function() { + return false; + }).finally(function() { + window.clearTimeout(timeoutId); + }); + } + + function sendComplaintsAudioToServer(audioBlob) { + var formData = new FormData(); + formData.append('audio', audioBlob, 'complaints.webm'); + formData.append('lang', 'en-IN'); + formData.append('medical_context', (selectedComplaints || []).join(', ')); + + return fetch(complaintsSttServerTranscribeUrl, { + method: 'POST', + mode: 'cors', + body: formData + }).then(function(response) { + if (!response.ok) { + return response.json().catch(function() { + return {}; + }).then(function(payload) { + var err = new Error(payload.detail || ('Server response: ' + response.status)); + err.statusCode = response.status; + throw err; + }); + } + return response.json(); + }).then(function(payload) { + var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim(); + if (!text) { + throw new Error('Empty transcript from server'); + } + return text; + }); + } + + function stopComplaintsMediaStream() { + if (complaintsMediaStream && complaintsMediaStream.getTracks) { + complaintsMediaStream.getTracks().forEach(function(track) { + track.stop(); + }); + } + complaintsMediaStream = null; + complaintsMediaRecorder = null; + complaintsMediaChunks = []; + } + + function startComplaintsBrowserStt() { + if (!complaintsSpeechRecognition) { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech recognition is not available.'); + return; + } + + if (complaintsMicActive) { + complaintsSpeechRecognition.stop(); + return; + } + + setComplaintsMicListening(true); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Listening complaints (browser mode)...'); + + try { + complaintsSpeechRecognition.start(); + } catch (e) { + setComplaintsMicListening(false); + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Could not start browser speech recognition.'); + } + } + + function startComplaintsServerStt() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') { + if (complaintsSpeechRecognition) { + setComplaintsMicMode('browser', 'Server recording is unavailable. Switched to browser mode.'); + startComplaintsBrowserStt(); + return; + } + setComplaintsMicMode('off', 'Speech is not available in this browser.'); + return; + } + + if (complaintsMicActive && complaintsMediaRecorder) { + complaintsMediaRecorder.stop(); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing server transcript...'); + return; + } + + navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) { + complaintsMediaStream = stream; + complaintsMediaRecorder = new MediaRecorder(stream); + complaintsMediaChunks = []; + + complaintsMediaRecorder.ondataavailable = function(event) { + if (event.data && event.data.size > 0) { + complaintsMediaChunks.push(event.data); + } + }; + + complaintsMediaRecorder.onstop = function() { + var blob = new Blob(complaintsMediaChunks, { + type: (complaintsMediaRecorder && complaintsMediaRecorder.mimeType) ? complaintsMediaRecorder.mimeType : 'audio/webm' + }); + + stopComplaintsMediaStream(); + setComplaintsMicListening(false); + + sendComplaintsAudioToServer(blob).then(function(transcript) { + appendTranscriptToComplaints(transcript); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Complaints speech text added.'); + }).catch(function(error) { + if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Speech input error: ' + (error.message || 'Invalid request')); + return; + } + if (complaintsSpeechRecognition) { + setComplaintsMicMode('browser', 'Server STT unavailable. Switched to browser mode.'); + } else { + setComplaintsMicMode('off', 'Server STT unavailable and browser speech not supported.'); + } + }); + }; + + setComplaintsMicListening(true); + $('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording complaints... click Mic again to stop.'); + complaintsMediaRecorder.start(); + }).catch(function() { + if (complaintsSpeechRecognition) { + setComplaintsMicMode('browser', 'Microphone permission failed for server mode. Switched to browser mode.'); + } else { + setComplaintsMicMode('off', 'Microphone permission denied.'); + } + }); + } + + function initComplaintsSpeech() { + var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (SpeechRecognition) { + complaintsSpeechRecognition = new SpeechRecognition(); + complaintsSpeechRecognition.lang = 'en-IN'; + complaintsSpeechRecognition.interimResults = true; + complaintsSpeechRecognition.continuous = false; + + complaintsSpeechRecognition.onresult = function(event) { + var interimTranscript = ''; + var finalTranscript = ''; + for (var i = event.resultIndex; i < event.results.length; i++) { + var t = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalTranscript += t; + } else { + interimTranscript += t; + } + } + + if (interimTranscript) { + // Show interim as a non-destructive preview below the textarea + $('#complaints_interim_preview').text('🎤 ' + interimTranscript).show(); + } + + if (finalTranscript.trim()) { + // Final: clear preview, append once to the base text + $('#complaints_interim_preview').hide().text(''); + appendTranscriptToComplaints(finalTranscript.trim()); + complaintsBaseText = ($('#complaints').val() || '').toString().trim(); + } + }; + + complaintsSpeechRecognition.onerror = function(event) { + setComplaintsMicListening(false); + $('#complaints_interim_preview').hide().text(''); + $('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech error: ' + (event.error || 'unknown')); + }; + + complaintsSpeechRecognition.onend = function() { + setComplaintsMicListening(false); + $('#complaints_interim_preview').hide().text(''); + }; + } + + if (complaintsSpeechRecognition) { + setComplaintsMicMode('browser', 'Complaints Mic is ready (browser mode for local language).'); + } else { + setComplaintsMicMode('off', 'Complaints Mic unavailable in this browser.'); + } + + $('#btn_complaints_mic').on('click', function() { + if (complaintsMicMode === 'browser') { + startComplaintsBrowserStt(); + } + }); + } + + function initMedicalSpeechButtons() { + setMedicalMicButtonState(''); + $(document).on('click', '.btn-medical-stt', function() { + var targetId = ($(this).data('target') || '').toString(); + startMedicalServerStt(targetId); + }); + } + function getCustomInvestigationProfiles() { try { var parsed = JSON.parse(localStorage.getItem(invCustomProfileKey) || '[]'); @@ -1104,6 +1598,7 @@ function setStatus(type, message) { var $badge = $('#rx_status_badge'); var $text = $('#rx_status_text'); + var statusMessage = (message || '').toString(); $badge.removeClass('bg-secondary bg-warning bg-success bg-danger'); if (type === 'saved') { @@ -1118,7 +1613,7 @@ $badge.addClass('bg-secondary').text('Not Saved'); } - $text.text(message || ''); + $text.text(statusMessage).attr('title', statusMessage); } function updateScanBanner() { @@ -1879,6 +2374,40 @@ }); } + function renderTemplateSelectOptions(searchQuery) { + var list = Array.isArray(templateLoadState.rows) ? templateLoadState.rows : []; + var query = (searchQuery || '').toString().trim().toLowerCase(); + var $sel = $('#rx_template_select'); + $sel.empty(); + + var added = 0; + list.forEach(function(row, idx) { + var name = (row.template_name || ('Template ' + (idx + 1))).toString(); + var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] '; + var previewText = (row.template_text || '').toString(); + var haystack = (name + ' ' + previewText + ' ' + src).toLowerCase(); + if (query && haystack.indexOf(query) === -1) { + return; + } + $sel.append('