diff --git a/netlify.toml b/netlify.toml index 15e6bac08f8..59abf59caff 100644 --- a/netlify.toml +++ b/netlify.toml @@ -26,7 +26,7 @@ status = 200 X-Content-Type-Options = "nosniff" Content-Security-Policy = ''' default-src 'self'; - script-src 'self' 'nonce-f51b9742' https://plausible.10bedicu.in; + script-src 'self' blob: 'nonce-f51b9742' https://plausible.10bedicu.in; style-src 'self' 'unsafe-inline'; connect-src *; img-src 'self' blob: data: https://cdn.coronasafe.network https://egov-s3-facility-10bedicu.s3.amazonaws.com https://egov-s3-patient-data-10bedicu.s3.amazonaws.com; 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/ABDM/LinkABHANumberModal.tsx b/src/Components/ABDM/LinkABHANumberModal.tsx index 47b29b805ae..5d1f7469efc 100644 --- a/src/Components/ABDM/LinkABHANumberModal.tsx +++ b/src/Components/ABDM/LinkABHANumberModal.tsx @@ -13,7 +13,7 @@ import TextFormField from "../Form/FormFields/TextFormField"; import { classNames } from "../../Utils/utils"; import request from "../../Utils/request/request"; import routes from "../../Redux/api"; -import { ABDMError } from "./models"; +import { ABDMError, ABHAQRContent } from "./models"; export const validateRule = ( condition: boolean, @@ -188,9 +188,20 @@ const ScanABHAQRSection = ({ setIsLoading(true); try { - const abha = JSON.parse(value); + const abha = JSON.parse(value) as ABHAQRContent; + const { res, data } = await request(routes.abha.linkViaQR, { - body: { ...abha, patientId }, + body: { + patientId, + hidn: abha?.hidn, + phr: abha?.hid, + name: abha?.name, + gender: abha?.gender, + dob: abha?.dob.replace(/\//g, "-"), + address: abha?.address, + "dist name": abha?.district_name, + "state name": abha?.["state name"], + }, }); if (res?.status === 200 || res?.status === 202) { diff --git a/src/Components/ABDM/models.ts b/src/Components/ABDM/models.ts index b85445fa6cd..957dc9c2d17 100644 --- a/src/Components/ABDM/models.ts +++ b/src/Components/ABDM/models.ts @@ -107,3 +107,29 @@ export interface IcreateHealthFacilityTBody { export interface IpartialUpdateHealthFacilityTBody { hf_id: string; } + +export interface ILinkViaQRBody { + hidn: string; + phr: string; + name: string; + gender: "M" | "F" | "O"; + dob: string; + address?: string; + "dist name"?: string; + "state name"?: string; + patientId?: string; +} + +export interface ABHAQRContent { + address: string; + distlgd: string; + district_name: string; + dob: string; + gender: "M"; + hid: string; + hidn: string; + mobile: string; + name: string; + "state name": string; + statelgd: string; +} 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..94b61fa339e 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -14,6 +14,7 @@ import { IHealthId, IinitiateAbdmAuthenticationTBody, ILinkABHANumber, + ILinkViaQRBody, IpartialUpdateHealthFacilityTBody, ISearchByHealthIdTBody, IVerifyAadhaarOtpTBody, @@ -82,6 +83,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 +977,12 @@ const routes = { // ICD11 listICD11Diagnosis: { path: "/api/v1/icd/", + TRes: Type(), + }, + getICD11Diagnosis: { + path: "/api/v1/icd/{id}/", + TRes: Type(), + enableExperimentalCache: true, }, // Medibase listMedibaseMedicines: { @@ -1181,7 +1189,7 @@ const routes = { path: "/api/v1/abdm/healthid/link_via_qr/", method: "POST", TRes: Type(), - TBody: Type(), + TBody: Type(), }, linkCareContext: { 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)) + ), + ]; +}; diff --git a/vite.config.ts b/vite.config.ts index 4740e295bb2..1683caa7a7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -101,7 +101,7 @@ export default defineConfig({ preview: { headers: { "Content-Security-Policy": `default-src 'self';\ - script-src 'self' 'nonce-f51b9742' https://plausible.10bedicu.in;\ + script-src 'self' blob: 'nonce-f51b9742' https://plausible.10bedicu.in;\ style-src 'self' 'unsafe-inline';\ connect-src *;\ img-src 'self' blob: data: https://cdn.coronasafe.network ${cdnUrls};\