diff --git a/cypress/pageobject/Patient/PatientLogupdate.ts b/cypress/pageobject/Patient/PatientLogupdate.ts index d7b49fde05e..68287bfae41 100644 --- a/cypress/pageobject/Patient/PatientLogupdate.ts +++ b/cypress/pageobject/Patient/PatientLogupdate.ts @@ -51,7 +51,7 @@ class PatientLogupdate { } typePulse(pulse: string) { - cy.typeAndSelectOption("#pulse", pulse); + cy.get("#pulse").click().type(pulse); } typeTemperature(temperature: string) { @@ -59,11 +59,11 @@ class PatientLogupdate { } typeRespiratory(respiratory: string) { - cy.typeAndSelectOption("#resp", respiratory); + cy.get("#resp").click().type(respiratory); } typeSpo2(spo: string) { - cy.typeAndSelectOption("#ventilator_spo2", spo); + cy.get("#ventilator_spo2").click().type(spo); } selectRhythm(rhythm: string) { diff --git a/public/locale/en.json b/public/locale/en.json index 1159c4ab0dc..2e8469a3667 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -262,6 +262,8 @@ "abha_number_exists_description": "There is an ABHA Number already linked with the given Aadhaar Number, Do you want to create a new ABHA Address?", "abha_number_linked_successfully": "ABHA Number has been linked successfully.", "abha_profile": "ABHA Profile", + "accept": "Accept", + "accept_all": "Accept All", "access_level": "Access Level", "action_irreversible": "This action is irreversible", "active": "Active", @@ -342,6 +344,7 @@ "auth_method_unsupported": "This authentication method is not supported, please try a different method", "authorize_shift_delete": "Authorize shift delete", "auto_generated_for_care": "Auto Generated for Care", + "autofilled_fields": "Autofilled Fields", "available_features": "Available Features", "available_in": "Available in", "average_weekly_working_hours": "Average weekly working hours", @@ -500,6 +503,8 @@ "continue_watching": "Continue watching", "contribute_github": "Contribute on Github", "copied_to_clipboard": "Copied to clipboard", + "copilot_thinking": "Copilot is thinking...", + "could_not_autofill": "We could not autofill any fields from what you said", "countries_travelled": "Countries travelled", "covid_19_cat_gov": "Covid_19 Clinical Category as per Govt. of Kerala guideline (A/B/C)", "covid_19_death_reporting_form_1": "Covid-19 Death Reporting : Form 1", @@ -722,6 +727,7 @@ "health_facility__registered_2": "No Action Required", "health_facility__registered_3": "Registered", "health_facility__validation__hf_id_required": "Health Facility Id is required", + "hearing": "We are hearing you...", "help_confirmed": "There is sufficient diagnostic and/or clinical evidence to treat this as a confirmed condition.", "help_differential": "One of a set of potential (and typically mutually exclusive) diagnoses asserted to further guide the diagnostic process and preliminary treatment.", "help_entered-in-error": "The statement was entered in error and is not valid.", @@ -1041,6 +1047,7 @@ "prn_prescriptions": "PRN Prescriptions", "procedure_suggestions": "Procedure Suggestions", "procedures_select_placeholder": "Select procedures to add details", + "process_transcript": "Process Again", "profile": "Profile", "provisional": "Provisional", "qualification": "Qualification", @@ -1068,6 +1075,7 @@ "refuted": "Refuted", "register_hospital": "Register Hospital", "register_page_title": "Register As Hospital Administrator", + "reject": "Reject", "reload": "Reload", "remove": "Remove", "rename": "Rename", @@ -1097,6 +1105,7 @@ "result_on": "Result on", "resume": "Resume", "retake": "Retake", + "retake_recording": "Retake Recording", "return_to_care": "Return to CARE", "return_to_login": "Return to Login", "return_to_password_reset": "Return to Password Reset", @@ -1117,6 +1126,8 @@ "save_and_continue": "Save and Continue", "save_investigation": "Save Investigation", "scan_asset_qr": "Scan Asset QR!", + "scribe__reviewing_field": "Reviewing field {{currentField}} / {{totalFields}}", + "scribe_error": "Could not autofill fields", "search_by_username": "Search by username", "search_for_facility": "Search for Facility", "search_icd11_placeholder": "Search for ICD-11 Diagnoses", @@ -1181,9 +1192,11 @@ "staff_list": "Staff List", "start_datetime": "Start Date/Time", "start_dosage": "Start Dosage", + "start_review": "Start Review", "state": "State", "status": "Status", "stop": "Stop", + "stop_recording": "Stop Recording", "stream_stop_due_to_inativity": "The live feed will stop streaming due to inactivity", "stream_stopped_due_to_inativity": "The live feed has stopped streaming due to inactivity", "stream_uuid": "Stream UUID", @@ -1200,6 +1213,7 @@ "support": "Support", "switch": "Switch", "switch_camera_is_not_available": "Switch camera is not available.", + "symptoms": "Symptoms", "systolic": "Systolic", "tachycardia": "Tachycardia", "target_dosage": "Target Dosage", @@ -1212,6 +1226,8 @@ "total_beds": "Total Beds", "total_staff": "Total Staff", "total_users": "Total Users", + "transcript_edit_info": "You can update this if we made an error", + "transcript_information": "This is what we heard", "transfer_in_progress": "TRANSFER IN PROGRESS", "transfer_to_receiving_facility": "Transfer to receiving facility", "travel_within_last_28_days": "Domestic/international Travel (within last 28 days)", @@ -1301,6 +1317,7 @@ "vitals": "Vitals", "vitals_monitor": "Vitals Monitor", "vitals_present": "Vitals Monitor present", + "voice_autofill": "Voice Autofill", "ward": "Ward", "warranty_amc_expiry": "Warranty / AMC Expiry", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", diff --git a/src/CAREUI/interactive/KeyboardShortcut.tsx b/src/CAREUI/interactive/KeyboardShortcut.tsx index 07f3af6a429..ab825d4aada 100644 --- a/src/CAREUI/interactive/KeyboardShortcut.tsx +++ b/src/CAREUI/interactive/KeyboardShortcut.tsx @@ -1,6 +1,7 @@ +import { Fragment } from "react/jsx-runtime"; import useKeyboardShortcut from "use-keyboard-shortcut"; -import { classNames, isAppleDevice } from "@/Utils/utils"; +import { classNames, isAppleDevice } from "../../Utils/utils"; interface Props { children?: React.ReactNode; @@ -70,3 +71,29 @@ const SHORTCUT_KEY_MAP = { ArrowLeft: "←", ArrowRight: "→", } as Record; + +export function KeyboardShortcutKey(props: { + shortcut: string[]; + useShortKeys?: boolean; +}) { + const { shortcut, useShortKeys } = props; + + return ( +
+ {shortcut.map((key, idx, keys) => ( + + + {SHORTCUT_KEY_MAP[key] + ? useShortKeys + ? SHORTCUT_KEY_MAP[key][0] + : SHORTCUT_KEY_MAP[key] + : key} + + {idx !== keys.length - 1 && ( + + + )} + + ))} +
+ ); +} diff --git a/src/Routers/AppRouter.tsx b/src/Routers/AppRouter.tsx index 686094c9be1..125ee8c9055 100644 --- a/src/Routers/AppRouter.tsx +++ b/src/Routers/AppRouter.tsx @@ -158,7 +158,10 @@ export default function AppRouter() { id="pages" className="flex-1 overflow-y-scroll bg-gray-100 pb-4 focus:outline-none md:py-0" > -
+
{pages}
diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index d6d30dfce85..40021007773 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -81,7 +81,6 @@ import { CreateFileResponse, FileUploadModel, } from "@/components/Patient/models"; -import { ScribeModel } from "@/components/Scribe/Scribe"; import { SkillModel, SkillObjectModel, @@ -111,36 +110,6 @@ export interface LoginCredentials { } const routes = { - createScribe: { - path: "/api/care_scribe/scribe/", - method: "POST", - TReq: Type(), - TRes: Type(), - }, - getScribe: { - path: "/api/care_scribe/scribe/{external_id}/", - method: "GET", - TRes: Type(), - }, - updateScribe: { - path: "/api/care_scribe/scribe/{external_id}/", - method: "PUT", - TReq: Type(), - TRes: Type(), - }, - createScribeFileUpload: { - path: "/api/care_scribe/scribe_file/", - method: "POST", - TBody: Type(), - TRes: Type(), - }, - editScribeFileUpload: { - path: "/api/care_scribe/scribe_file/{id}/?file_type={fileType}&associating_id={associatingId}", - method: "PATCH", - TBody: Type>(), - TRes: Type(), - }, - // Auth Endpoints login: { path: "/api/v1/auth/login/", diff --git a/src/Utils/useSegmentedRecorder.ts b/src/Utils/useSegmentedRecorder.ts index c10379a9d12..806e80064a2 100644 --- a/src/Utils/useSegmentedRecorder.ts +++ b/src/Utils/useSegmentedRecorder.ts @@ -8,6 +8,7 @@ const useSegmentedRecording = () => { const [recorder, setRecorder] = useState(null); const [audioBlobs, setAudioBlobs] = useState([]); const [restart, setRestart] = useState(false); + const [microphoneAccess, setMicrophoneAccess] = useState(false); // New state const { t } = useTranslation(); const bufferInterval = 1 * 1000; @@ -25,6 +26,7 @@ const useSegmentedRecording = () => { requestRecorder().then( (newRecorder) => { setRecorder(newRecorder); + setMicrophoneAccess(true); // Set access to true on success if (restart) { setIsRecording(true); } @@ -34,6 +36,7 @@ const useSegmentedRecording = () => { msg: t("audio__permission_message"), }); setIsRecording(false); + setMicrophoneAccess(false); // Set access to false on failure }, ); } @@ -98,8 +101,16 @@ const useSegmentedRecording = () => { return () => recorder.removeEventListener("dataavailable", handleData); }, [recorder, isRecording, bufferInterval, audioBlobs, restart]); - const startRecording = () => { - setIsRecording(true); + const startRecording = async () => { + try { + const newRecorder = await requestRecorder(); + setRecorder(newRecorder); + setMicrophoneAccess(true); + setIsRecording(true); + } catch (error) { + setMicrophoneAccess(false); + throw new Error("Microphone access denied"); + } }; const stopRecording = () => { @@ -116,6 +127,7 @@ const useSegmentedRecording = () => { stopRecording, resetRecording, audioBlobs, + microphoneAccess, // Return microphoneAccess }; }; diff --git a/src/Utils/useValueInjectionObserver.tsx b/src/Utils/useValueInjectionObserver.tsx new file mode 100644 index 00000000000..ee1530bd3a0 --- /dev/null +++ b/src/Utils/useValueInjectionObserver.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; + +/** + * A custom React hook that observes changes to a specific attribute of a DOM element + * and returns a new value when the attribute changes. It is primarily useful + * for detecting updates to the value of a custom CUI component through layers where + * the component's state cannot be determined or mutated (example. CARE Scribe). + * + * @template T + * @param {Object} options - Configuration options for the observer. + * @param {HTMLElement | null} options.targetElement - The DOM element to observe for attribute changes. + * @param {string} [options.attribute="value"] - The name of the attribute to observe (default is "value"). + * + * @example + * const targetElement = document.getElementById('my-input'); + * const DOMValue = useValueInjectionObserver({ + * targetElement: targetElement, + * attribute: 'value', + * }); + * + * @returns {unknown} This hook returns the current value of the attribute. + */ +export function useValueInjectionObserver(options: { + targetElement: HTMLElement | null; + attribute?: string; +}) { + const { targetElement, attribute = "value" } = options; + const [value, setValue] = useState(); + + useEffect(() => { + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === attribute + ) { + const newValue = JSON.parse( + targetElement?.getAttribute(attribute) || '""', + ); + setValue(newValue); + } + }); + }); + + const config = { + attributes: true, + attributeFilter: [attribute], + }; + + targetElement && observer.observe(targetElement, config); + return () => observer.disconnect(); + }, [targetElement]); + + return value; +} diff --git a/src/Utils/useVoiceRecorder.ts b/src/Utils/useVoiceRecorder.ts new file mode 100644 index 00000000000..119a5b24bf5 --- /dev/null +++ b/src/Utils/useVoiceRecorder.ts @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; + +import * as Notify from "./Notifications"; + +const useVoiceRecorder = (handleMicPermission: (allowed: boolean) => void) => { + const [audioURL, setAudioURL] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [recorder, setRecorder] = useState(null); + const [blob, setBlob] = useState(null); + const [waveform, setWaveform] = useState([]); // Decibel waveform + + let audioContext: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let source: MediaStreamAudioSourceNode | null = null; + + useEffect(() => { + if (!isRecording && recorder && audioURL) { + setRecorder(null); + } + }, [isRecording, recorder, audioURL]); + + useEffect(() => { + const initializeRecorder = async () => { + try { + const fetchedRecorder = await requestRecorder(); + setRecorder(fetchedRecorder); + handleMicPermission(true); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Please grant microphone permission to record audio."; + Notify.Error({ + msg: errorMessage, + }); + setIsRecording(false); + handleMicPermission(false); + } + }; + // Lazily obtain recorder the first time we are recording. + if (recorder === null) { + if (isRecording) { + initializeRecorder(); + } + return; + } + + if (isRecording) { + recorder.start(); + setupAudioAnalyser(); + } else { + recorder.stream.getTracks().forEach((i) => i.stop()); + recorder.stop(); + if (audioContext) { + audioContext.close(); + } + } + + const handleData = (e: BlobEvent) => { + const url = URL.createObjectURL(e.data); + setAudioURL(url); + const blob = new Blob([e.data], { type: "audio/mpeg" }); + setBlob(blob); + }; + + recorder.addEventListener("dataavailable", handleData); + return () => { + recorder.removeEventListener("dataavailable", handleData); + if (audioContext) { + audioContext.close(); + } + }; + }, [recorder, isRecording]); + + const setupAudioAnalyser = () => { + let animationFrameId: number; + audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + analyser = audioContext.createAnalyser(); + analyser.fftSize = 32; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + source = audioContext.createMediaStreamSource( + recorder?.stream as MediaStream, + ); + source.connect(analyser); + + const updateWaveform = () => { + if (isRecording) { + analyser?.getByteFrequencyData(dataArray); + const normalizedWaveform = Array.from(dataArray).map((value) => + Math.min(100, (value / 255) * 100), + ); + setWaveform(normalizedWaveform); + animationFrameId = requestAnimationFrame(updateWaveform); + } else { + cancelAnimationFrame(animationFrameId); + source?.disconnect(); + analyser?.disconnect(); + } + }; + + updateWaveform(); + }; + + const startRecording = () => { + setIsRecording(true); + }; + + const stopRecording = () => { + setIsRecording(false); + setWaveform([]); + }; + + const resetRecording = () => { + setAudioURL(""); + setBlob(null); + setWaveform([]); + }; + + return { + audioURL, + isRecording, + startRecording, + stopRecording, + blob, + waveform, + resetRecording, + }; +}; + +async function requestRecorder() { + const constraints: MediaStreamConstraints = { + audio: { + echoCancellation: true, + noiseSuppression: true, + // iOS Safari requires these constraints + sampleRate: 44100, + channelCount: 1, + }, + }; + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + // iOS Safari requires a different mime type + const options = { + mimeType: MediaRecorder.isTypeSupported("audio/webm") + ? "audio/webm" + : "audio/mp4", + }; + return new MediaRecorder(stream, options); + } catch (error) { + throw new Error( + `Failed to initialize recorder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} +export default useVoiceRecorder; diff --git a/src/components/Common/BloodPressureFormField.tsx b/src/components/Common/BloodPressureFormField.tsx index 77ef37fe5d7..2b928029d3d 100644 --- a/src/components/Common/BloodPressureFormField.tsx +++ b/src/components/Common/BloodPressureFormField.tsx @@ -49,7 +49,9 @@ export default function BloodPressureFormField(props: Props) { labelClassName="hidden" errorClassName="hidden" /> - / + + / + = ({ const minutes = dayjs(value).minute(); const ampm = dayjs(value).hour() > 11 ? "PM" : "AM"; + const dateInputRef = useRef(null); const hourScrollerRef = useRef(null); const minuteScrollerRef = useRef(null); @@ -289,10 +291,25 @@ const DateInputV2: React.FC = ({ return `${right ? "md:-translate-x-1/2" : ""} ${top ? "md:-translate-y-[calc(100%+50px)]" : ""}`; }; + const domValue = useValueInjectionObserver({ + targetElement: dateInputRef.current, + attribute: "data-cui-dateinput-value", + }); + + useEffect(() => { + if (value !== domValue && typeof domValue !== "undefined") + onChange(dayjs(domValue).toDate()); + }, [domValue]); + return (
{({ open, close }) => { @@ -304,13 +321,14 @@ const DateInputV2: React.FC = ({ className="w-full" ref={popoverButtonRef} > - + ) => { const newValue = e.value; + setInputValue(newValue); - const regex = /^-?\d*\.?\d{0,1}$/; - if (regex.test(newValue)) { - setInputValue(newValue); - } - }; - - const handleBlur = () => { - if (!inputValue) return; - const parsedValue = parseFloat(inputValue); - if (isNaN(parsedValue)) return; + if (Number.isNaN(newValue)) return; - const finalValue = - unit === "celsius" - ? celsiusToFahrenheit(parsedValue).toString() - : parsedValue.toString(); + const valueInFahrenheit = + unit === "celsius" ? celsiusToFahrenheit(Number(newValue)) : newValue; - setInputValue(finalValue); - onChange({ name, value: finalValue }); + onChange({ name, value: valueInFahrenheit.toString() }); }; return ( @@ -82,7 +71,6 @@ export default function TemperatureFormField({ max={`${unit === "celsius" ? 41.1 : 106}`} step={0.1} onChange={handleInputChange} - onBlur={handleBlur} autoComplete="off" error={error} /> diff --git a/src/components/Common/prescription-builder/PrescriptionDropdown.tsx b/src/components/Common/prescription-builder/PrescriptionDropdown.tsx index 901fe1db9d6..1a726e5fd85 100644 --- a/src/components/Common/prescription-builder/PrescriptionDropdown.tsx +++ b/src/components/Common/prescription-builder/PrescriptionDropdown.tsx @@ -59,7 +59,7 @@ export function PrescriptionDropdown(props: { > {options.map((option, i) => { return ( -
+
- ); - case "recording": - return ( - - ); - case "submit": - return

Processing...

; - default: - return null; - } - }; - - function getStageMessage(stage: string) { - if (errors?.length > 0) return "Errored out. Please try again."; - if (isGPTProcessing) return "Extracting form data from transcript..."; - if (isAudioUploading) return "Uploading audio..."; - if (isTranscribing) return "Transcribing audio..."; - switch (stage) { - case "start": - return "Click the microphone to start recording"; - case "recording": - return "Recording..."; - case "recording-review": - return "Uploading audio..."; - case "review": - return "Transcript generated"; - case "submit": - return "Generating form data from transcript..."; - case "final-review": - return "Form data extracted. Please review."; - default: - return ""; - } - } - - if (!featureFlags.includes("SCRIBE_ENABLED")) return null; - - return ( - - - setOpen(!open)} - className="rounded py-2 font-bold" - > - - Voice AutoFill - - - {open && ( - -
- - -
-
-

- Voice AutoFill -

- - { - handleRerecordClick(); - setOpen(false); - }} - className={`flex items-center justify-center rounded-full bg-white p-2 font-bold text-red-500 transition duration-150 ease-in-out hover:bg-red-200 ${ - stage === "start" && "opacity-0 hover:cursor-default" - }`} - > - - -
- - {(stage === "review" || - stage === "final-review" || - stage === "recording-review") && ( -
-

- Recorded Audio -

- {audioBlobs.length > 0 && - audioBlobs.map((blob, index) => ( -
- )} - - {(stage === "review" || stage === "final-review") && ( -
-
-

- Transcript -

-

- (Edit if needed) -

-
-