@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,612 @@
1
+ import React, { useMemo, useState, useCallback, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useForm, Controller } from 'react-hook-form';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import {
6
+ Form,
7
+ ButtonSet,
8
+ Button,
9
+ Stack,
10
+ NumberInput,
11
+ Row,
12
+ NumberInputSkeleton,
13
+ ButtonSkeleton,
14
+ InlineNotification,
15
+ Column,
16
+ } from '@carbon/react';
17
+ import {
18
+ type DefaultWorkspaceProps,
19
+ ExtensionSlot,
20
+ Extension,
21
+ useConfig,
22
+ useLayoutType,
23
+ type AssignedExtension,
24
+ useSession,
25
+ useAbortController,
26
+ showSnackbar,
27
+ age,
28
+ navigate,
29
+ useVisit,
30
+ } from '@openmrs/esm-framework';
31
+ import styles from './record-vitals.scss';
32
+ import classNames from 'classnames';
33
+ import { type ConfigObject } from '../config-schema';
34
+ import {
35
+ usePerson,
36
+ createOrUpdateVitalsAndBiometrics,
37
+ invalidateCachedVitalsAndBiometrics,
38
+ useConceptUnits,
39
+ useVitalsConceptMetadata,
40
+ useEncounterVitalsAndBiometrics,
41
+ } from './record-vitals.resource';
42
+ import {
43
+ calculateBodyMassIndex,
44
+ assessValue,
45
+ getReferenceRangesForConcept,
46
+ interpretBloodPressure,
47
+ prepareObsForSubmission,
48
+ } from './helper';
49
+ import { VitalsAndBiometricsFormSchema, type VitalsBiometricsFormData } from './schema';
50
+ import capitalize from 'lodash-es/capitalize';
51
+ import { extractNumbers, getMuacColorCode, isValueWithinReferenceRange } from './utils';
52
+ import { useOptimisticVisitMutations } from '@openmrs/esm-patient-common-lib';
53
+ import VitalsAndBiometricsInput from './component/vitals-biometrics-input.component';
54
+
55
+ interface RecordVitalsWorkspaceProps extends DefaultWorkspaceProps {
56
+ patientUuid: string;
57
+ patient?: fhir.Patient;
58
+ formContext: 'creating' | 'editing';
59
+ editEncounterUuid?: string;
60
+ queueEntryUuid?: string;
61
+ completeServiceCallback?: () => Promise<void>;
62
+ }
63
+
64
+ const RecordVitalsWorkspace: React.FC<RecordVitalsWorkspaceProps> = ({
65
+ patientUuid,
66
+ patient,
67
+ editEncounterUuid,
68
+ formContext = 'creating',
69
+ queueEntryUuid,
70
+ completeServiceCallback,
71
+ closeWorkspace,
72
+ closeWorkspaceWithSavedChanges,
73
+ promptBeforeClosing,
74
+ }) => {
75
+ const { t } = useTranslation();
76
+ const isTablet = useLayoutType() === 'tablet';
77
+ const config = useConfig<ConfigObject>();
78
+ const biometricsUnitsSymbols = config.biometrics;
79
+ const useMuacColorStatus = config.vitals.useMuacColors;
80
+ const { activeVisit } = useVisit(patientUuid);
81
+
82
+ const session = useSession();
83
+ const { conceptUnits, isLoading: isLoadingConceptUnits } = useConceptUnits();
84
+ const {
85
+ conceptRanges,
86
+ conceptRangeMap,
87
+ data: conceptUnitsFromMetadata,
88
+ isLoading: isLoadingMetadata,
89
+ } = useVitalsConceptMetadata(patientUuid);
90
+
91
+ // Merge concept units from both sources
92
+ const allConceptUnits = useMemo(() => {
93
+ const merged = new Map(conceptUnits);
94
+ if (conceptUnitsFromMetadata) {
95
+ conceptUnitsFromMetadata.forEach((value, key) => {
96
+ if (!merged.has(key) && value) {
97
+ merged.set(key, value);
98
+ }
99
+ });
100
+ }
101
+ return merged;
102
+ }, [conceptUnits, conceptUnitsFromMetadata]);
103
+
104
+ const { person } = usePerson(patientUuid);
105
+ const openmrsId =
106
+ person?.identifiers?.find((id) => id.identifierType?.uuid === config.OpenmrsIdentifierTypeUuid)?.identifier || '';
107
+ const {
108
+ getRefinedInitialValues,
109
+ isLoading: isLoadingEncounter,
110
+ mutate: mutateEncounter,
111
+ vitalsAndBiometrics: initialFieldValuesMap,
112
+ } = useEncounterVitalsAndBiometrics(formContext === 'editing' ? editEncounterUuid : null);
113
+ const [hasInvalidVitals, setHasInvalidVitals] = useState(false);
114
+ const [muacColorCode, setMuacColorCode] = useState('');
115
+ const [showErrorNotification, setShowErrorNotification] = useState(false);
116
+ const [showErrorMessage, setShowErrorMessage] = useState(false);
117
+ const abortController = useAbortController();
118
+ const { invalidateVisitRelatedData } = useOptimisticVisitMutations(patientUuid);
119
+
120
+ const isLoadingInitialValues = useMemo(
121
+ () => (formContext === 'creating' ? false : isLoadingEncounter),
122
+ [formContext, isLoadingEncounter],
123
+ );
124
+
125
+ const {
126
+ control,
127
+ handleSubmit,
128
+ watch,
129
+ setValue,
130
+ formState: { isDirty, isSubmitting, dirtyFields },
131
+ reset,
132
+ } = useForm<VitalsBiometricsFormData>({
133
+ mode: 'all',
134
+ resolver: zodResolver(VitalsAndBiometricsFormSchema),
135
+ });
136
+
137
+ useEffect(() => {
138
+ if (formContext === 'editing' && !isLoadingInitialValues && initialFieldValuesMap) {
139
+ reset(getRefinedInitialValues());
140
+ }
141
+ }, [formContext, isLoadingInitialValues, initialFieldValuesMap, getRefinedInitialValues, reset]);
142
+
143
+ useEffect(() => promptBeforeClosing(() => isDirty), [isDirty, promptBeforeClosing]);
144
+
145
+ const midUpperArmCircumference = watch('midUpperArmCircumference');
146
+ const systolicBloodPressure = watch('systolicBloodPressure');
147
+ const diastolicBloodPressure = watch('diastolicBloodPressure');
148
+ const respiratoryRate = watch('respiratoryRate');
149
+ const oxygenSaturation = watch('oxygenSaturation');
150
+ const temperature = watch('temperature');
151
+ const pulse = watch('pulse');
152
+ const weight = watch('weight');
153
+ const height = watch('height');
154
+
155
+ useEffect(() => {
156
+ const patientBirthDate = patient?.birthDate;
157
+ if (patientBirthDate && midUpperArmCircumference) {
158
+ const patientAge = extractNumbers(age(patientBirthDate));
159
+ getMuacColorCode(patientAge, midUpperArmCircumference, setMuacColorCode);
160
+ }
161
+ }, [watch, patient?.birthDate, midUpperArmCircumference]);
162
+
163
+ useEffect(() => {
164
+ if (height && weight) {
165
+ const computedBodyMassIndex = calculateBodyMassIndex(
166
+ weight,
167
+ height,
168
+ allConceptUnits.get(config.concepts.weightUuid) as 'lb' | 'lbs' | 'g',
169
+ allConceptUnits.get(config.concepts.heightUuid) as 'm' | 'cm' | 'in',
170
+ );
171
+ setValue('computedBodyMassIndex', computedBodyMassIndex);
172
+ }
173
+ }, [weight, height, setValue, allConceptUnits, config.concepts.weightUuid, config.concepts.heightUuid]);
174
+
175
+ function onError(err) {
176
+ if (err?.oneFieldRequired) {
177
+ setShowErrorNotification(true);
178
+ }
179
+ }
180
+
181
+ const concepts = useMemo(
182
+ () => ({
183
+ midUpperArmCircumferenceRange: conceptRangeMap.get(config.concepts.midUpperArmCircumferenceUuid),
184
+ diastolicBloodPressureRange: conceptRangeMap.get(config.concepts.diastolicBloodPressureUuid),
185
+ systolicBloodPressureRange: conceptRangeMap.get(config.concepts.systolicBloodPressureUuid),
186
+ oxygenSaturationRange: conceptRangeMap.get(config.concepts.oxygenSaturationUuid),
187
+ respiratoryRateRange: conceptRangeMap.get(config.concepts.respiratoryRateUuid),
188
+ temperatureRange: conceptRangeMap.get(config.concepts.temperatureUuid),
189
+ weightRange: conceptRangeMap.get(config.concepts.weightUuid),
190
+ heightRange: conceptRangeMap.get(config.concepts.heightUuid),
191
+ pulseRange: conceptRangeMap.get(config.concepts.pulseUuid),
192
+ }),
193
+ [conceptRangeMap, config.concepts],
194
+ );
195
+
196
+ const savePatientVitalsAndBiometrics = useCallback(
197
+ async (data: VitalsBiometricsFormData) => {
198
+ const formData = data;
199
+ setShowErrorMessage(true);
200
+ setShowErrorNotification(false);
201
+
202
+ data?.computedBodyMassIndex && delete data.computedBodyMassIndex;
203
+
204
+ const allFieldsAreValid = Object.entries(formData)
205
+ .filter(([, value]) => Boolean(value))
206
+ .every(([key, value]) => isValueWithinReferenceRange(conceptRanges, config.concepts[`${key}Uuid`], value));
207
+
208
+ if (allFieldsAreValid) {
209
+ setShowErrorMessage(false);
210
+ const { newObs, toBeVoided } = prepareObsForSubmission(
211
+ formData,
212
+ dirtyFields,
213
+ formContext,
214
+ initialFieldValuesMap,
215
+ config.concepts,
216
+ );
217
+
218
+ try {
219
+ await createOrUpdateVitalsAndBiometrics(
220
+ patientUuid,
221
+ config.vitals.encounterTypeUuid,
222
+ editEncounterUuid,
223
+ session?.sessionLocation?.uuid,
224
+ activeVisit?.uuid,
225
+ session?.currentProvider?.uuid,
226
+ [...newObs, ...toBeVoided],
227
+ abortController,
228
+ );
229
+
230
+ if (mutateEncounter) {
231
+ mutateEncounter();
232
+ }
233
+
234
+ invalidateVisitRelatedData({ observations: true, encounters: true });
235
+ invalidateCachedVitalsAndBiometrics();
236
+ closeWorkspaceWithSavedChanges();
237
+
238
+ showSnackbar({
239
+ isLowContrast: true,
240
+ kind: 'success',
241
+ title:
242
+ formContext === 'creating'
243
+ ? t('vitalsAndBiometricsSaved', 'Vitals and Biometrics saved')
244
+ : t('vitalsAndBiometricsUpdated', 'Vitals and Biometrics updated'),
245
+ subtitle: t('vitalsAndBiometricsNowAvailable', 'They are now visible on the Vitals and Biometrics page'),
246
+ });
247
+
248
+ if (formContext === 'creating') {
249
+ navigate({
250
+ to: `${window.spaBase}/home/triage/patient/${patientUuid}/vitals`,
251
+ });
252
+ }
253
+ } catch (error) {
254
+ showSnackbar({
255
+ title:
256
+ formContext === 'creating'
257
+ ? t('vitalsAndBiometricsSaveError', 'Error saving Vitals and Biometrics')
258
+ : t('vitalsAndBiometricsUpdateError', 'Error updating Vitals and Biometrics'),
259
+ kind: 'error',
260
+ isLowContrast: false,
261
+ subtitle: t('checkForValidity', 'Some of the values entered are invalid'),
262
+ });
263
+ }
264
+ } else {
265
+ setHasInvalidVitals(true);
266
+ }
267
+ },
268
+ [
269
+ abortController,
270
+ activeVisit?.uuid,
271
+ closeWorkspaceWithSavedChanges,
272
+ config.concepts,
273
+ config.vitals.encounterTypeUuid,
274
+ dirtyFields,
275
+ editEncounterUuid,
276
+ conceptRanges,
277
+ formContext,
278
+ initialFieldValuesMap,
279
+ mutateEncounter,
280
+ invalidateVisitRelatedData,
281
+ patientUuid,
282
+ session?.sessionLocation?.uuid,
283
+ session?.currentProvider?.uuid,
284
+ t,
285
+ completeServiceCallback,
286
+ ],
287
+ );
288
+
289
+ if (isLoadingConceptUnits || isLoadingInitialValues || isLoadingMetadata) {
290
+ return (
291
+ <Form className={styles.form}>
292
+ <ExtensionSlot name="visit-context-header-slot" state={{ patientUuid }} />
293
+ <div className={styles.grid}>
294
+ <Stack>
295
+ <Column>
296
+ <p className={styles.title}>{t('recordVitals', 'Record vitals')}</p>
297
+ </Column>
298
+ <Row className={styles.row}>
299
+ <Column>
300
+ <NumberInputSkeleton />
301
+ </Column>
302
+ <Column>
303
+ <NumberInputSkeleton />
304
+ </Column>
305
+ <Column>
306
+ <NumberInputSkeleton />
307
+ </Column>
308
+ <Column>
309
+ <NumberInputSkeleton />
310
+ </Column>
311
+ </Row>
312
+ </Stack>
313
+ </div>
314
+ <ButtonSet className={isTablet ? styles.tablet : styles.desktop}>
315
+ <ButtonSkeleton className={styles.button} />
316
+ <ButtonSkeleton className={styles.button} />
317
+ </ButtonSet>
318
+ </Form>
319
+ );
320
+ }
321
+
322
+ return (
323
+ <Form
324
+ className={styles.form}
325
+ data-openmrs-role="record-vitals-form"
326
+ onSubmit={handleSubmit(savePatientVitalsAndBiometrics, onError)}>
327
+ <div className={styles.patientHeader}>
328
+ <span className={styles.patientName}>{capitalize(person?.display)}</span>
329
+ <span className={styles.patientDetails}>· {openmrsId} </span>
330
+ <span className={styles.patientDetails}>
331
+ · {person?.gender} · {person?.age} {t('years', 'yrs')}
332
+ </span>
333
+ </div>
334
+ <Stack gap={5} className={styles.container}>
335
+ <h1 className={styles.sectionTitle}>{t('vitalSigns', 'Vital Signs')}</h1>
336
+ <div className={styles.inputRow}>
337
+ <VitalsAndBiometricsInput
338
+ control={control}
339
+ fieldProperties={[
340
+ {
341
+ id: 'temperature',
342
+ max: concepts.temperatureRange?.hiAbsolute,
343
+ min: concepts.temperatureRange?.lowAbsolute,
344
+ name: t('temperature', 'Temperature'),
345
+ type: 'number',
346
+ },
347
+ ]}
348
+ interpretation={
349
+ temperature &&
350
+ assessValue(temperature, getReferenceRangesForConcept(config.concepts.temperatureUuid, conceptRanges))
351
+ }
352
+ isValueWithinReferenceRange={
353
+ temperature
354
+ ? isValueWithinReferenceRange(conceptRanges, config.concepts['temperatureUuid'], temperature)
355
+ : true
356
+ }
357
+ showErrorMessage={showErrorMessage}
358
+ label={t('temperature', 'Temperature')}
359
+ unitSymbol={allConceptUnits.get(config.concepts.temperatureUuid) ?? ''}
360
+ />
361
+ <VitalsAndBiometricsInput
362
+ control={control}
363
+ fieldProperties={[
364
+ {
365
+ name: t('systolic', 'systolic'),
366
+ separator: '/',
367
+ type: 'number',
368
+ min: concepts.systolicBloodPressureRange?.lowAbsolute,
369
+ max: concepts.systolicBloodPressureRange?.hiAbsolute,
370
+ id: 'systolicBloodPressure',
371
+ },
372
+ {
373
+ name: t('diastolic', 'diastolic'),
374
+ type: 'number',
375
+ min: concepts.diastolicBloodPressureRange?.lowAbsolute,
376
+ max: concepts.diastolicBloodPressureRange?.hiAbsolute,
377
+ id: 'diastolicBloodPressure',
378
+ },
379
+ ]}
380
+ interpretation={
381
+ systolicBloodPressure &&
382
+ diastolicBloodPressure &&
383
+ interpretBloodPressure(systolicBloodPressure, diastolicBloodPressure, config.concepts, conceptRanges)
384
+ }
385
+ isValueWithinReferenceRange={
386
+ systolicBloodPressure &&
387
+ diastolicBloodPressure &&
388
+ isValueWithinReferenceRange(
389
+ conceptRanges,
390
+ config.concepts.systolicBloodPressureUuid,
391
+ systolicBloodPressure,
392
+ ) &&
393
+ isValueWithinReferenceRange(
394
+ conceptRanges,
395
+ config.concepts.diastolicBloodPressureUuid,
396
+ diastolicBloodPressure,
397
+ )
398
+ }
399
+ showErrorMessage={showErrorMessage}
400
+ label={t('bloodPressure', 'Blood pressure')}
401
+ unitSymbol={allConceptUnits.get(config.concepts.systolicBloodPressureUuid) ?? ''}
402
+ />
403
+ </div>
404
+ <div className={styles.inputRow}>
405
+ <VitalsAndBiometricsInput
406
+ control={control}
407
+ fieldProperties={[
408
+ {
409
+ name: t('pulse', 'Pulse'),
410
+ type: 'number',
411
+ min: concepts.pulseRange?.lowAbsolute,
412
+ max: concepts.pulseRange?.hiAbsolute,
413
+ id: 'pulse',
414
+ },
415
+ ]}
416
+ interpretation={
417
+ pulse && assessValue(pulse, getReferenceRangesForConcept(config.concepts.pulseUuid, conceptRanges))
418
+ }
419
+ isValueWithinReferenceRange={
420
+ pulse ? isValueWithinReferenceRange(conceptRanges, config.concepts['pulseUuid'], pulse) : true
421
+ }
422
+ label={t('heartRate', 'Heart rate')}
423
+ showErrorMessage={showErrorMessage}
424
+ unitSymbol={allConceptUnits.get(config.concepts.pulseUuid) ?? ''}
425
+ />
426
+ <VitalsAndBiometricsInput
427
+ control={control}
428
+ fieldProperties={[
429
+ {
430
+ name: t('respirationRate', 'Respiration rate'),
431
+ type: 'number',
432
+ min: concepts.respiratoryRateRange?.lowAbsolute,
433
+ max: concepts.respiratoryRateRange?.hiAbsolute,
434
+ id: 'respiratoryRate',
435
+ },
436
+ ]}
437
+ interpretation={
438
+ respiratoryRate &&
439
+ assessValue(
440
+ respiratoryRate,
441
+ getReferenceRangesForConcept(config.concepts.respiratoryRateUuid, conceptRanges),
442
+ )
443
+ }
444
+ isValueWithinReferenceRange={
445
+ respiratoryRate
446
+ ? isValueWithinReferenceRange(conceptRanges, config.concepts['respiratoryRateUuid'], respiratoryRate)
447
+ : true
448
+ }
449
+ showErrorMessage={showErrorMessage}
450
+ label={t('respirationRate', 'Respiration rate')}
451
+ unitSymbol={allConceptUnits.get(config.concepts.respiratoryRateUuid) ?? ''}
452
+ />
453
+ </div>
454
+ <div className={styles.inputRow}>
455
+ <VitalsAndBiometricsInput
456
+ control={control}
457
+ fieldProperties={[
458
+ {
459
+ name: t('oxygenSaturation', 'Oxygen saturation'),
460
+ type: 'number',
461
+ min: concepts.oxygenSaturationRange?.lowAbsolute,
462
+ max: concepts.oxygenSaturationRange?.hiAbsolute,
463
+ id: 'oxygenSaturation',
464
+ },
465
+ ]}
466
+ interpretation={
467
+ oxygenSaturation &&
468
+ assessValue(
469
+ oxygenSaturation,
470
+ getReferenceRangesForConcept(config.concepts.oxygenSaturationUuid, conceptRanges),
471
+ )
472
+ }
473
+ isValueWithinReferenceRange={
474
+ oxygenSaturation
475
+ ? isValueWithinReferenceRange(conceptRanges, config.concepts['oxygenSaturationUuid'], oxygenSaturation)
476
+ : true
477
+ }
478
+ showErrorMessage={showErrorMessage}
479
+ label={t('spo2', 'SpO2')}
480
+ unitSymbol={allConceptUnits.get(config.concepts.oxygenSaturationUuid) ?? ''}
481
+ />
482
+ <VitalsAndBiometricsInput
483
+ control={control}
484
+ fieldProperties={[
485
+ {
486
+ name: t('muac', 'MUAC'),
487
+ type: 'number',
488
+ min: concepts.midUpperArmCircumferenceRange?.lowAbsolute,
489
+ max: concepts.midUpperArmCircumferenceRange?.hiAbsolute,
490
+ id: 'midUpperArmCircumference',
491
+ },
492
+ ]}
493
+ muacColorCode={muacColorCode}
494
+ isValueWithinReferenceRange={
495
+ midUpperArmCircumference
496
+ ? isValueWithinReferenceRange(
497
+ conceptRanges,
498
+ config.concepts['midUpperArmCircumferenceUuid'],
499
+ midUpperArmCircumference,
500
+ )
501
+ : true
502
+ }
503
+ showErrorMessage={showErrorMessage}
504
+ label={t('muac', 'MUAC')}
505
+ unitSymbol={allConceptUnits.get(config.concepts.midUpperArmCircumferenceUuid) ?? ''}
506
+ useMuacColors={useMuacColorStatus}
507
+ />
508
+ </div>
509
+ <h1 className={styles.sectionTitle}>{t('anthropometric', 'Anthropometric')}</h1>
510
+ <div className={styles.inputRow}>
511
+ <VitalsAndBiometricsInput
512
+ control={control}
513
+ fieldProperties={[
514
+ {
515
+ name: t('weight', 'Weight'),
516
+ type: 'number',
517
+ min: concepts.weightRange?.lowAbsolute,
518
+ max: concepts.weightRange?.hiAbsolute,
519
+ id: 'weight',
520
+ },
521
+ ]}
522
+ interpretation={
523
+ weight && assessValue(weight, getReferenceRangesForConcept(config.concepts.weightUuid, conceptRanges))
524
+ }
525
+ isValueWithinReferenceRange={
526
+ weight ? isValueWithinReferenceRange(conceptRanges, config.concepts['weightUuid'], weight) : true
527
+ }
528
+ showErrorMessage={showErrorMessage}
529
+ label={t('weight', 'Weight')}
530
+ unitSymbol={allConceptUnits.get(config.concepts.weightUuid) ?? ''}
531
+ />
532
+ <VitalsAndBiometricsInput
533
+ control={control}
534
+ fieldProperties={[
535
+ {
536
+ name: t('height', 'Height'),
537
+ type: 'number',
538
+ min: concepts.heightRange?.lowAbsolute,
539
+ max: concepts.heightRange?.hiAbsolute,
540
+ id: 'height',
541
+ },
542
+ ]}
543
+ interpretation={
544
+ height && assessValue(height, getReferenceRangesForConcept(config.concepts.heightUuid, conceptRanges))
545
+ }
546
+ isValueWithinReferenceRange={
547
+ height ? isValueWithinReferenceRange(conceptRanges, config.concepts['heightUuid'], height) : true
548
+ }
549
+ showErrorMessage={showErrorMessage}
550
+ label={t('height', 'Height')}
551
+ unitSymbol={allConceptUnits.get(config.concepts.heightUuid) ?? ''}
552
+ />
553
+ </div>
554
+ <div className={styles.inputRow}>
555
+ <VitalsAndBiometricsInput
556
+ control={control}
557
+ fieldProperties={[
558
+ {
559
+ name: t('bmi', 'BMI'),
560
+ type: 'number',
561
+ id: 'computedBodyMassIndex',
562
+ },
563
+ ]}
564
+ readOnly
565
+ label={t('calculatedBmi', 'BMI (calc.)')}
566
+ unitSymbol={biometricsUnitsSymbols['bmiUnit']}
567
+ />
568
+ </div>
569
+ <h1 className={styles.sectionTitle}>{t('additionalNotes', 'Additional Notes')}</h1>
570
+
571
+ <VitalsAndBiometricsInput
572
+ control={control}
573
+ fieldWidth={isTablet ? '100%' : '100%'}
574
+ fieldProperties={[
575
+ {
576
+ name: t('notes', 'Notes'),
577
+ type: 'textarea',
578
+ id: 'generalPatientNote',
579
+ },
580
+ ]}
581
+ placeholder={t('additionalNoteText', 'Type any additional notes here')}
582
+ label={t('notes', 'Notes')}
583
+ />
584
+ {showErrorNotification && (
585
+ <Column className={styles.errorContainer}>
586
+ <InlineNotification
587
+ lowContrast
588
+ title={t('error', 'Error')}
589
+ subtitle={t('pleaseFillField', 'Please fill at least one field') + '.'}
590
+ onClose={() => setShowErrorNotification(false)}
591
+ />
592
+ </Column>
593
+ )}
594
+ </Stack>
595
+
596
+ <ButtonSet
597
+ className={classNames(styles.buttonSet, {
598
+ [styles.tablet]: isTablet,
599
+ [styles.desktop]: !isTablet,
600
+ })}>
601
+ <Button className={styles.button} kind="secondary" onClick={() => closeWorkspace()}>
602
+ {t('discard', 'Discard')}
603
+ </Button>
604
+ <Button className={styles.button} kind="primary" type="submit" disabled={!isDirty || isSubmitting}>
605
+ <span>{t('recordVitals', 'Record Vitals')}</span>
606
+ </Button>
607
+ </ButtonSet>
608
+ </Form>
609
+ );
610
+ };
611
+
612
+ export default RecordVitalsWorkspace;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+
3
+ export const VitalsAndBiometricsFormSchema = z
4
+ .object({
5
+ systolicBloodPressure: z.number(),
6
+ diastolicBloodPressure: z.number(),
7
+ respiratoryRate: z.number(),
8
+ oxygenSaturation: z.number(),
9
+ pulse: z.number(),
10
+ temperature: z.number(),
11
+ generalPatientNote: z.string(),
12
+ weight: z.number(),
13
+ height: z.number(),
14
+ midUpperArmCircumference: z.number(),
15
+ computedBodyMassIndex: z.number(),
16
+ })
17
+ .partial()
18
+ .refine(
19
+ (fields) => {
20
+ return Object.values(fields).some((value) => Boolean(value));
21
+ },
22
+ {
23
+ message: 'Please fill at least one field',
24
+ path: ['oneFieldRequired'],
25
+ },
26
+ );
27
+
28
+ export type VitalsBiometricsFormData = z.infer<typeof VitalsAndBiometricsFormSchema>;