From caa411dcf93ee1ceb419ff7aa5b00183b07809e8 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Wed, 7 Aug 2024 20:13:12 +0530 Subject: [PATCH 1/3] Cypress: fix incorrect patient category select's id (#8267) --- cypress/pageobject/Patient/PatientLogupdate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/pageobject/Patient/PatientLogupdate.ts b/cypress/pageobject/Patient/PatientLogupdate.ts index bc141c04984..ac1bd6a4991 100644 --- a/cypress/pageobject/Patient/PatientLogupdate.ts +++ b/cypress/pageobject/Patient/PatientLogupdate.ts @@ -16,7 +16,7 @@ class PatientLogupdate { } selectPatientCategory(category: string) { - cy.clickAndSelectOption("#patient_category", category); + cy.clickAndSelectOption("#patientCategory", category); } typePhysicalExamination(examination: string) { From b552047d64a1b8fac950eb4ba5d5a39bc08b0bef Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 8 Aug 2024 16:20:26 +0530 Subject: [PATCH 2/3] =?UTF-8?q?Adds=20support=20for=20printing=20?= =?UTF-8?q?=F0=9F=96=A8=EF=B8=8F=20prescriptions=20=F0=9F=92=8A=20(#8259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adds support for printing prescriptions * Improve print preview layout * add links to reach the print page * update title of print output * Updated prescription print preview as per requirements * disable print if empty; add titration instructions; improve layout * remove todo comments :) * update disable logic --------- Co-authored-by: Mohammed Nihal <57055998+nihal467@users.noreply.github.com> --- src/CAREUI/misc/PrintPreview.tsx | 36 +++ .../Medicine/ManagePrescriptions.tsx | 10 +- .../MedicineAdministrationSheet/index.tsx | 44 ++- src/Components/Medicine/PrintPreview.tsx | 271 ++++++++++++++++++ src/Locale/en/Common.json | 5 +- src/Locale/en/Consultation.json | 6 + src/Locale/en/Medicine.json | 4 +- src/Routers/routes/ConsultationRoutes.tsx | 3 + 8 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 src/CAREUI/misc/PrintPreview.tsx create mode 100644 src/Components/Medicine/PrintPreview.tsx diff --git a/src/CAREUI/misc/PrintPreview.tsx b/src/CAREUI/misc/PrintPreview.tsx new file mode 100644 index 00000000000..243826c7337 --- /dev/null +++ b/src/CAREUI/misc/PrintPreview.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; +import ButtonV2 from "../../Components/Common/components/ButtonV2"; +import CareIcon from "../icons/CareIcon"; +import { classNames } from "../../Utils/utils"; +import Page from "../../Components/Common/components/Page"; + +type Props = { + children: ReactNode; + disabled?: boolean; + className?: string; + title: string; +}; + +export default function PrintPreview(props: Props) { + return ( + +
+
+ window.print()}> + + Print + +
+ +
+
+ {props.children} +
+
+
+
+ ); +} diff --git a/src/Components/Medicine/ManagePrescriptions.tsx b/src/Components/Medicine/ManagePrescriptions.tsx index a5ae50813a2..8409e721779 100644 --- a/src/Components/Medicine/ManagePrescriptions.tsx +++ b/src/Components/Medicine/ManagePrescriptions.tsx @@ -10,7 +10,15 @@ export default function ManagePrescriptions() { const { goBack } = useAppHistory(); return ( - + + + Print + + } + >
{ const prescriptionList = [ ...(data?.results ?? []), - ...(showDiscontinued ? discontinuedPrescriptions.data?.results ?? [] : []), + ...(showDiscontinued + ? (discontinuedPrescriptions.data?.results ?? []) + : []), ]; const { activityTimelineBounds, prescriptions } = useMemo( @@ -90,25 +92,37 @@ const MedicineAdministrationSheet = ({ readonly, is_prn }: Props) => { options={ !readonly && !!data?.results && ( - + <> + + + + + {t("edit_prescriptions")} + + {t("edit")} + + refetch()} + /> + - - - {t("edit_prescriptions")} - - {t("edit")} + + Print - refetch()} - /> - + ) } /> diff --git a/src/Components/Medicine/PrintPreview.tsx b/src/Components/Medicine/PrintPreview.tsx new file mode 100644 index 00000000000..09bad44d630 --- /dev/null +++ b/src/Components/Medicine/PrintPreview.tsx @@ -0,0 +1,271 @@ +import { useTranslation } from "react-i18next"; +import PrintPreview from "../../CAREUI/misc/PrintPreview"; +import { useSlugs } from "../../Common/hooks/useSlug"; +import routes from "../../Redux/api"; +import useQuery from "../../Utils/request/useQuery"; +import { + classNames, + formatDate, + formatDateTime, + formatName, + patientAgeInYears, +} from "../../Utils/utils"; +import MedicineRoutes from "./routes"; +import { Prescription } from "./models"; +import useConfig from "../../Common/hooks/useConfig"; +import { ReactNode } from "react"; + +export default function PrescriptionsPrintPreview() { + const { main_logo } = useConfig(); + const { t } = useTranslation(); + const [patientId, consultationId] = useSlugs("patient", "consultation"); + + const patientQuery = useQuery(routes.getPatient, { + pathParams: { id: patientId }, + }); + + const encounterQuery = useQuery(routes.getConsultation, { + pathParams: { id: consultationId }, + }); + + const prescriptionsQuery = useQuery(MedicineRoutes.listPrescriptions, { + pathParams: { consultation: consultationId }, + query: { discontinued: false, limit: 100 }, + }); + + const patient = patientQuery.data; + const encounter = encounterQuery.data; + + const items = prescriptionsQuery.data?.results; + const normalPrescriptions = items?.filter((p) => p.dosage_type !== "PRN"); + const prnPrescriptions = items?.filter((p) => p.dosage_type === "PRN"); + + return ( + +
+

{encounter?.facility_name}

+ care logo +
+
+ + {patient && ( + <> + {patient.name} -{" "} + {t(`GENDER__${patient.gender}`)},{" "} + {patientAgeInYears(patient).toString()}yrs + + )} + + + {encounter?.patient_no} + + + + {formatDate(encounter?.encounter_date)} + + + {encounter?.current_bed?.bed_object.location_object?.name} + {" - "} + {encounter?.current_bed?.bed_object.name} + + + + {patient?.allergies ?? "None"} + +
+ + + + +
+

+ Sign of the Consulting Doctor +

+ + {encounter?.treating_physician_object && + formatName(encounter?.treating_physician_object)} + +

+ Generated on: {formatDateTime(new Date())} +

+

+ This is a computer generated prescription. It shall be issued to the + patient only after the concerned doctor has verified the content and + authorized the same by affixing signature. +

+
+
+ ); +} + +const PatientDetail = ({ + name, + children, + className, +}: { + name: string; + children?: ReactNode; + className?: string; +}) => { + return ( +
+
{name}:
+ {children != null ? ( + {children} + ) : ( +
+ )} +
+ ); +}; + +const PrescriptionsTable = ({ + items, + prn, +}: { + items?: Prescription[]; + prn?: boolean; +}) => { + if (!items) { + return ( +
+ ); + } + + if (!items.length) { + return; + } + + return ( + + + + + + + + {/* */} + + + + + {items.map((item) => ( + + ))} + +
+ {prn && "PRN"} Prescriptions +
MedicineDosageDirections{prn ? "Indicator" : "Freq."}Notes / Instructions
+ ); +}; + +const PrescriptionEntry = ({ obj }: { obj: Prescription }) => { + const { t } = useTranslation(); + const medicine = obj.medicine_object; + + return ( + + +

+ + {medicine?.name ?? obj.medicine_old} + {" "} +

+ {medicine?.type === "brand" && ( + +

+ Generic:{" "} + + {medicine.generic ?? "--"} + +

+

+ Brand:{" "} + + {medicine.company ?? "--"} + +

+
+ )} + + + {obj.dosage_type === "TITRATED" &&

Titrated

} +

+ {obj.base_dosage}{" "} + {obj.target_dosage != null && `→ ${obj.target_dosage}`}{" "} +

+ {obj.max_dosage && ( +

+ Max. {obj.max_dosage} in + 24hrs +

+ )} + {obj.min_hours_between_doses && ( +

+ Min.{" "} + + {obj.min_hours_between_doses}hrs + {" "} + b/w doses +

+ )} + + + {obj.route && ( +

+ Route: + + {t(`PRESCRIPTION_ROUTE_${obj.route}`)} + +

+ )} + {obj.frequency && ( +

+ Freq: + + {t(`PRESCRIPTION_FREQUENCY_${obj.frequency}`)} + +

+ )} + {obj.days && ( +

+ Days: + {obj.days} day(s) +

+ )} + {obj.indicator && ( +

+ Indicator: + {obj.indicator} +

+ )} + + + {obj.notes} + {obj.instruction_on_titration && ( +

+ Titration instructions:{" "} + {obj.instruction_on_titration} +

+ )} + + + ); +}; diff --git a/src/Locale/en/Common.json b/src/Locale/en/Common.json index 0bd26305b86..707f8f74a0b 100644 --- a/src/Locale/en/Common.json +++ b/src/Locale/en/Common.json @@ -177,5 +177,8 @@ "caution": "Caution", "feed_optimal_experience_for_phones": "For optimal viewing experience, consider rotating your device.", "feed_optimal_experience_for_apple_phones": "For optimal viewing experience, consider rotating your device. Ensure auto-rotate is enabled in your device settings.", - "action_irreversible": "This action is irreversible" + "action_irreversible": "This action is irreversible", + "GENDER__1": "Male", + "GENDER__2": "Female", + "GENDER__3": "Non-binary" } \ No newline at end of file diff --git a/src/Locale/en/Consultation.json b/src/Locale/en/Consultation.json index 6e3846fb983..a76afe72410 100644 --- a/src/Locale/en/Consultation.json +++ b/src/Locale/en/Consultation.json @@ -38,6 +38,12 @@ "no_changes": "No changes", "no_treating_physicians_available": "This facility does not have any home facility doctors. Please contact your admin.", "encounter_suggestion_edit_disallowed": "Not allowed to switch to this option in edit consultation", + "encounter_suggestion__A": "Admission", + "encounter_suggestion__DC": "Domiciliary Care", + "encounter_suggestion__OP": "Out-patient visit", + "encounter_suggestion__DD": "Consultation", + "encounter_suggestion__HI": "Consultation", + "encounter_suggestion__R": "Consultation", "encounter_date_field_label__A": "Date & Time of Admission to the Facility", "encounter_date_field_label__DC": "Date & Time of Domiciliary Care commencement", "encounter_date_field_label__OP": "Date & Time of Out-patient visit", diff --git a/src/Locale/en/Medicine.json b/src/Locale/en/Medicine.json index d559ef2fdbf..80726d83fb2 100644 --- a/src/Locale/en/Medicine.json +++ b/src/Locale/en/Medicine.json @@ -47,7 +47,7 @@ "PRESCRIPTION_ROUTE_IM": "IM", "PRESCRIPTION_ROUTE_SC": "S/C", "PRESCRIPTION_ROUTE_INHALATION": "Inhalation", - "PRESCRIPTION_ROUTE_NASOGASTRIC": "Nasogastric/Gastrostomy tube", + "PRESCRIPTION_ROUTE_NASOGASTRIC": "Nasogastric / Gastrostomy tube", "PRESCRIPTION_ROUTE_INTRATHECAL": "intrathecal injection", "PRESCRIPTION_ROUTE_TRANSDERMAL": "Transdermal", "PRESCRIPTION_ROUTE_RECTAL": "Rectal", @@ -61,4 +61,4 @@ "PRESCRIPTION_FREQUENCY_Q4H": "4th hourly", "PRESCRIPTION_FREQUENCY_QOD": "Alternate day", "PRESCRIPTION_FREQUENCY_QWK": "Once a week" -} +} \ No newline at end of file diff --git a/src/Routers/routes/ConsultationRoutes.tsx b/src/Routers/routes/ConsultationRoutes.tsx index 8b75e3f147f..2484acca0fd 100644 --- a/src/Routers/routes/ConsultationRoutes.tsx +++ b/src/Routers/routes/ConsultationRoutes.tsx @@ -10,6 +10,7 @@ import { ConsultationDetails } from "../../Components/Facility/ConsultationDetai import TreatmentSummary from "../../Components/Facility/TreatmentSummary"; import ConsultationDoctorNotes from "../../Components/Facility/ConsultationDoctorNotes"; import PatientConsentRecords from "../../Components/Patient/PatientConsentRecords"; +import PrescriptionsPrintPreview from "../../Components/Medicine/PrintPreview"; export default { "/facility/:facilityId/patient/:patientId/consultation": ({ @@ -48,6 +49,8 @@ export default { ), "/facility/:facilityId/patient/:patientId/consultation/:consultationId/prescriptions": (path: any) => , + "/facility/:facilityId/patient/:patientId/consultation/:consultationId/prescriptions/print": + () => , "/facility/:facilityId/patient/:patientId/consultation/:id/investigation": ({ facilityId, patientId, From 12b57b9e7e6d0c00f8d069f4ef2e42b5bdf198cd Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Thu, 8 Aug 2024 18:08:55 +0530 Subject: [PATCH 3/3] Made Scribe work with Symptoms, Investigations, Diagnosis and Prescriptions (#8183) * Fixed Symptoms and Diagnosis * fixes * fixed bugs * fixed times, measured at, and rounds type * fix translation issue * minor enhancement * fixed rounds type * disable scribe * init * Added support for Investigations, and Prescriptions * undo config * Update src/Components/Scribe/formDetails.ts * Fix bug related to diagnosis update * cleanup * more cleanup --------- Co-authored-by: Mohammed Nihal <57055998+nihal467@users.noreply.github.com> --- .../InvestigationBuilder.tsx | 89 +++---- .../ConsultationDiagnosisBuilder.tsx | 7 +- src/Components/Patient/DailyRounds.tsx | 140 +++++++++- src/Components/Scribe/Scribe.tsx | 140 +++++++--- src/Components/Scribe/formDetails.ts | 239 ++++++++++++++++-- 5 files changed, 510 insertions(+), 105 deletions(-) diff --git a/src/Components/Common/prescription-builder/InvestigationBuilder.tsx b/src/Components/Common/prescription-builder/InvestigationBuilder.tsx index ef99a7f1acc..ce7c579ee22 100644 --- a/src/Components/Common/prescription-builder/InvestigationBuilder.tsx +++ b/src/Components/Common/prescription-builder/InvestigationBuilder.tsx @@ -29,12 +29,28 @@ export interface InvestigationBuilderProps { setInvestigations: React.Dispatch>; } -export default function InvestigationBuilder( - props: InvestigationBuilderProps, -) { - const { investigations, setInvestigations } = props; - const [investigationsList, setInvestigationsList] = useState([]); - const [activeIdx, setActiveIdx] = useState(null); +export const loadInvestigations = async () => { + const fetchInvestigations = async () => { + const { data } = await request(routes.listInvestigations); + return ( + data?.results.map( + (investigation) => + `${investigation.name} -- ${humanizeStrings( + investigation.groups.map((group) => ` ( ${group.name} ) `), + )}`, + ) ?? [] + ); + }; + + const fetchInvestigationGroups = async () => { + const { data } = await request(routes.listInvestigationGroups); + return data?.results.map((group) => `${group.name} (GROUP)`) ?? []; + }; + + const invs = await fetchInvestigations(); + const groups = await fetchInvestigationGroups(); + + let additionalStrings: string[] = []; const additionalInvestigations = [ ["Vitals", ["Temp", "Blood Pressure", "Respiratory Rate", "Pulse Rate"]], [ @@ -51,57 +67,42 @@ export default function InvestigationBuilder( ], ], ]; + additionalInvestigations.forEach((investigation) => { + additionalStrings.push((investigation[0] as string) + " (GROUP)"); + additionalStrings = [ + ...additionalStrings, + ...(investigation[1] as string[]).map( + (i: any) => i + " -- ( " + investigation[0] + " )", + ), + ]; + }); + + return [...groups, ...invs, ...additionalStrings]; +}; + +export default function InvestigationBuilder( + props: InvestigationBuilderProps, +) { + const { investigations, setInvestigations } = props; + const [investigationsList, setInvestigationsList] = useState([]); + const [activeIdx, setActiveIdx] = useState(null); const setItem = (object: InvestigationType, i: number) => { setInvestigations( - investigations.map((investigation, index) => + investigations?.map((investigation, index) => index === i ? object : investigation, ), ); }; useEffect(() => { - loadInvestigations(); + const load = async () => setInvestigationsList(await loadInvestigations()); + load(); }, []); - const loadInvestigations = async () => { - const invs = await fetchInvestigations(); - const groups = await fetchInvestigationGroups(); - - let additionalStrings: string[] = []; - additionalInvestigations.forEach((investigation) => { - additionalStrings.push((investigation[0] as string) + " (GROUP)"); - additionalStrings = [ - ...additionalStrings, - ...(investigation[1] as string[]).map( - (i: any) => i + " -- ( " + investigation[0] + " )", - ), - ]; - }); - - setInvestigationsList([...groups, ...invs, ...additionalStrings]); - }; - - const fetchInvestigations = async () => { - const { data } = await request(routes.listInvestigations); - return ( - data?.results.map( - (investigation) => - `${investigation.name} -- ${humanizeStrings( - investigation.groups.map((group) => ` ( ${group.name} ) `), - )}`, - ) ?? [] - ); - }; - - const fetchInvestigationGroups = async () => { - const { data } = await request(routes.listInvestigationGroups); - return data?.results.map((group) => `${group.name} (GROUP)`) ?? []; - }; - return (
- {investigations.map((investigation, i) => { + {investigations?.map((investigation, i) => { const setFrequency = (frequency: string) => { setItem( { diff --git a/src/Components/Diagnosis/ConsultationDiagnosisBuilder/ConsultationDiagnosisBuilder.tsx b/src/Components/Diagnosis/ConsultationDiagnosisBuilder/ConsultationDiagnosisBuilder.tsx index 0391748c929..b6495143f5d 100644 --- a/src/Components/Diagnosis/ConsultationDiagnosisBuilder/ConsultationDiagnosisBuilder.tsx +++ b/src/Components/Diagnosis/ConsultationDiagnosisBuilder/ConsultationDiagnosisBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import useSlug from "../../../Common/hooks/useSlug"; import { ConsultationDiagnosis, @@ -83,6 +83,11 @@ interface EditDiagnosesProps { export const EditDiagnosesBuilder = (props: EditDiagnosesProps) => { const consultation = useSlug("consultation"); const [diagnoses, setDiagnoses] = useState(props.value); + + useEffect(() => { + setDiagnoses(props.value); + }, [props.value]); + return (
diff --git a/src/Components/Patient/DailyRounds.tsx b/src/Components/Patient/DailyRounds.tsx index d451513c9bc..d960636d522 100644 --- a/src/Components/Patient/DailyRounds.tsx +++ b/src/Components/Patient/DailyRounds.tsx @@ -29,7 +29,7 @@ import RadioFormField from "../Form/FormFields/RadioFormField"; import request from "../../Utils/request/request"; import routes from "../../Redux/api"; import { Scribe } from "../Scribe/Scribe"; -import { DAILY_ROUND_FORM_SCRIBE_DATA } from "../Scribe/formDetails"; +import { SCRIBE_FORMS } from "../Scribe/formDetails"; import { DailyRoundsModel } from "./models"; import InvestigationBuilder from "../Common/prescription-builder/InvestigationBuilder"; import { FieldErrorText } from "../Form/FormFields/FormField"; @@ -45,6 +45,9 @@ import { EncounterSymptomsBuilder } from "../Symptoms/SymptomsBuilder"; import { FieldLabel } from "../Form/FormFields/FormField"; import useAuthUser from "../../Common/hooks/useAuthUser"; import CheckBoxFormField from "../Form/FormFields/CheckBoxFormField"; +import SymptomsApi from "../Symptoms/api"; +import DiagnosesRoutes from "../Diagnosis/routes"; +import MedicineRoutes from "../Medicine/routes"; import { scrollTo } from "../../Utils/utils"; const Loading = lazy(() => import("../Common/Loading")); @@ -54,6 +57,8 @@ export const DailyRounds = (props: any) => { const authUser = useAuthUser(); const { goBack } = useAppHistory(); const { facilityId, patientId, consultationId, id } = props; + const [symptomsSeed, setSymptomsSeed] = useState(1); + const [prescriptionSeed, setPrescriptionSeed] = useState(1); const initForm: any = { physical_examination_info: "", @@ -478,11 +483,129 @@ export const DailyRounds = (props: any) => { >
{ + form={SCRIBE_FORMS.daily_round} + onFormUpdate={async (fields) => { + // Symptoms + let rounds_type = fields.rounds_type || state.form.rounds_type; + if (fields.additional_symptoms) { + for (const symptom of fields.additional_symptoms) { + const { res } = await request(SymptomsApi.add, { + pathParams: { consultationId }, + body: { + ...symptom, + }, + }); + if (res?.ok) setSymptomsSeed((s) => s + 1); + } + } + + // ICD11 Diagnosis + if (fields.icd11_diagnosis) { + for (const diagnosis of fields.icd11_diagnosis) { + // Fetch available diagnoses + + const { res: icdRes, data: icdData } = await request( + routes.listICD11Diagnosis, + { + query: { query: diagnosis.diagnosis }, + }, + ); + + if (!icdRes?.ok) { + error({ + text: "Failed to fetch ICD11 Diagnosis", + }); + continue; + } + + const availableDiagnosis = icdData?.[0]?.id; + + if (!availableDiagnosis) { + error({ + text: "Could not find the requested diagnosis. Please enter manually.", + }); + continue; + } + + const { res, data } = await request( + DiagnosesRoutes.createConsultationDiagnosis, + { + pathParams: { consultation: consultationId }, + body: { + ...diagnosis, + diagnosis: availableDiagnosis, + }, + }, + ); + + if (res?.ok && data) + setDiagnoses((diagnoses) => [...(diagnoses || []), data]); + } + } + + // Prescriptions + if (fields.prescriptions || fields.prn_prescriptions) { + const combined_prescriptions = [ + ...(fields.prescriptions || []), + ...(fields.prn_prescriptions || []), + ]; + for (const prescription of combined_prescriptions) { + // fetch medicine + const { res: medicineRes, data: medicineData } = await request( + routes.listMedibaseMedicines, + { + query: { query: prescription.medicine }, + }, + ); + + if (!medicineRes?.ok) { + error({ + text: "Failed to fetch medicine", + }); + continue; + } + + const availableMedicine = medicineData?.[0]?.id; + + if (!availableMedicine) { + error({ + text: "Could not find the requested medicine. Please enter manually.", + }); + continue; + } + + const { res } = await request( + MedicineRoutes.createPrescription, + { + pathParams: { consultation: consultationId }, + body: { + ...prescription, + medicine: availableMedicine, + }, + }, + ); + + if (res?.ok) setPrescriptionSeed((s) => s + 1); + } + } + + if ( + Object.keys(fields).some((f) => + [ + "investigations", + "icd11_diagnosis", + "additional_symptoms", + "prescriptions", + "prn_prescriptions", + ].includes(f), + ) + ) { + rounds_type = "DOCTORS_LOG"; + } + dispatch({ type: "set_form", - form: { ...state.form, ...fields }, + form: { ...state.form, ...fields, rounds_type }, }); fields.action !== undefined && setPreviousAction(fields.action); fields.review_interval !== undefined && @@ -536,6 +659,7 @@ export const DailyRounds = (props: any) => {
Symptoms { handleFormFieldChange({ name: "symptoms_dirty", @@ -593,7 +717,11 @@ export const DailyRounds = (props: any) => { <>

Vitals

- + { discontinued={ showDiscontinuedPrescriptions ? undefined : false } + key={prescriptionSeed} actions={["discontinue"]} />
@@ -783,6 +912,7 @@ export const DailyRounds = (props: any) => { showDiscontinuedPrescriptions ? undefined : false } actions={["discontinue"]} + key={prescriptionSeed} />
diff --git a/src/Components/Scribe/Scribe.tsx b/src/Components/Scribe/Scribe.tsx index 16b9056a795..0b05f55ebdf 100644 --- a/src/Components/Scribe/Scribe.tsx +++ b/src/Components/Scribe/Scribe.tsx @@ -21,8 +21,15 @@ export interface Field { description: string; type: string; example: string; - default: string; + default: any; options?: readonly FieldOption[]; + validator: (value: any) => boolean; +} + +export interface ScribeForm { + id: string; + name: string; + fields: () => Promise | Field[]; } export type ScribeModel = { @@ -45,7 +52,8 @@ export type ScribeModel = { }; interface ScribeProps { - fields: Field[]; + form: ScribeForm; + existingData?: { [key: string]: any }; onFormUpdate: (fields: any) => void; } @@ -54,7 +62,7 @@ const SCRIBE_FILE_TYPES = { SCRIBE: 1, }; -export const Scribe: React.FC = ({ fields, onFormUpdate }) => { +export const Scribe: React.FC = ({ form, onFormUpdate }) => { const { enable_scribe } = useConfig(); const [open, setOpen] = useState(false); const [_progress, setProgress] = useState(0); @@ -71,6 +79,21 @@ export const Scribe: React.FC = ({ fields, onFormUpdate }) => { const [updatedTranscript, setUpdatedTranscript] = useState(""); const [scribeID, setScribeID] = useState(""); const stageRef = useRef(stage); + const [fields, setFields] = useState([]); + + useEffect(() => { + const loadFields = async () => { + const fields = await form.fields(); + setFields( + fields.map((f) => ({ + ...f, + validate: undefined, + default: JSON.stringify(f.default), + })), + ); + }; + loadFields(); + }, [form]); useEffect(() => { if (stageRef.current === "cancelled") { @@ -312,8 +335,20 @@ export const Scribe: React.FC = ({ fields, onFormUpdate }) => { setProgress(100); const parsedFormData = JSON.parse(updatedFieldsResponse ?? "{}"); if (stageRef.current === "cancelled") return; - setFormFields(parsedFormData); - onFormUpdate(parsedFormData); + + // run type validations + const validated = Object.entries(parsedFormData) + .filter(([k, v]) => { + const f = fields.find((f) => f.id === k); + if (!f) return false; + if (v === f.default) return false; + //if (f.validator) return f.validator(f.type === "number" ? Number(v) : v); + return true; + }) + .map(([k, v]) => ({ [k]: v })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}); + setFormFields(validated as any); + onFormUpdate(validated); setStage("final-review"); } catch (error) { setErrors(["Error retrieving form data"]); @@ -373,35 +408,76 @@ export const Scribe: React.FC = ({ fields, onFormUpdate }) => { stageRef.current = "cancelled"; }; - const processFormField = ( + function processFormField( fieldDetails: Field | undefined, - formFields: { [key: string]: string | string[] | number }, + formFields: { [key: string]: any }, field: string, - ) => { - if (fieldDetails?.options) { - // Check if the form field is an array (multiple selections allowed) - if (Array.isArray(formFields[field])) { - // Map each selected option ID to its corresponding text - return (formFields[field] as string[]) - .map((option) => { - const optionDetails = fieldDetails.options?.find( - (o) => o.id === option, - ); - return optionDetails?.text ?? option; // Use option text if found, otherwise fallback to option ID - }) - .join(", "); - } else { - // Single selection scenario, find the option that matches the field value - return ( - fieldDetails.options?.find((o) => o.id === formFields[field])?.text ?? - JSON.stringify(formFields[field]) - ); + ): React.ReactNode { + const value = formFields[field]; + if (!fieldDetails || !value) return value; + + const { options } = fieldDetails; + + const getHumanizedKey = (key: string): string => { + return key + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + }; + + const getOptionText = (value: string | number): string => { + if (!options) return value.toString(); + const option = options.find((opt) => opt.id === value); + return option ? option.text : value.toString(); + }; + + const renderPrimitive = (value: any): any => { + return options ? getOptionText(value) : value; + }; + + const renderArray = (values: any[]): React.ReactNode => { + return values.map((value) => renderPrimitive(value)).join(", "); + }; + + const renderObject = (obj: { [key: string]: any }): React.ReactNode => { + return ( +
+ {Object.keys(obj).map((key, keyIndex) => ( +
+ {getHumanizedKey(key)}: {renderPrimitive(obj[key])} +
+ ))} +
+ ); + }; + + const renderObjectArray = (objects: any[]): React.ReactNode => { + return ( +
+ {objects.map((obj, objIndex) => ( +
{renderObject(obj)}
+ ))} +
+ ); + }; + + if (Array.isArray(value)) { + if ( + value.length > 0 && + typeof value[0] === "object" && + !Array.isArray(value[0]) + ) { + return renderObjectArray(value); } - } else { - // If no options are available, return the field value in JSON string format - return JSON.stringify(formFields[field]); + return renderArray(value); } - }; + + if (typeof value === "object") { + return renderObject(value); + } + + return renderPrimitive(value); + } const renderContentBasedOnStage = () => { switch (stage) { @@ -599,13 +675,13 @@ export const Scribe: React.FC = ({ fields, onFormUpdate }) => {

{fieldDetails?.friendlyName}

-

+

{processFormField( fieldDetails, formFields, field, )} -

+
); })} diff --git a/src/Components/Scribe/formDetails.ts b/src/Components/Scribe/formDetails.ts index 74673adea70..1c5b0cf3b9c 100644 --- a/src/Components/Scribe/formDetails.ts +++ b/src/Components/Scribe/formDetails.ts @@ -5,19 +5,27 @@ import { RHYTHM_CHOICES, TELEMEDICINE_ACTIONS, } from "../../Common/constants"; +import { loadInvestigations } from "../Common/prescription-builder/InvestigationBuilder"; import { SYMPTOM_CHOICES } from "../Symptoms/types"; -import { Field } from "./Scribe"; +import { Field, ScribeForm } from "./Scribe"; -export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ +const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ { friendlyName: "Additional Symptoms", id: "additional_symptoms", - type: "number[]", - example: "[1,2,3]", - default: "[]", - description: - "A numeric array of option IDs to store symptoms of the patient.", + type: "{symptom: number, other_symptom?: string, onset_date: string, cure_date?: string}[]", + example: + "[{symptom: 1, onset_date: '2024-12-03'}, {symptom: 2, onset_date: '2024-12-03', cure_date: '2024-12-05'}, {symptom: 9, other_symptom: 'Other symptom', onset_date: '2024-12-03'}]", + default: [], + description: `An array of objects to store the patient's symptoms along with their date of onset and date of cure (if any). The symptom field should be an integer corresponding to the symptom's ID. The onset_date and cure_date fields should be date strings (e.g., '2022-01-01'). If no onset_date has been specified, use todays date which is '${new Date().toISOString().slice(0, 10)}'. If the symptom is ongoing, the cure_date field should not be included. If the user has 'Other Symptom', only then the other_symptom field should be included with a string value describing the symptom.`, options: SYMPTOM_CHOICES, + validator: (value) => { + if (!Array.isArray(value)) return false; + value.forEach((s) => { + if (!s.symptom || !s.onset_date) return false; + }); + return true; + }, }, { friendlyName: "Other Symptoms", @@ -26,6 +34,9 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ example: "", default: "", description: "Just leave it blank", + validator: () => { + return true; + }, }, { friendlyName: "Physical Examination Info", @@ -36,6 +47,9 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ default: "", description: "This field is designated for storing detailed findings from the physical examination of the patient. It should include all observable physical attributes, conditions, or symptoms noted during the examination. When processing a doctor's transcript, identify and extract descriptions that pertain directly to the patient's physical state, such as visible conditions, physical symptoms, or any abnormalities noted by touch, sight, or measurement. This can include, but is not limited to, descriptions of skin conditions, swellings, lacerations, posture, mobility issues, and any other physically observable traits.", + validator: (value) => { + return typeof value === "string"; + }, }, { friendlyName: "Other Details", @@ -46,6 +60,9 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ "Patient reports trouble sleeping and a decreased appetite. Additionally, the patient is allergic to penicillin and has a history of asthma.", description: "This field is for capturing any supplementary details about the patient that are mentioned in the doctor's transcript but do not directly pertain to the physical examination findings. This includes, but is not limited to, behavioral observations, medical history, patient complaints, lifestyle factors, allergies, or any other non-physical observations that are relevant to the patient's overall health and well-being. When processing a transcript, extract information that describes the patient's health, habits, or conditions in a broader sense than what is observed through physical examination alone.", + validator: (value) => { + return typeof value === "string"; + }, }, { friendlyName: "Patient Category", @@ -58,14 +75,20 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ id: category.id, text: category.text, })), + validator: (value) => { + return typeof value === "string"; + }, }, { friendlyName: "Actions", id: "actions", type: "null", example: "null", - default: "null", + default: null, description: "Leave blank.", + validator: (value) => { + return value === null; + }, }, { friendlyName: "Action", @@ -78,16 +101,18 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ id: action.text, text: action.desc, })), + validator: (value) => typeof value === "string", }, { friendlyName: "Review Interval", id: "review_interval", type: "number", - default: "0", + default: 0, example: "15", description: "An integer to represent the interval at which the patient's condition is reviewed.", options: REVIEW_AT_CHOICES, + validator: (value) => typeof value === "number", }, { friendlyName: "Admitted To", @@ -97,63 +122,75 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ example: "General Ward", description: "A string to store the department or ward where the patient is admitted.", + validator: (value) => typeof value === "string", }, { friendlyName: "bp", id: "bp", - default: "{ systolic: undefined, diastolic: undefined, mean: undefined }", - type: "{ systolic: number, diastolic: number, mean: number }", - example: "{ systolic: 120, diastolic: 80, mean: 100 }", + default: { systolic: null, diastolic: null, mean: null }, + type: "{ systolic?: number, diastolic?: number }", + example: "{ systolic: 120 }", description: - "An object to store the blood pressure of the patient. It contains two integers, systolic and diastolic. Output mean is calculated from these two.", + "An object to store the blood pressure of the patient. It may contain two integers, systolic and diastolic.", + validator: (value) => { + if (typeof value !== "object") return false; + if (value.systolic && typeof value.systolic !== "number") return false; + if (value.diastolic && typeof value.diastolic !== "number") return false; + return true; + }, }, { friendlyName: "Pulse", id: "pulse", type: "number", - default: "null", + default: null, example: "72", description: "An integer to store the pulse rate of the patient. It can be null if the pulse rate is not taken.", + validator: (value) => typeof value === "number", }, { friendlyName: "Respiratory Rate", id: "resp", type: "number", - default: "null", + default: null, example: "16", description: "An integer to store the respiratory rate of the patient. It can be null if the respiratory rate is not taken.", + validator: (value) => typeof value === "number", }, { friendlyName: "Temperature", id: "temperature", type: "number", - default: "null", + default: null, example: "98.6", description: "A float to store the temperature of the patient. It can be null if the temperature is not taken.", + validator: (value) => typeof value === "number", }, { friendlyName: "SPO2", id: "ventilator_spo2", type: "number", - default: "null", + default: null, example: "98", description: "An integer to store the SPO2 level of the patient. It can be null if the SPO2 level is not taken.", + validator: (value) => typeof value === "number", }, { friendlyName: "Rhythm", id: "rhythm", - type: "string", + type: "number", example: "5", - default: "0", + default: 0, description: "An option to store the rhythm of the patient.", options: RHYTHM_CHOICES.map((rhythm) => ({ id: rhythm.id, text: rhythm.desc ?? "", })), + validator: (value) => typeof value === "number", }, { friendlyName: "Rhythm Detail", @@ -163,6 +200,7 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ example: "Just minor irregularities.", description: "A string to store the details about the rhythm of the patient.", + validator: (value) => typeof value === "string", }, { friendlyName: "Level Of Consciousness", @@ -173,13 +211,168 @@ export const DAILY_ROUND_FORM_SCRIBE_DATA: Field[] = [ description: "An option to store the level of consciousness of the patient.", options: CONSCIOUSNESS_LEVEL, + validator: (value) => typeof value === "string", + }, + { + friendlyName: "Diagnosis", + id: "icd11_diagnosis", + type: "{diagnosis: string, verification_status: \"unconfirmed\" | \"provisional\" | \"differential\" | \"confirmed\", is_principal: boolean}[]", + default: [], + example: + "[{diagnosis: '4A42.0 Paediatric onset systemic sclerosis', verification_status: 'confirmed', is_principal: true}, {diagnosis: 2, verification_status: 'provisional', is_principal: false}]", + description: + "A list of objects to store the patient's diagnosis along with their verification status and whether it is the principal diagnosis. If not specifically said, set is_principal to false. NOTE: only one principal diagnosis can exist. The diagnosis field should be a string that may contain a corresponding diagnosis ID. The verification_status field should be a string with one of the following values: 'unconfirmed', 'provisional', 'differential', or 'confirmed'.", + validator: (value) => { + if (!Array.isArray(value)) return false; + value.forEach((d) => { + if (!d.diagnosis || !d.verification_status) return false; + }); + return true; + }, + }, + { + friendlyName: "Investigations", + id: "investigations", + type: `{ + type: string[], + repetitive: boolean, + time?: string, + frequency?: '15 min' | '30 min' | '1 hr' | '6 hrs' | '12 hrs' | '24 hrs' | '48 hrs', + notes?: string + }[]`, + default: [], + example: `[ + { + type: ["Haemotology (GROUP)"], + repetitive: false, + time: "2024-07-31T18:10", + notes: "Patient is allergic to penicillin." + }, + { + type: ["ECG", "X-Ray"], + repetitive: true, + frequency: "24 hrs", + notes: "Patient is going nuts" + } + ]`, + description: + "A list of objects to store the patient's investigations. The type field should be an array of strings corresponding to the names of the investigations provided in the options. The repetitive field should be a boolean value. The time field should be a string and only be filled if repetitive field is false. The frequency field should be a string with one of the following values: '15 min', '30 min', '1 hr', '6 hrs', '12 hrs', '24 hrs', or '48 hrs' and should be only filled if this is a repititive investigation. The time field should be of the example format if present - (2024-07-31T18:10). The notes field should be a string. If the type is not available in options, DO NOT MAKE IT.", + validator: (value) => { + if (!Array.isArray(value)) return false; + value.forEach((i) => { + if (!i.type || !i.repetitive) return false; + if (i.repetitive && !i.frequency) return false; + }); + return true; + }, + }, + { + friendlyName: "Prescriptions", + id: "prescriptions", + type: `{ + base_dosage: number + " " + ("mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"), + days: number, + dosage_type: "REGULAR" | "TITRATED", + frequency: "STAT" | "OD" | "HS" | "BD" | "TID" | "QID" | "Q4H" | "QOD" | "QWK", + medicine: string, + notes: string, + route: "ORAL" | "IV" | "IM" | "SC" | "INHALATION" | "NASOGASTRIC" | "INTRATHECAL" | "TRANSDERMAL" | "RECTAL" | "SUBLINGUAL", + instruction_on_titration: string, + target_dosage: number + " " + ("mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"), + }[]`, + default: [], + example: `[ + {base_dosage: "5 ampule(s)", days: 7, dosage_type: "REGULAR", frequency: "STAT", medicine: "DOLO", notes: "Give with water", route: "ORAL"}, + {base_dosage: "7 ml", days: 3, dosage_type: "TITRATED", frequency: "Q4H", medicine: "Albumin", route: "INHALATION", instruction_on_titration: "Example", target_dosage: "40 ml"}, + ]`, + description: `A list of objects to store the patient's prescriptions. The prescription can be regular or titrated. If titrated, the prescription should also include instruction_on_titration, and a target_dosage. NOTE: target_dosage should have the same unit as base_dosage. + The frequency should be any of the mentioned ones. They are short for: + STAT: Imediately, + OD: Once daily, + HS: Night Only, + BD: Twice Daily, + TID: 8th Hourly, + QID: 6th Hourly, + Q4H: 4th Hourly, + QOD: Alternate Day, + QWK: Once a Week + `, + validator: (value) => { + if (!Array.isArray(value)) return false; + return true; + }, + }, + { + friendlyName: "PRN Prescriptions", + id: "prn_prescriptions", + type: `{ + base_dosage: number + " " + ("mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"), + dosage_type: "PRN", + medicine: string, + notes: string, + route: "ORAL" | "IV" | "IM" | "SC" | "INHALATION" | "NASOGASTRIC" | "INTRATHECAL" | "TRANSDERMAL" | "RECTAL" | "SUBLINGUAL", + indicator: string, + min_hours_between_doses: number, + max_dosage: number + " " + ("mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"), + }[]`, + default: [], + example: `[ + {base_dosage: "3 drop(s)", dosage_type:"PRN", indicator: "If patient gets fever", max_dosage: "5 drops(s)", min_hours_between_doses: 12, route: "IV", medicine: "Glentona", notes: "Example"} + ]`, + description: "A list of objects to store the patient's PRN prescriptions.", + validator: (value) => { + if (!Array.isArray(value)) return false; + return true; + }, }, + /*{ + friendlyName: "Round Type", + id: "rounds_type", + type: "string", + default: "NORMAL", + example: "TELEMEDICINE", + description: "A string to store the type of round.", + options: [ + { id: "NORMAL", text: "Brief Update" }, + { id: "VENTILATOR", text: "Detailed Update" }, + { id: "DOCTORS_LOG", text: "Progress Note" }, + { id: "TELEMEDICINE", text: "Telemedicine" }, + ], + validator: (value) => typeof value === "string", + }, + { + friendlyName: "Measured At", + id: "taken_at", + type: "string", + default: "", + example: "2024-07-31T18:10", + description: + "A string to store the date and time at which the round was taken or measured. 'The round was taken yesterday/today' would amount to yesterday/today's date.", + validator: (value) => typeof value === "string", + }, +*/ ]; -export const SCRIBE_FORMS = [ - { +export const SCRIBE_FORMS: { [key: string]: ScribeForm } = { + daily_round: { id: "daily_round", name: "Daily Round", - fields: DAILY_ROUND_FORM_SCRIBE_DATA, + fields: async () => { + const investigations = await loadInvestigations(); + + return DAILY_ROUND_FORM_SCRIBE_DATA.map((field) => { + if (field.id === "investigations") { + return { + ...field, + options: investigations.map((investigation, i) => ({ + id: i, + text: investigation, + currentData: undefined, + })), + }; + } + return field; + }); + }, }, -]; +};