@digihmis/esm-triage-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/132.js +1 -0
- package/dist/132.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/183.js +66 -0
- package/dist/183.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/22.js +1 -0
- package/dist/22.js.map +1 -0
- package/dist/222.js +1 -0
- package/dist/222.js.map +1 -0
- package/dist/317.js +1 -0
- package/dist/317.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/371.js +1 -0
- package/dist/371.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/45.js +1 -0
- package/dist/45.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/640.js +1 -0
- package/dist/640.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/709.js +1 -0
- package/dist/709.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/737.js +12 -0
- package/dist/737.js.map +1 -0
- package/dist/742.js +1 -0
- package/dist/742.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/806.js.map +1 -0
- package/dist/809.js +1 -0
- package/dist/809.js.map +1 -0
- package/dist/9.js +15 -0
- package/dist/9.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/940.js +1 -0
- package/dist/940.js.map +1 -0
- package/dist/974.js +1 -0
- package/dist/974.js.map +1 -0
- package/dist/976.js +1 -0
- package/dist/976.js.map +1 -0
- package/dist/digihmis-esm-triage-app.js +6 -0
- package/dist/digihmis-esm-triage-app.js.buildmanifest.json +905 -0
- package/dist/digihmis-esm-triage-app.js.map +1 -0
- package/dist/main.js +90 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/config-schema.ts +142 -0
- package/src/dashboard.meta.ts +16 -0
- package/src/declarations.d.ts +6 -0
- package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
- package/src/index.ts +41 -0
- package/src/left-panel/left-panel.component.tsx +100 -0
- package/src/left-panel/left-panel.scss +13 -0
- package/src/page-header/page-header.component.tsx +35 -0
- package/src/page-header/page-header.scss +76 -0
- package/src/past-vitals/action-menu/vitals-anthropometrics.component.tsx +44 -0
- package/src/past-vitals/action-menu/vitals-anthropometrics.scss +0 -0
- package/src/past-vitals/paginated-antropometrics/paginated-antropometrics.component.tsx +132 -0
- package/src/past-vitals/paginated-antropometrics/paginated-antropometrics.scss +9 -0
- package/src/past-vitals/paginated-vitals/paginated-vitals.component.tsx +157 -0
- package/src/past-vitals/paginated-vitals/paginated-vitals.scss +94 -0
- package/src/past-vitals/paginated-vitals/type/index.ts +1 -0
- package/src/past-vitals/patient-banner/patient-banner.component.tsx +60 -0
- package/src/past-vitals/patient-banner/patient-banner.scss +72 -0
- package/src/past-vitals/patient-vitals-history.component.tsx +85 -0
- package/src/past-vitals/patient-vitals-history.resource.ts +30 -0
- package/src/past-vitals/patient-vitals-history.scss +75 -0
- package/src/past-vitals/triage-anthropometrics/antropometrics-chart.component.tsx +170 -0
- package/src/past-vitals/triage-anthropometrics/antropometrics-chart.scss +70 -0
- package/src/past-vitals/triage-anthropometrics/triage-antropometrics.component.tsx +124 -0
- package/src/past-vitals/triage-anthropometrics/triage-antropometrics.scss +37 -0
- package/src/past-vitals/triage-anthropometrics/type/index.ts +17 -0
- package/src/past-vitals/triage-notes/triage-note.scss +94 -0
- package/src/past-vitals/triage-notes/triage-notes.component.tsx +75 -0
- package/src/past-vitals/triage-notes/triage-notes.resource.ts +72 -0
- package/src/past-vitals/triage-notes/type/index.ts +50 -0
- package/src/past-vitals/triage-vitals/triage-vitals-history.component.tsx +229 -0
- package/src/past-vitals/triage-vitals/triage-vitals-history.scss +48 -0
- package/src/past-vitals/triage-vitals/type/index.ts +32 -0
- package/src/past-vitals/triage-vitals/vital-chart.component.tsx +198 -0
- package/src/past-vitals/triage-vitals/vitals-chart.scss +70 -0
- package/src/past-vitals/utils/index.ts +5 -0
- package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.component.tsx +61 -0
- package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.scss +171 -0
- package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.test.tsx +31 -0
- package/src/past-vitals/vitals-and-biometrics-header/vitals-header.extension.tsx +225 -0
- package/src/past-vitals/vitals-and-biometrics-header/vitals-header.scss +233 -0
- package/src/queue-room-table/queue-room-table.component.tsx +380 -0
- package/src/queue-room-table/queue-room-table.resource.ts +73 -0
- package/src/queue-room-table/queue-room-table.scss +193 -0
- package/src/queue-room-table/type/index.ts +30 -0
- package/src/queue-room-table/useQueueActions.ts +24 -0
- package/src/record-vitals-workspace/common/index.ts +19 -0
- package/src/record-vitals-workspace/component/vitals-biometrics-input.component.tsx +433 -0
- package/src/record-vitals-workspace/component/vitals-biometrics-input.scss +358 -0
- package/src/record-vitals-workspace/helper/index.ts +200 -0
- package/src/record-vitals-workspace/record-vitals.resource.ts +570 -0
- package/src/record-vitals-workspace/record-vitals.scss +140 -0
- package/src/record-vitals-workspace/record-vitals.workspace.tsx +612 -0
- package/src/record-vitals-workspace/schema/index.ts +28 -0
- package/src/record-vitals-workspace/type/index.ts +170 -0
- package/src/record-vitals-workspace/utils/index.ts +100 -0
- package/src/routes.json +70 -0
- package/src/setup-tests.ts +1 -0
- package/src/triage-queue-dashboard/triage-dashboard.resource.ts +75 -0
- package/src/triage-queue-dashboard/triage-queue-dashboard.component.tsx +261 -0
- package/src/triage-queue-dashboard/triage-queue-dashboard.scss +166 -0
- package/src/triage-queue-dashboard/type/index.ts +31 -0
- package/translations/am.json +92 -0
- package/translations/ar.json +92 -0
- package/translations/en.json +92 -0
- package/translations/es.json +92 -0
- package/translations/fr.json +92 -0
- package/translations/he.json +92 -0
- package/translations/km.json +92 -0
- package/translations/pt.json +92 -0
- package/translations/sw.json +92 -0
- package/translations/vi.json +92 -0
- package/translations/zh.json +92 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FHIRResource,
|
|
5
|
+
type FetchResponse,
|
|
6
|
+
fhirBaseUrl,
|
|
7
|
+
restBaseUrl,
|
|
8
|
+
openmrsFetch,
|
|
9
|
+
useConfig,
|
|
10
|
+
type OpenmrsResource,
|
|
11
|
+
} from '@openmrs/esm-framework';
|
|
12
|
+
import useSWRImmutable from 'swr/immutable';
|
|
13
|
+
import {
|
|
14
|
+
FHIRObservationResource,
|
|
15
|
+
FHIRSearchBundleResponse,
|
|
16
|
+
MappedVitals,
|
|
17
|
+
Patient,
|
|
18
|
+
PatientVitalsAndBiometrics,
|
|
19
|
+
VitalsResponse,
|
|
20
|
+
type ConceptAnswers,
|
|
21
|
+
type ConceptResponse,
|
|
22
|
+
} from './type';
|
|
23
|
+
import useSWR from 'swr';
|
|
24
|
+
|
|
25
|
+
import useSWRInfinite from 'swr/infinite';
|
|
26
|
+
import { type ConfigObject } from '../config-schema';
|
|
27
|
+
import {
|
|
28
|
+
assessValue,
|
|
29
|
+
calculateBodyMassIndex,
|
|
30
|
+
interpretBloodPressure,
|
|
31
|
+
mapFhirInterpretationToObservationInterpretation,
|
|
32
|
+
} from './helper';
|
|
33
|
+
|
|
34
|
+
export const usePerson = (uuid: string) => {
|
|
35
|
+
const customRepresentation = `custom:(uuid,display,gender,birthdate,dead,age,deathDate,causeOfDeath:(uuid,display),identifiers:(uuid,identifier,identifierType:(uuid,display),preferred),person:(uuid`;
|
|
36
|
+
const url = `${restBaseUrl}/person/${uuid}?v=${customRepresentation}`;
|
|
37
|
+
const { isLoading, error, data } = useSWR<FetchResponse<Patient['person']>>(url, openmrsFetch);
|
|
38
|
+
const person = data?.data;
|
|
39
|
+
return { isLoading, error, person };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const pageSize = 100;
|
|
43
|
+
|
|
44
|
+
/** We use this as the first value to the SWR key to be able to invalidate all relevant cached entries */
|
|
45
|
+
const swrKeyNeedle = Symbol('vitalsAndBiometrics');
|
|
46
|
+
const encounterRepresentation =
|
|
47
|
+
'custom:(uuid,encounterDatetime,encounterType:(uuid,display),obs:(uuid,concept:(uuid,display),value,interpretation))';
|
|
48
|
+
|
|
49
|
+
type ConceptRange = {
|
|
50
|
+
display: string;
|
|
51
|
+
hiAbsolute: number | null;
|
|
52
|
+
hiCritical: number | null;
|
|
53
|
+
hiNormal: number | null;
|
|
54
|
+
lowAbsolute: number | null;
|
|
55
|
+
lowCritical: number | null;
|
|
56
|
+
lowNormal: number | null;
|
|
57
|
+
units: string | null;
|
|
58
|
+
uuid: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type VitalsAndBiometricsMode = 'vitals' | 'biometrics' | 'both';
|
|
62
|
+
|
|
63
|
+
type VitalsAndBiometricsSwrKey = {
|
|
64
|
+
swrKeyNeedle: typeof swrKeyNeedle;
|
|
65
|
+
mode: VitalsAndBiometricsMode;
|
|
66
|
+
patientUuid: string;
|
|
67
|
+
conceptUuids: string;
|
|
68
|
+
page: number;
|
|
69
|
+
prevPageData: FHIRSearchBundleResponse;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type VitalsFetchResponse = FetchResponse<VitalsResponse>;
|
|
73
|
+
|
|
74
|
+
export interface ConceptMetadata {
|
|
75
|
+
uuid: string;
|
|
76
|
+
display: string;
|
|
77
|
+
hiNormal: number | null;
|
|
78
|
+
hiAbsolute: number | null;
|
|
79
|
+
hiCritical: number | null;
|
|
80
|
+
lowNormal: number | null;
|
|
81
|
+
lowAbsolute: number | null;
|
|
82
|
+
lowCritical: number | null;
|
|
83
|
+
units: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface VitalsConceptMetadataResponse {
|
|
87
|
+
setMembers: Array<ConceptMetadata>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type VitalsAndBiometricsFieldValuesMap = Map<string, { value: number | string; obs: OpenmrsResource }>;
|
|
91
|
+
|
|
92
|
+
interface PartialEncounter extends OpenmrsResource {
|
|
93
|
+
encounterType: OpenmrsResource;
|
|
94
|
+
encounterDatetime: string;
|
|
95
|
+
obs: Array<OpenmrsResource>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getInterpretationKey(header: string) {
|
|
99
|
+
// Reason for `Render` string is to match the column header in the table
|
|
100
|
+
return `${header}RenderInterpretation`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useVitalsConceptMetadata(patientUuid: string) {
|
|
104
|
+
const {
|
|
105
|
+
concepts: {
|
|
106
|
+
diastolicBloodPressureUuid,
|
|
107
|
+
oxygenSaturationUuid,
|
|
108
|
+
pulseUuid,
|
|
109
|
+
respiratoryRateUuid,
|
|
110
|
+
systolicBloodPressureUuid,
|
|
111
|
+
temperatureUuid,
|
|
112
|
+
heightUuid,
|
|
113
|
+
weightUuid,
|
|
114
|
+
midUpperArmCircumferenceUuid, // Add MUAC
|
|
115
|
+
},
|
|
116
|
+
} = useConfig<ConfigObject>();
|
|
117
|
+
|
|
118
|
+
const apiUrl = `${restBaseUrl}/conceptreferencerange/?patient=${patientUuid}&concept=${systolicBloodPressureUuid},${diastolicBloodPressureUuid},${pulseUuid},${temperatureUuid},${oxygenSaturationUuid},${respiratoryRateUuid},${heightUuid},${weightUuid},${midUpperArmCircumferenceUuid}&v=full`;
|
|
119
|
+
|
|
120
|
+
const { data, error, isLoading } = useSWRImmutable<{ data: any }, Error>(patientUuid ? apiUrl : null, openmrsFetch);
|
|
121
|
+
|
|
122
|
+
const conceptMetadata = data?.data?.results;
|
|
123
|
+
|
|
124
|
+
const conceptUnits = conceptMetadata?.length
|
|
125
|
+
? new Map<string, string>(conceptMetadata.map((concept) => [concept.concept, concept.units]))
|
|
126
|
+
: new Map<string, string>([]);
|
|
127
|
+
|
|
128
|
+
const conceptRanges = useMemo(
|
|
129
|
+
() =>
|
|
130
|
+
conceptMetadata?.length
|
|
131
|
+
? conceptMetadata.map((concept) => ({
|
|
132
|
+
uuid: concept.concept,
|
|
133
|
+
display: concept.display,
|
|
134
|
+
hiNormal: concept.hiNormal ?? null,
|
|
135
|
+
hiAbsolute: concept.hiAbsolute ?? null,
|
|
136
|
+
hiCritical: concept.hiCritical ?? null,
|
|
137
|
+
lowNormal: concept.lowNormal ?? null,
|
|
138
|
+
lowAbsolute: concept.lowAbsolute ?? null,
|
|
139
|
+
lowCritical: concept.lowCritical ?? null,
|
|
140
|
+
units: concept.units ?? null,
|
|
141
|
+
}))
|
|
142
|
+
: [],
|
|
143
|
+
[conceptMetadata],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const conceptRangeMap = useMemo(
|
|
147
|
+
() => new Map<string, ConceptRange>(conceptRanges.map((range) => [range.uuid, range])),
|
|
148
|
+
[conceptRanges],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
data: conceptUnits, // This now includes MUAC units
|
|
153
|
+
error,
|
|
154
|
+
isLoading,
|
|
155
|
+
conceptMetadata,
|
|
156
|
+
conceptRanges,
|
|
157
|
+
conceptRangeMap,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useConceptUnits() {
|
|
162
|
+
const { concepts } = useConfig<ConfigObject>();
|
|
163
|
+
const vitalSignsConceptSetUuid = concepts.vitalSignsConceptSetUuid;
|
|
164
|
+
|
|
165
|
+
const customRepresentation = 'custom:(setMembers:(uuid,display,units))';
|
|
166
|
+
|
|
167
|
+
const apiUrl = `${restBaseUrl}/concept/${vitalSignsConceptSetUuid}?v=${customRepresentation}`;
|
|
168
|
+
|
|
169
|
+
const { data, error, isLoading } = useSWRImmutable<{ data: VitalsConceptMetadataResponse }, Error>(
|
|
170
|
+
apiUrl,
|
|
171
|
+
openmrsFetch,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const conceptMetadata = data?.data?.setMembers;
|
|
175
|
+
|
|
176
|
+
const conceptUnits = conceptMetadata?.length
|
|
177
|
+
? new Map<string, string>(conceptMetadata.map((concept) => [concept.uuid, concept.units]))
|
|
178
|
+
: new Map<string, string>([]);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
conceptUnits,
|
|
182
|
+
error,
|
|
183
|
+
isLoading,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const withUnit = (label: string, unit: string | null | undefined) => {
|
|
188
|
+
return `${label} ${unit ? `(${unit})` : ''}`;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// We need to track a bound mutator for basically every hook, because there does not appear to be
|
|
192
|
+
// a way to invalidate an SWRInfinite key that works other than using the bound mutator
|
|
193
|
+
// Each mutator is stored in the vitalsHooksMutates map and removed (via a useEffect hook) when the
|
|
194
|
+
// hook is unmounted.
|
|
195
|
+
let vitalsHooksCounter = 0;
|
|
196
|
+
const vitalsHooksMutates = new Map<number, ReturnType<typeof useSWRInfinite<VitalsFetchResponse>>['mutate']>();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Hook to get concepts for vitals, biometrics or both
|
|
200
|
+
*/
|
|
201
|
+
export function useVitalsOrBiometricsConcepts(mode: VitalsAndBiometricsMode) {
|
|
202
|
+
const { concepts } = useConfig<ConfigObject>();
|
|
203
|
+
|
|
204
|
+
const conceptUuids = useMemo(() => {
|
|
205
|
+
const biometricsKeys = ['heightUuid', 'midUpperArmCircumferenceUuid', 'weightUuid'];
|
|
206
|
+
|
|
207
|
+
if (!concepts) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return Object.entries(concepts)
|
|
212
|
+
.filter(([key, conceptUuid]) => {
|
|
213
|
+
if (!conceptUuid) {
|
|
214
|
+
console.warn(`Missing UUID for concept ${key}`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if (mode === 'both') {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return mode === 'vitals' ? !biometricsKeys.includes(key) : biometricsKeys.includes(key);
|
|
221
|
+
})
|
|
222
|
+
.map(([_, conceptUuid]) => conceptUuid);
|
|
223
|
+
}, [concepts, mode]);
|
|
224
|
+
|
|
225
|
+
return conceptUuids;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Hook to get the vitals and / or biometrics for a patient
|
|
230
|
+
*
|
|
231
|
+
* @param patientUuid The uuid of the patient to get the vitals for
|
|
232
|
+
* @param mode Either 'vitals', to load only vitals, 'biometrics', to load only biometrics or 'both' to load both
|
|
233
|
+
* @returns An SWR-like structure that includes the cleaned-up vitals
|
|
234
|
+
*/
|
|
235
|
+
export function useVitalsAndBiometrics(patientUuid: string, mode: VitalsAndBiometricsMode = 'vitals') {
|
|
236
|
+
const conceptUuids = useVitalsOrBiometricsConcepts(mode);
|
|
237
|
+
const { concepts } = useConfig<ConfigObject>();
|
|
238
|
+
const { conceptRanges } = useVitalsConceptMetadata(patientUuid);
|
|
239
|
+
const { conceptUnits } = useConceptUnits();
|
|
240
|
+
|
|
241
|
+
const getPage = useCallback(
|
|
242
|
+
(page: number, prevPageData: FHIRSearchBundleResponse): VitalsAndBiometricsSwrKey => ({
|
|
243
|
+
swrKeyNeedle,
|
|
244
|
+
mode,
|
|
245
|
+
patientUuid,
|
|
246
|
+
conceptUuids: conceptUuids.join(','),
|
|
247
|
+
page,
|
|
248
|
+
prevPageData,
|
|
249
|
+
}),
|
|
250
|
+
[mode, conceptUuids, patientUuid],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const { data, isLoading, isValidating, setSize, error, size, mutate } = useSWRInfinite<VitalsFetchResponse, Error>(
|
|
254
|
+
getPage,
|
|
255
|
+
handleFetch,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// see the comments above for why this is here
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const index = ++vitalsHooksCounter;
|
|
261
|
+
vitalsHooksMutates.set(index, mutate);
|
|
262
|
+
return () => {
|
|
263
|
+
vitalsHooksMutates.delete(index);
|
|
264
|
+
};
|
|
265
|
+
}, [mutate]);
|
|
266
|
+
|
|
267
|
+
const getVitalsMapKey = useCallback(
|
|
268
|
+
(conceptUuid: string): string => {
|
|
269
|
+
switch (conceptUuid) {
|
|
270
|
+
case concepts.systolicBloodPressureUuid:
|
|
271
|
+
return 'systolic';
|
|
272
|
+
case concepts.diastolicBloodPressureUuid:
|
|
273
|
+
return 'diastolic';
|
|
274
|
+
case concepts.pulseUuid:
|
|
275
|
+
return 'pulse';
|
|
276
|
+
case concepts.temperatureUuid:
|
|
277
|
+
return 'temperature';
|
|
278
|
+
case concepts.oxygenSaturationUuid:
|
|
279
|
+
return 'spo2';
|
|
280
|
+
case concepts.respiratoryRateUuid:
|
|
281
|
+
return 'respiratoryRate';
|
|
282
|
+
case concepts.heightUuid:
|
|
283
|
+
return 'height';
|
|
284
|
+
case concepts.weightUuid:
|
|
285
|
+
return 'weight';
|
|
286
|
+
case concepts.midUpperArmCircumferenceUuid:
|
|
287
|
+
return 'muac';
|
|
288
|
+
default:
|
|
289
|
+
return ''; // or throw an error for unknown conceptUuid
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[
|
|
293
|
+
concepts.heightUuid,
|
|
294
|
+
concepts.midUpperArmCircumferenceUuid,
|
|
295
|
+
concepts.systolicBloodPressureUuid,
|
|
296
|
+
concepts.oxygenSaturationUuid,
|
|
297
|
+
concepts.diastolicBloodPressureUuid,
|
|
298
|
+
concepts.pulseUuid,
|
|
299
|
+
concepts.respiratoryRateUuid,
|
|
300
|
+
concepts.temperatureUuid,
|
|
301
|
+
concepts.weightUuid,
|
|
302
|
+
],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const formattedObs: Array<PatientVitalsAndBiometrics> = useMemo(() => {
|
|
306
|
+
const vitalsHashTable = data?.[0]?.data?.entry
|
|
307
|
+
?.map((entry) => entry.resource)
|
|
308
|
+
.filter(Boolean)
|
|
309
|
+
.map(mapVitalsAndBiometrics)
|
|
310
|
+
?.reduce((vitalsHashTable, vitalSign) => {
|
|
311
|
+
const encounterId = vitalSign.encounterId;
|
|
312
|
+
if (vitalsHashTable.has(encounterId) && vitalsHashTable.get(encounterId)) {
|
|
313
|
+
vitalsHashTable.set(encounterId, {
|
|
314
|
+
...vitalsHashTable.get(encounterId),
|
|
315
|
+
[getVitalsMapKey(vitalSign.code)]: vitalSign.value,
|
|
316
|
+
[getInterpretationKey(getVitalsMapKey(vitalSign.code))]: vitalSign.interpretation,
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
vitalSign.value &&
|
|
320
|
+
vitalsHashTable.set(encounterId, {
|
|
321
|
+
date:
|
|
322
|
+
typeof vitalSign.recordedDate === 'string'
|
|
323
|
+
? vitalSign.recordedDate
|
|
324
|
+
: vitalSign.recordedDate.toDateString(),
|
|
325
|
+
[getVitalsMapKey(vitalSign.code)]: vitalSign.value,
|
|
326
|
+
[getInterpretationKey(getVitalsMapKey(vitalSign.code))]: vitalSign.interpretation,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return vitalsHashTable;
|
|
330
|
+
}, new Map<string, Partial<PatientVitalsAndBiometrics>>());
|
|
331
|
+
|
|
332
|
+
return Array.from(vitalsHashTable ?? []).map(([encounterId, vitalSigns]) => {
|
|
333
|
+
const result = {
|
|
334
|
+
id: encounterId,
|
|
335
|
+
date: vitalSigns.date,
|
|
336
|
+
...vitalSigns,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (mode === 'both' || mode === 'biometrics') {
|
|
340
|
+
result.bmi = calculateBodyMassIndex(
|
|
341
|
+
Number(vitalSigns.weight),
|
|
342
|
+
Number(vitalSigns.height),
|
|
343
|
+
conceptUnits.get(concepts.weightUuid) as 'lb' | 'lbs' | 'g',
|
|
344
|
+
conceptUnits.get(concepts.heightUuid) as 'm' | 'cm' | 'in',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (mode === 'both' || mode === 'vitals') {
|
|
349
|
+
result.bloodPressureRenderInterpretation = interpretBloodPressure(
|
|
350
|
+
vitalSigns.systolic,
|
|
351
|
+
vitalSigns.diastolic,
|
|
352
|
+
concepts,
|
|
353
|
+
conceptRanges,
|
|
354
|
+
vitalSigns.systolicRenderInterpretation,
|
|
355
|
+
vitalSigns.diastolicRenderInterpretation,
|
|
356
|
+
);
|
|
357
|
+
result.pulseRenderInterpretation = vitalSigns.pulseRenderInterpretation;
|
|
358
|
+
result.temperatureRenderInterpretation = vitalSigns.temperatureRenderInterpretation;
|
|
359
|
+
result.spo2RenderInterpretation = vitalSigns.spo2RenderInterpretation;
|
|
360
|
+
result.respiratoryRateRenderInterpretation = vitalSigns.respiratoryRateRenderInterpretation;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
});
|
|
365
|
+
}, [conceptRanges, conceptUnits, concepts, data, getVitalsMapKey, mode]);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
data: data ? formattedObs : undefined,
|
|
369
|
+
isLoading,
|
|
370
|
+
error,
|
|
371
|
+
hasMore: data?.length
|
|
372
|
+
? !!data[data.length - 1].data?.link?.some((link: { relation?: string }) => link.relation === 'next')
|
|
373
|
+
: false,
|
|
374
|
+
isValidating,
|
|
375
|
+
loadingNewData: isValidating,
|
|
376
|
+
setPage: setSize,
|
|
377
|
+
currentPage: size,
|
|
378
|
+
totalResults: data?.[0]?.data?.total ?? undefined,
|
|
379
|
+
mutate,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Hook to get the vitals and biometrics for a patient associated with a specific encounter
|
|
385
|
+
*/
|
|
386
|
+
export function useEncounterVitalsAndBiometrics(encounterUuid: string) {
|
|
387
|
+
const { concepts } = useConfig<ConfigObject>();
|
|
388
|
+
const fieldNameSuffix = 'Uuid';
|
|
389
|
+
const url = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterRepresentation}` : null;
|
|
390
|
+
|
|
391
|
+
const { data, isLoading, error, mutate } = useSWRImmutable<FetchResponse<PartialEncounter>, Error>(url, openmrsFetch);
|
|
392
|
+
|
|
393
|
+
const vitalsAndBiometrics: VitalsAndBiometricsFieldValuesMap = useMemo(() => {
|
|
394
|
+
if (!isLoading && data && concepts) {
|
|
395
|
+
return data.data.obs.reduce((vitalsAndBiometrics, obs) => {
|
|
396
|
+
let fieldName = Object.entries(concepts).find(([, value]) => value === obs.concept.uuid)?.[0];
|
|
397
|
+
|
|
398
|
+
if (fieldName) {
|
|
399
|
+
fieldName = fieldName.endsWith(fieldNameSuffix) ? fieldName.replace(fieldNameSuffix, '') : fieldName;
|
|
400
|
+
vitalsAndBiometrics.set(fieldName, {
|
|
401
|
+
value: obs.value,
|
|
402
|
+
obs,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return vitalsAndBiometrics;
|
|
406
|
+
}, new Map<string, { value: string; obs: OpenmrsResource }>());
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}, [isLoading, data, concepts]);
|
|
410
|
+
|
|
411
|
+
const getRefinedInitialValues = useCallback(() => {
|
|
412
|
+
const initialValues: Record<string, string | number> = {};
|
|
413
|
+
if (vitalsAndBiometrics) {
|
|
414
|
+
vitalsAndBiometrics.forEach((value, key) => {
|
|
415
|
+
initialValues[key] = value.value;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return initialValues;
|
|
419
|
+
}, [vitalsAndBiometrics]);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
isLoading,
|
|
423
|
+
vitalsAndBiometrics,
|
|
424
|
+
encounter: data?.data,
|
|
425
|
+
error,
|
|
426
|
+
mutate,
|
|
427
|
+
getRefinedInitialValues,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Fetcher for the useVitalsAndBiometricsHook
|
|
433
|
+
* @internal
|
|
434
|
+
*/
|
|
435
|
+
function handleFetch({ patientUuid, conceptUuids, page, prevPageData }: VitalsAndBiometricsSwrKey) {
|
|
436
|
+
if (prevPageData && !prevPageData?.data?.link.some((link) => link.relation === 'next')) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let url = `${fhirBaseUrl}/Observation?subject:Patient=${patientUuid}&`;
|
|
441
|
+
let urlSearchParams = new URLSearchParams();
|
|
442
|
+
|
|
443
|
+
urlSearchParams.append('code', conceptUuids);
|
|
444
|
+
urlSearchParams.append('_summary', 'data');
|
|
445
|
+
urlSearchParams.append('_sort', '-date');
|
|
446
|
+
urlSearchParams.append('_count', pageSize.toString());
|
|
447
|
+
|
|
448
|
+
if (page) {
|
|
449
|
+
urlSearchParams.append('_getpagesoffset', (page * pageSize).toString());
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return openmrsFetch<VitalsResponse>(url + urlSearchParams.toString());
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Mapper that converts a FHIR Observation resource into a MappedVitals object.
|
|
457
|
+
* @internal
|
|
458
|
+
*/
|
|
459
|
+
function mapVitalsAndBiometrics(resource: FHIRObservationResource): MappedVitals {
|
|
460
|
+
const referenceRanges = {
|
|
461
|
+
uuid: resource?.code?.coding?.[0]?.code,
|
|
462
|
+
display: resource?.code?.text,
|
|
463
|
+
hiNormal: null,
|
|
464
|
+
hiAbsolute: null,
|
|
465
|
+
hiCritical: null,
|
|
466
|
+
lowNormal: null,
|
|
467
|
+
lowAbsolute: null,
|
|
468
|
+
lowCritical: null,
|
|
469
|
+
units: resource.valueQuantity?.unit ?? null,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
resource?.referenceRange?.forEach((range) => {
|
|
473
|
+
const rangeType = range.type?.coding?.[0]?.code;
|
|
474
|
+
const rangeSystem = range.type?.coding?.[0]?.system;
|
|
475
|
+
|
|
476
|
+
if (rangeSystem === 'http://terminology.hl7.org/CodeSystem/referencerange-meaning') {
|
|
477
|
+
if (rangeType === 'normal') {
|
|
478
|
+
referenceRanges.hiNormal = range.high?.value ?? null;
|
|
479
|
+
referenceRanges.lowNormal = range.low?.value ?? null;
|
|
480
|
+
} else if (rangeType === 'treatment') {
|
|
481
|
+
referenceRanges.hiCritical = range.high?.value ?? null;
|
|
482
|
+
referenceRanges.lowCritical = range.low?.value ?? null;
|
|
483
|
+
}
|
|
484
|
+
} else if (rangeSystem === 'http://fhir.openmrs.org/ext/obs/reference-range' && rangeType === 'absolute') {
|
|
485
|
+
referenceRanges.hiAbsolute = range.high?.value ?? null;
|
|
486
|
+
referenceRanges.lowAbsolute = range.low?.value ?? null;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
code: resource?.code?.coding?.[0]?.code,
|
|
492
|
+
encounterId: extractEncounterUuid(resource.encounter),
|
|
493
|
+
// Use Observation.interpretation from FHIR when available (preferred).
|
|
494
|
+
// Fallback to calculation for backward compatibility: existing observations may not have
|
|
495
|
+
// interpretation set if they were created before interpretation was added, or if reference
|
|
496
|
+
// ranges weren't available at creation time (OpenMRS core only sets interpretation when
|
|
497
|
+
// ObsReferenceRange is present).
|
|
498
|
+
interpretation: resource.interpretation?.[0]?.coding?.[0]?.display
|
|
499
|
+
? mapFhirInterpretationToObservationInterpretation(resource.interpretation?.[0]?.coding?.[0]?.display)
|
|
500
|
+
: assessValue(resource?.valueQuantity?.value, referenceRanges),
|
|
501
|
+
recordedDate: resource?.effectiveDateTime,
|
|
502
|
+
value: resource?.valueQuantity?.value,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function createOrUpdateVitalsAndBiometrics(
|
|
507
|
+
patientUuid: string,
|
|
508
|
+
encounterTypeUuid: string,
|
|
509
|
+
encounterUuid: string | null,
|
|
510
|
+
location: string,
|
|
511
|
+
visitUuid: string,
|
|
512
|
+
providerUuid: string,
|
|
513
|
+
vitalsAndBiometricsObs: Array<OpenmrsResource>,
|
|
514
|
+
abortController: AbortController,
|
|
515
|
+
) {
|
|
516
|
+
const url = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}` : `${restBaseUrl}/encounter`;
|
|
517
|
+
|
|
518
|
+
const encounter = {
|
|
519
|
+
patient: patientUuid,
|
|
520
|
+
obs: vitalsAndBiometricsObs,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
if (!encounterUuid) {
|
|
524
|
+
encounter['location'] = location;
|
|
525
|
+
encounter['encounterType'] = encounterTypeUuid;
|
|
526
|
+
encounter['encounterDatetime'] = new Date().toISOString();
|
|
527
|
+
|
|
528
|
+
if (visitUuid) {
|
|
529
|
+
encounter['visit'] = visitUuid;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (providerUuid) {
|
|
533
|
+
encounter['encounterProviders'] = [
|
|
534
|
+
{
|
|
535
|
+
provider: providerUuid,
|
|
536
|
+
encounterRole: 'a0b03050-c99b-11e0-9572-0800200c9a66',
|
|
537
|
+
},
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return openmrsFetch(url, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: {
|
|
545
|
+
'Content-Type': 'application/json',
|
|
546
|
+
},
|
|
547
|
+
signal: abortController.signal,
|
|
548
|
+
body: encounter,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function deleteEncounter(encounterUuid: string) {
|
|
553
|
+
return openmrsFetch(`${restBaseUrl}/encounter/${encounterUuid}`, {
|
|
554
|
+
method: 'DELETE',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function extractEncounterUuid(encounter: FHIRResource['resource']['encounter']): string {
|
|
559
|
+
if (!encounter || !encounter.reference) {
|
|
560
|
+
return '';
|
|
561
|
+
}
|
|
562
|
+
return encounter.reference.split('/')[1];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Invalidate all useVitalsAndBiometrics hooks data, to force them to reload
|
|
567
|
+
*/
|
|
568
|
+
export async function invalidateCachedVitalsAndBiometrics() {
|
|
569
|
+
vitalsHooksMutates.forEach((mutate) => mutate());
|
|
570
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/layout';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
5
|
+
|
|
6
|
+
.container {
|
|
7
|
+
margin: layout.$spacing-05;
|
|
8
|
+
|
|
9
|
+
& section {
|
|
10
|
+
margin: layout.$spacing-05 0 layout.$spacing-03;
|
|
11
|
+
|
|
12
|
+
&:first-of-type {
|
|
13
|
+
margin-top: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&:last-of-type {
|
|
17
|
+
margin-bottom: 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.heading {
|
|
23
|
+
@include type.type-style('heading-03');
|
|
24
|
+
margin: layout.$spacing-05;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.sectionTitle {
|
|
28
|
+
@include type.type-style('heading-compact-02');
|
|
29
|
+
color: colors.$gray-70;
|
|
30
|
+
margin: 0 0 layout.$spacing-03 0;
|
|
31
|
+
|
|
32
|
+
&:after {
|
|
33
|
+
content: '';
|
|
34
|
+
display: block;
|
|
35
|
+
width: layout.$spacing-07;
|
|
36
|
+
padding-top: 3px;
|
|
37
|
+
border-bottom: 0.375rem solid;
|
|
38
|
+
@include brand-03(border-bottom-color);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.dateTimeSection {
|
|
43
|
+
display: flex;
|
|
44
|
+
margin: layout.$spacing-05 0;
|
|
45
|
+
align-items: center;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.radioButton {
|
|
49
|
+
margin: layout.$spacing-05 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.headerGridRow {
|
|
53
|
+
border-bottom: 0.0625rem solid colors.$gray-20;
|
|
54
|
+
margin: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.dataGridRow {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: 50% 10% 1fr;
|
|
60
|
+
margin: layout.$spacing-03 layout.$spacing-05;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.form {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
justify-content: start;
|
|
67
|
+
height: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.patientHeader {
|
|
71
|
+
padding: layout.$spacing-05;
|
|
72
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
73
|
+
background-color: colors.$gray-10;
|
|
74
|
+
|
|
75
|
+
.patientName {
|
|
76
|
+
@include type.type-style('heading-compact-01');
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
margin-right: layout.$spacing-03;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.patientId {
|
|
82
|
+
@include type.type-style('body-compact-01');
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
margin-right: layout.$spacing-02;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.patientDetails {
|
|
88
|
+
@include type.type-style('body-compact-01');
|
|
89
|
+
color: colors.$gray-70;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.buttonSet {
|
|
94
|
+
margin-top: auto;
|
|
95
|
+
justify-self: end;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.button {
|
|
99
|
+
height: layout.$spacing-10;
|
|
100
|
+
display: flex;
|
|
101
|
+
align-content: flex-start;
|
|
102
|
+
align-items: baseline;
|
|
103
|
+
min-width: 50%;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tablet {
|
|
107
|
+
padding: layout.$spacing-06 layout.$spacing-05;
|
|
108
|
+
background-color: white;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.desktop {
|
|
112
|
+
padding: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.inputRow {
|
|
116
|
+
display: grid;
|
|
117
|
+
grid-template-columns: 1fr 1fr;
|
|
118
|
+
gap: layout.$spacing-05;
|
|
119
|
+
|
|
120
|
+
:global(.omrs-breakpoint-lt-tablet) & {
|
|
121
|
+
grid-template-columns: 1fr;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.dateTimeSection {
|
|
126
|
+
:global(.omrs-breakpoint-lt-tablet) & {
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.inlineNotification {
|
|
132
|
+
width: 100%;
|
|
133
|
+
max-width: unset;
|
|
134
|
+
padding: '0';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.label {
|
|
138
|
+
@include type.type-style('label-01');
|
|
139
|
+
color: colors.$gray-70;
|
|
140
|
+
}
|