From 35e78bd15290d29253bed3b1df28222842779536 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 25 Jan 2024 13:03:02 +0530 Subject: [PATCH] Adds support for filtering patients by diagnoses (#7062) * Adds support for filtering by diagnoses * code cleanup * sort diagnoses filter based on priority --- src/Common/hooks/useAsyncOptions.ts | 17 ++--- src/Components/Diagnosis/utils.ts | 13 ++++ src/Components/Patient/DiagnosesFilter.tsx | 81 ++++++++++++++++++++++ src/Components/Patient/ManagePatients.tsx | 50 +++++++++++++ src/Components/Patient/PatientFilter.tsx | 34 +++++++++ src/Redux/api.tsx | 7 ++ src/Utils/request/useQuery.ts | 2 +- src/Utils/request/utils.ts | 4 +- src/Utils/utils.ts | 14 ++++ 9 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 src/Components/Diagnosis/utils.ts create mode 100644 src/Components/Patient/DiagnosesFilter.tsx diff --git a/src/Common/hooks/useAsyncOptions.ts b/src/Common/hooks/useAsyncOptions.ts index 2f3f68d5c3a..4c9199b02be 100644 --- a/src/Common/hooks/useAsyncOptions.ts +++ b/src/Common/hooks/useAsyncOptions.ts @@ -1,6 +1,7 @@ import { debounce } from "lodash-es"; import { useMemo, useState } from "react"; import { useDispatch } from "react-redux"; +import { mergeQueryOptions } from "../../Utils/utils"; interface IUseAsyncOptionsArgs { debounceInterval?: number; @@ -8,6 +9,9 @@ interface IUseAsyncOptionsArgs { } /** + * Deprecated. This is no longer needed as `useQuery` with `mergeQueryOptions` + * can be reused for this. + * * Hook to implement async autocompletes with ease and typesafety. * * See `DiagnosisSelectFormField` for usage. @@ -51,14 +55,11 @@ export function useAsyncOptions>( ); const mergeValueWithQueryOptions = (selected?: T[]) => { - if (!selected?.length) return queryOptions; - - return [ - ...selected, - ...queryOptions.filter( - (option) => !selected.find((s) => s[uniqueKey] === option[uniqueKey]) - ), - ]; + return mergeQueryOptions( + selected ?? [], + queryOptions, + (obj) => obj[uniqueKey] + ); }; return { diff --git a/src/Components/Diagnosis/utils.ts b/src/Components/Diagnosis/utils.ts new file mode 100644 index 00000000000..c53f9b81bc1 --- /dev/null +++ b/src/Components/Diagnosis/utils.ts @@ -0,0 +1,13 @@ +import routes from "../../Redux/api"; +import request from "../../Utils/request/request"; +import { ICD11DiagnosisModel } from "./types"; + +// TODO: cache ICD11 responses and hit the cache if present instead of making an API call. + +export const getDiagnosisById = async (id: ICD11DiagnosisModel["id"]) => { + return (await request(routes.getICD11Diagnosis, { pathParams: { id } })).data; +}; + +export const getDiagnosesByIds = async (ids: ICD11DiagnosisModel["id"][]) => { + return Promise.all([...new Set(ids)].map(getDiagnosisById)); +}; diff --git a/src/Components/Patient/DiagnosesFilter.tsx b/src/Components/Patient/DiagnosesFilter.tsx new file mode 100644 index 00000000000..e8bd1afb722 --- /dev/null +++ b/src/Components/Patient/DiagnosesFilter.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { ICD11DiagnosisModel } from "../Diagnosis/types"; +import { getDiagnosesByIds } from "../Diagnosis/utils"; +import { useTranslation } from "react-i18next"; +import AutocompleteMultiSelectFormField from "../Form/FormFields/AutocompleteMultiselect"; +import useQuery from "../../Utils/request/useQuery"; +import routes from "../../Redux/api"; +import { mergeQueryOptions } from "../../Utils/utils"; +import { debounce } from "lodash-es"; + +export const FILTER_BY_DIAGNOSES_KEYS = [ + "diagnoses", + "diagnoses_confirmed", + "diagnoses_unconfirmed", + "diagnoses_provisional", + "diagnoses_differential", +] as const; + +export const DIAGNOSES_FILTER_LABELS = { + diagnoses: "Diagnoses (of any verification status)", + diagnoses_unconfirmed: "Unconfirmed Diagnoses", + diagnoses_provisional: "Provisional Diagnoses", + diagnoses_differential: "Differential Diagnoses", + diagnoses_confirmed: "Confirmed Diagnoses", +} as const; + +export type DiagnosesFilterKey = (typeof FILTER_BY_DIAGNOSES_KEYS)[number]; + +interface Props { + name: DiagnosesFilterKey; + value?: string; + onChange: (event: { name: DiagnosesFilterKey; value: string }) => void; +} +export default function DiagnosesFilter(props: Props) { + const { t } = useTranslation(); + const [diagnoses, setDiagnoses] = useState([]); + const { data, loading, refetch } = useQuery(routes.listICD11Diagnosis); + + useEffect(() => { + if (!props.value) { + setDiagnoses([]); + return; + } + if (diagnoses.map((d) => d.id).join(",") === props.value) { + return; + } + + // Re-use the objects which we already have, fetch the rest. + const ids = props.value.split(","); + const existing = diagnoses.filter(({ id }) => ids.includes(id)); + const objIds = existing.map((o) => o.id); + const diagnosesToBeFetched = ids.filter((id) => !objIds.includes(id)); + getDiagnosesByIds(diagnosesToBeFetched).then((data) => { + const retrieved = data.filter(Boolean) as ICD11DiagnosisModel[]; + setDiagnoses([...existing, ...retrieved]); + }); + }, [props.value]); + + return ( + { + setDiagnoses(e.value); + props.onChange({ + name: props.name, + value: e.value.map((o) => o.id).join(","), + }); + }} + options={mergeQueryOptions(diagnoses, data ?? [], (obj) => obj.id)} + optionLabel={(option) => option.label} + optionValue={(option) => option} + onQuery={debounce((query: string) => refetch({ query: { query } }), 300)} + isLoading={loading} + /> + ); +} diff --git a/src/Components/Patient/ManagePatients.tsx b/src/Components/Patient/ManagePatients.tsx index e1fe1f37e4a..ce8475e4c66 100644 --- a/src/Components/Patient/ManagePatients.tsx +++ b/src/Components/Patient/ManagePatients.tsx @@ -51,6 +51,13 @@ import { triggerGoal } from "../../Integrations/Plausible.js"; import useAuthUser from "../../Common/hooks/useAuthUser.js"; import useQuery from "../../Utils/request/useQuery.js"; import routes from "../../Redux/api.js"; +import { + DIAGNOSES_FILTER_LABELS, + DiagnosesFilterKey, + FILTER_BY_DIAGNOSES_KEYS, +} from "./DiagnosesFilter.js"; +import { ICD11DiagnosisModel } from "../Diagnosis/types.js"; +import { getDiagnosesByIds } from "../Diagnosis/utils.js"; const Loading = lazy(() => import("../Common/Loading")); @@ -110,6 +117,7 @@ export const PatientManager = () => { name: "", }); const authUser = useAuthUser(); + const [diagnoses, setDiagnoses] = useState([]); const [showDialog, setShowDialog] = useState(false); const [showDoctors, setShowDoctors] = useState(false); const [showDoctorConnect, setShowDoctorConnect] = useState(false); @@ -231,8 +239,33 @@ export const PatientManager = () => { qParams.last_consultation_is_telemedicine || undefined, is_antenatal: qParams.is_antenatal || undefined, ventilator_interface: qParams.ventilator_interface || undefined, + diagnoses: qParams.diagnoses || undefined, + diagnoses_confirmed: qParams.diagnoses_confirmed || undefined, + diagnoses_provisional: qParams.diagnoses_provisional || undefined, + diagnoses_unconfirmed: qParams.diagnoses_unconfirmed || undefined, + diagnoses_differential: qParams.diagnoses_differential || undefined, }; + useEffect(() => { + const ids: string[] = []; + FILTER_BY_DIAGNOSES_KEYS.forEach((key) => { + ids.push(...(qParams[key] ?? "").split(",").filter(Boolean)); + }); + const existing = diagnoses.filter(({ id }) => ids.includes(id)); + const objIds = existing.map((o) => o.id); + const diagnosesToBeFetched = ids.filter((id) => !objIds.includes(id)); + getDiagnosesByIds(diagnosesToBeFetched).then((data) => { + const retrieved = data.filter(Boolean) as ICD11DiagnosisModel[]; + setDiagnoses([...existing, ...retrieved]); + }); + }, [ + qParams.diagnoses, + qParams.diagnoses_confirmed, + qParams.diagnoses_provisional, + qParams.diagnoses_unconfirmed, + qParams.diagnoses_differential, + ]); + useEffect(() => { if (params.facility) { setShowDoctorConnect(true); @@ -395,6 +428,11 @@ export const PatientManager = () => { qParams.last_consultation_is_telemedicine, qParams.is_antenatal, qParams.ventilator_interface, + qParams.diagnoses, + qParams.diagnoses_confirmed, + qParams.diagnoses_provisional, + qParams.diagnoses_unconfirmed, + qParams.diagnoses_differential, ]); const getTheCategoryFromId = () => { @@ -520,6 +558,11 @@ export const PatientManager = () => { }); }; + const getDiagnosisFilterValue = (key: DiagnosesFilterKey) => { + const ids: string[] = (qParams[key] ?? "").split(","); + return ids.map((id) => diagnoses.find((obj) => obj.id == id)?.label ?? id); + }; + let patientList: ReactNode[] = []; if (data && data.length) { patientList = data.map((patient: any) => { @@ -1025,6 +1068,13 @@ export const PatientManager = () => { ...range("Age", "age"), badge("SRF ID", "srf_id"), { name: "LSG Body", value: localbodyName, paramKey: "lsgBody" }, + ...FILTER_BY_DIAGNOSES_KEYS.map((key) => + value( + DIAGNOSES_FILTER_LABELS[key], + key, + getDiagnosisFilterValue(key).join(", ") + ) + ), badge("Declared Status", "is_declared_positive"), ...dateRange("Result", "date_of_result"), ...dateRange("Declared positive", "date_declared_positive"), diff --git a/src/Components/Patient/PatientFilter.tsx b/src/Components/Patient/PatientFilter.tsx index 75da581e010..0e1cdfdd083 100644 --- a/src/Components/Patient/PatientFilter.tsx +++ b/src/Components/Patient/PatientFilter.tsx @@ -34,6 +34,7 @@ import { } from "../Form/FormFields/Utils"; import MultiSelectMenuV2 from "../Form/MultiSelectMenuV2"; import SelectMenuV2 from "../Form/SelectMenuV2"; +import DiagnosesFilter, { FILTER_BY_DIAGNOSES_KEYS } from "./DiagnosesFilter"; const getDate = (value: any) => value && dayjs(value).isValid() && dayjs(value).toDate(); @@ -98,6 +99,11 @@ export default function PatientFilter(props: any) { filter.last_consultation_is_telemedicine || null, is_antenatal: filter.is_antenatal || null, ventilator_interface: filter.ventilator_interface || null, + diagnoses: filter.diagnoses || null, + diagnoses_confirmed: filter.diagnoses_confirmed || null, + diagnoses_provisional: filter.diagnoses_provisional || null, + diagnoses_unconfirmed: filter.diagnoses_unconfirmed || null, + diagnoses_differential: filter.diagnoses_differential || null, }); const dispatch: any = useDispatch(); @@ -211,6 +217,11 @@ export default function PatientFilter(props: any) { last_consultation_is_telemedicine, is_antenatal, ventilator_interface, + diagnoses, + diagnoses_confirmed, + diagnoses_provisional, + diagnoses_unconfirmed, + diagnoses_differential, } = filterState; const data = { district: district || "", @@ -273,6 +284,11 @@ export default function PatientFilter(props: any) { last_consultation_is_telemedicine || "", is_antenatal: is_antenatal || "", ventilator_interface: ventilator_interface || "", + diagnoses: diagnoses || "", + diagnoses_confirmed: diagnoses_confirmed || "", + diagnoses_provisional: diagnoses_provisional || "", + diagnoses_unconfirmed: diagnoses_unconfirmed || "", + diagnoses_differential: diagnoses_differential || "", }; onChange(data); }; @@ -459,6 +475,24 @@ export default function PatientFilter(props: any) { + + ICD-11 Diagnoses based + + } + expanded + className="w-full" + > + {FILTER_BY_DIAGNOSES_KEYS.map((name) => ( + + ))} + diff --git a/src/Redux/api.tsx b/src/Redux/api.tsx index ee8c8f86ccf..cde22411c92 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -82,6 +82,7 @@ import { InvestigationGroup, InvestigationType, } from "../Components/Facility/Investigations"; +import { ICD11DiagnosisModel } from "../Components/Diagnosis/types"; /** * A fake function that returns an empty object casted to type T @@ -975,6 +976,12 @@ const routes = { // ICD11 listICD11Diagnosis: { path: "/api/v1/icd/", + TRes: Type(), + }, + getICD11Diagnosis: { + path: "/api/v1/icd/{id}/", + TRes: Type(), + enableExperimentalCache: true, }, // Medibase listMedibaseMedicines: { diff --git a/src/Utils/request/useQuery.ts b/src/Utils/request/useQuery.ts index 97d1b565f2f..b80d7c52a13 100644 --- a/src/Utils/request/useQuery.ts +++ b/src/Utils/request/useQuery.ts @@ -28,7 +28,7 @@ export default function useQuery( const resolvedOptions = options && overrides ? mergeRequestOptions(options, overrides) - : options; + : overrides ?? options; setLoading(true); const response = await request(route, resolvedOptions); diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts index dd1f79fce4f..166355812bb 100644 --- a/src/Utils/request/utils.ts +++ b/src/Utils/request/utils.ts @@ -5,11 +5,11 @@ import { QueryParams, RequestOptions } from "./types"; export function makeUrl( path: string, query?: QueryParams, - pathParams?: Record + pathParams?: Record ) { if (pathParams) { path = Object.entries(pathParams).reduce( - (acc, [key, value]) => acc.replace(`{${key}}`, value), + (acc, [key, value]) => acc.replace(`{${key}}`, `${value}`), path ); } diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index c518bcffe7d..2966790fb10 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -471,3 +471,17 @@ export const isValidUrl = (url?: string) => { return false; } }; + +export const mergeQueryOptions = ( + selected: T[], + queryOptions: T[], + compareBy: (obj: T) => T[keyof T] +) => { + if (!selected.length) return queryOptions; + return [ + ...selected, + ...queryOptions.filter( + (option) => !selected.find((s) => compareBy(s) === compareBy(option)) + ), + ]; +};