@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.
Files changed (148) hide show
  1. package/README.md +4 -0
  2. package/dist/117.js +1 -0
  3. package/dist/117.js.map +1 -0
  4. package/dist/132.js +1 -0
  5. package/dist/132.js.map +1 -0
  6. package/dist/152.js +1 -0
  7. package/dist/152.js.map +1 -0
  8. package/dist/183.js +66 -0
  9. package/dist/183.js.map +1 -0
  10. package/dist/209.js +1 -0
  11. package/dist/209.js.map +1 -0
  12. package/dist/22.js +1 -0
  13. package/dist/22.js.map +1 -0
  14. package/dist/222.js +1 -0
  15. package/dist/222.js.map +1 -0
  16. package/dist/317.js +1 -0
  17. package/dist/317.js.map +1 -0
  18. package/dist/349.js +1 -0
  19. package/dist/349.js.map +1 -0
  20. package/dist/371.js +1 -0
  21. package/dist/371.js.map +1 -0
  22. package/dist/378.js +1 -0
  23. package/dist/378.js.map +1 -0
  24. package/dist/442.js +1 -0
  25. package/dist/442.js.map +1 -0
  26. package/dist/45.js +1 -0
  27. package/dist/45.js.map +1 -0
  28. package/dist/466.js +1 -0
  29. package/dist/466.js.map +1 -0
  30. package/dist/508.js +1 -0
  31. package/dist/508.js.map +1 -0
  32. package/dist/61.js +1 -0
  33. package/dist/61.js.map +1 -0
  34. package/dist/640.js +1 -0
  35. package/dist/640.js.map +1 -0
  36. package/dist/689.js +1 -0
  37. package/dist/689.js.map +1 -0
  38. package/dist/709.js +1 -0
  39. package/dist/709.js.map +1 -0
  40. package/dist/712.js +1 -0
  41. package/dist/712.js.map +1 -0
  42. package/dist/720.js +1 -0
  43. package/dist/720.js.map +1 -0
  44. package/dist/737.js +12 -0
  45. package/dist/737.js.map +1 -0
  46. package/dist/742.js +1 -0
  47. package/dist/742.js.map +1 -0
  48. package/dist/771.js +1 -0
  49. package/dist/771.js.map +1 -0
  50. package/dist/806.js +1 -0
  51. package/dist/806.js.map +1 -0
  52. package/dist/809.js +1 -0
  53. package/dist/809.js.map +1 -0
  54. package/dist/9.js +15 -0
  55. package/dist/9.js.map +1 -0
  56. package/dist/912.js +1 -0
  57. package/dist/912.js.map +1 -0
  58. package/dist/913.js +1 -0
  59. package/dist/913.js.map +1 -0
  60. package/dist/940.js +1 -0
  61. package/dist/940.js.map +1 -0
  62. package/dist/974.js +1 -0
  63. package/dist/974.js.map +1 -0
  64. package/dist/976.js +1 -0
  65. package/dist/976.js.map +1 -0
  66. package/dist/digihmis-esm-triage-app.js +6 -0
  67. package/dist/digihmis-esm-triage-app.js.buildmanifest.json +905 -0
  68. package/dist/digihmis-esm-triage-app.js.map +1 -0
  69. package/dist/main.js +90 -0
  70. package/dist/main.js.map +1 -0
  71. package/dist/routes.json +1 -0
  72. package/jest.config.js +26 -0
  73. package/package.json +51 -0
  74. package/rspack.config.js +1 -0
  75. package/src/config-schema.ts +142 -0
  76. package/src/dashboard.meta.ts +16 -0
  77. package/src/declarations.d.ts +6 -0
  78. package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
  79. package/src/index.ts +41 -0
  80. package/src/left-panel/left-panel.component.tsx +100 -0
  81. package/src/left-panel/left-panel.scss +13 -0
  82. package/src/page-header/page-header.component.tsx +35 -0
  83. package/src/page-header/page-header.scss +76 -0
  84. package/src/past-vitals/action-menu/vitals-anthropometrics.component.tsx +44 -0
  85. package/src/past-vitals/action-menu/vitals-anthropometrics.scss +0 -0
  86. package/src/past-vitals/paginated-antropometrics/paginated-antropometrics.component.tsx +132 -0
  87. package/src/past-vitals/paginated-antropometrics/paginated-antropometrics.scss +9 -0
  88. package/src/past-vitals/paginated-vitals/paginated-vitals.component.tsx +157 -0
  89. package/src/past-vitals/paginated-vitals/paginated-vitals.scss +94 -0
  90. package/src/past-vitals/paginated-vitals/type/index.ts +1 -0
  91. package/src/past-vitals/patient-banner/patient-banner.component.tsx +60 -0
  92. package/src/past-vitals/patient-banner/patient-banner.scss +72 -0
  93. package/src/past-vitals/patient-vitals-history.component.tsx +85 -0
  94. package/src/past-vitals/patient-vitals-history.resource.ts +30 -0
  95. package/src/past-vitals/patient-vitals-history.scss +75 -0
  96. package/src/past-vitals/triage-anthropometrics/antropometrics-chart.component.tsx +170 -0
  97. package/src/past-vitals/triage-anthropometrics/antropometrics-chart.scss +70 -0
  98. package/src/past-vitals/triage-anthropometrics/triage-antropometrics.component.tsx +124 -0
  99. package/src/past-vitals/triage-anthropometrics/triage-antropometrics.scss +37 -0
  100. package/src/past-vitals/triage-anthropometrics/type/index.ts +17 -0
  101. package/src/past-vitals/triage-notes/triage-note.scss +94 -0
  102. package/src/past-vitals/triage-notes/triage-notes.component.tsx +75 -0
  103. package/src/past-vitals/triage-notes/triage-notes.resource.ts +72 -0
  104. package/src/past-vitals/triage-notes/type/index.ts +50 -0
  105. package/src/past-vitals/triage-vitals/triage-vitals-history.component.tsx +229 -0
  106. package/src/past-vitals/triage-vitals/triage-vitals-history.scss +48 -0
  107. package/src/past-vitals/triage-vitals/type/index.ts +32 -0
  108. package/src/past-vitals/triage-vitals/vital-chart.component.tsx +198 -0
  109. package/src/past-vitals/triage-vitals/vitals-chart.scss +70 -0
  110. package/src/past-vitals/utils/index.ts +5 -0
  111. package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.component.tsx +61 -0
  112. package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.scss +171 -0
  113. package/src/past-vitals/vitals-and-biometrics-header/vitals-header-item.test.tsx +31 -0
  114. package/src/past-vitals/vitals-and-biometrics-header/vitals-header.extension.tsx +225 -0
  115. package/src/past-vitals/vitals-and-biometrics-header/vitals-header.scss +233 -0
  116. package/src/queue-room-table/queue-room-table.component.tsx +380 -0
  117. package/src/queue-room-table/queue-room-table.resource.ts +73 -0
  118. package/src/queue-room-table/queue-room-table.scss +193 -0
  119. package/src/queue-room-table/type/index.ts +30 -0
  120. package/src/queue-room-table/useQueueActions.ts +24 -0
  121. package/src/record-vitals-workspace/common/index.ts +19 -0
  122. package/src/record-vitals-workspace/component/vitals-biometrics-input.component.tsx +433 -0
  123. package/src/record-vitals-workspace/component/vitals-biometrics-input.scss +358 -0
  124. package/src/record-vitals-workspace/helper/index.ts +200 -0
  125. package/src/record-vitals-workspace/record-vitals.resource.ts +570 -0
  126. package/src/record-vitals-workspace/record-vitals.scss +140 -0
  127. package/src/record-vitals-workspace/record-vitals.workspace.tsx +612 -0
  128. package/src/record-vitals-workspace/schema/index.ts +28 -0
  129. package/src/record-vitals-workspace/type/index.ts +170 -0
  130. package/src/record-vitals-workspace/utils/index.ts +100 -0
  131. package/src/routes.json +70 -0
  132. package/src/setup-tests.ts +1 -0
  133. package/src/triage-queue-dashboard/triage-dashboard.resource.ts +75 -0
  134. package/src/triage-queue-dashboard/triage-queue-dashboard.component.tsx +261 -0
  135. package/src/triage-queue-dashboard/triage-queue-dashboard.scss +166 -0
  136. package/src/triage-queue-dashboard/type/index.ts +31 -0
  137. package/translations/am.json +92 -0
  138. package/translations/ar.json +92 -0
  139. package/translations/en.json +92 -0
  140. package/translations/es.json +92 -0
  141. package/translations/fr.json +92 -0
  142. package/translations/he.json +92 -0
  143. package/translations/km.json +92 -0
  144. package/translations/pt.json +92 -0
  145. package/translations/sw.json +92 -0
  146. package/translations/vi.json +92 -0
  147. package/translations/zh.json +92 -0
  148. 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
+ }