@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,193 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
4
+ @use '@openmrs/esm-styleguide/src/vars' as *;
5
+
6
+ .activeVisitsContainer {
7
+ background-color: $ui-02;
8
+ border: 1px solid $ui-03;
9
+ width: 100%;
10
+ margin: 1rem auto;
11
+ max-width: 95vw;
12
+ padding-bottom: 0;
13
+
14
+ :global(.omrs-breakpoint-lt-tablet) & {
15
+ margin: 0.5rem auto;
16
+ max-width: 100vw;
17
+ border-left: none;
18
+ border-right: none;
19
+ }
20
+ }
21
+
22
+ .tableContainer {
23
+ width: 100%;
24
+
25
+ :global(.omrs-breakpoint-lt-tablet) & {
26
+ overflow-x: auto;
27
+ }
28
+ }
29
+
30
+ .activeVisitsTable {
31
+ width: 100%;
32
+
33
+ th,
34
+ td {
35
+ white-space: nowrap;
36
+ text-align: left;
37
+ }
38
+
39
+ tbody tr[data-parent-row] {
40
+ &:nth-last-of-type(2) > td {
41
+ border-bottom: none;
42
+ }
43
+ }
44
+
45
+ :global(.omrs-breakpoint-lt-desktop) & {
46
+ th,
47
+ td {
48
+ padding: layout.$spacing-03;
49
+ font-size: 0.875rem;
50
+ }
51
+ }
52
+
53
+ :global(.omrs-breakpoint-lt-tablet) & {
54
+ th,
55
+ td {
56
+ padding: layout.$spacing-02;
57
+ font-size: 0.8125rem;
58
+ }
59
+ }
60
+ }
61
+
62
+ .loadingContainer {
63
+ display: flex;
64
+ justify-content: center;
65
+ align-items: center;
66
+ padding: layout.$spacing-07;
67
+
68
+ :global(.omrs-breakpoint-lt-tablet) & {
69
+ padding: layout.$spacing-05;
70
+ }
71
+ }
72
+
73
+ .emptyStateContainer {
74
+ border: 1px solid colors.$gray-20;
75
+ padding: 3rem 0;
76
+
77
+ :global(.omrs-breakpoint-lt-desktop) & {
78
+ padding: 2rem 0;
79
+ }
80
+
81
+ :global(.omrs-breakpoint-lt-tablet) & {
82
+ padding: 1.5rem 0;
83
+ border-left: none;
84
+ border-right: none;
85
+ }
86
+ }
87
+
88
+ .tile {
89
+ margin: auto;
90
+ width: fit-content;
91
+ background: colors.$gray-20;
92
+
93
+ :global(.omrs-breakpoint-lt-tablet) & {
94
+ width: 90%;
95
+ padding: layout.$spacing-04;
96
+ }
97
+ }
98
+
99
+ .tileContent {
100
+ display: flex;
101
+ flex-direction: column;
102
+ align-items: center;
103
+
104
+ :global(.omrs-breakpoint-lt-tablet) & {
105
+ text-align: center;
106
+ }
107
+ }
108
+
109
+ .content {
110
+ @include type.type-style('heading-compact-02');
111
+ color: colors.$gray-70;
112
+ margin-bottom: layout.$spacing-03;
113
+
114
+ :global(.omrs-breakpoint-lt-tablet) & {
115
+ @include type.type-style('heading-compact-01');
116
+ }
117
+ }
118
+
119
+ .emptyStateHelperText {
120
+ :global(.omrs-breakpoint-lt-tablet) & {
121
+ font-size: 0.875rem;
122
+ padding: 0 layout.$spacing-03;
123
+ }
124
+ }
125
+
126
+ .waitTime {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: layout.$spacing-02;
130
+
131
+ :global(.omrs-breakpoint-lt-tablet) & {
132
+ gap: layout.$spacing-01;
133
+ font-size: 0.75rem;
134
+
135
+ svg {
136
+ width: 14px;
137
+ height: 14px;
138
+ }
139
+ }
140
+ }
141
+
142
+ .longWait {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: layout.$spacing-03;
146
+
147
+ :global(.omrs-breakpoint-lt-tablet) & {
148
+ gap: layout.$spacing-02;
149
+ }
150
+ }
151
+
152
+ .actions {
153
+ display: flex;
154
+ flex-direction: row;
155
+ align-items: center;
156
+ // flex-wrap: wrap;
157
+
158
+ :global(.omrs-breakpoint-lt-desktop) & {
159
+ gap: layout.$spacing-02;
160
+ flex-direction: row;
161
+ }
162
+
163
+ :global(.omrs-breakpoint-lt-tablet) & {
164
+ gap: layout.$spacing-02;
165
+ flex-direction: row;
166
+
167
+ button {
168
+ font-size: 0.75rem;
169
+ padding: layout.$spacing-02 layout.$spacing-03;
170
+ }
171
+ }
172
+ }
173
+
174
+ .pagination {
175
+ background-color: colors.$gray-20;
176
+ border: 1px solid colors.$gray-20;
177
+
178
+ :global(.omrs-breakpoint-lt-desktop) & {
179
+ font-size: 0.875rem;
180
+ }
181
+
182
+ :global(.omrs-breakpoint-lt-tablet) & {
183
+ flex-wrap: wrap;
184
+ padding: layout.$spacing-03;
185
+
186
+ :global(.cds--pagination__left),
187
+ :global(.cds--pagination__right) {
188
+ width: 100%;
189
+ justify-content: center;
190
+ margin: layout.$spacing-02 0;
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,30 @@
1
+ export interface QueueEntry {
2
+ uuid: string;
3
+ display: string;
4
+ patient: {
5
+ uuid: string;
6
+ display: string;
7
+ person: {
8
+ display: string;
9
+ age: number;
10
+ gender: string;
11
+ };
12
+ };
13
+ priority: {
14
+ uuid: string;
15
+ display: string;
16
+ };
17
+ status: {
18
+ uuid: string;
19
+ display: string;
20
+ };
21
+ visit: {
22
+ uuid: string;
23
+ };
24
+ queueRoom: {
25
+ uuid: string;
26
+ display: string;
27
+ };
28
+ startedAt: string;
29
+ sortWeight: number;
30
+ }
@@ -0,0 +1,24 @@
1
+ import { useConfig } from '@openmrs/esm-framework';
2
+ import { updateQueueEntryStatus } from './queue-room-table.resource';
3
+
4
+ export const useQueueActions = () => {
5
+ const config = useConfig();
6
+
7
+ const servePatient = async (queueEntryUuid: string) => {
8
+ return updateQueueEntryStatus(
9
+ queueEntryUuid,
10
+ config.concepts.inServiceStatusUuid,
11
+ config.concepts.completedStatusUuid,
12
+ );
13
+ };
14
+
15
+ const completeService = async (queueEntryUuid: string) => {
16
+ return updateQueueEntryStatus(
17
+ queueEntryUuid,
18
+ config.concepts.completedStatusUuid,
19
+ config.concepts.completedStatusUuid,
20
+ );
21
+ };
22
+
23
+ return { servePatient, completeService };
24
+ };
@@ -0,0 +1,19 @@
1
+ export {
2
+ createOrUpdateVitalsAndBiometrics,
3
+ deleteEncounter,
4
+ invalidateCachedVitalsAndBiometrics,
5
+ useConceptUnits,
6
+ useEncounterVitalsAndBiometrics,
7
+ useVitalsAndBiometrics,
8
+ useVitalsConceptMetadata,
9
+ withUnit,
10
+ type ConceptMetadata,
11
+ } from '../record-vitals.resource';
12
+ export {
13
+ assessValue,
14
+ calculateBodyMassIndex,
15
+ generatePlaceholder,
16
+ getReferenceRangesForConcept,
17
+ interpretBloodPressure,
18
+ } from '../helper';
19
+ export type { ObservationInterpretation, PatientVitalsAndBiometrics } from '../type';
@@ -0,0 +1,433 @@
1
+ import React, { Fragment, useId, useState } from 'react';
2
+ import classNames from 'classnames';
3
+ import { type Control, Controller } from 'react-hook-form';
4
+ import { FormLabel, NumberInput, TextArea } from '@carbon/react';
5
+ import { Warning, WarningAlt, ErrorFilled, Misuse, CheckmarkFilled } from '@carbon/react/icons';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { useLayoutType, ResponsiveWrapper } from '@openmrs/esm-framework';
8
+ import { generatePlaceholder } from '../common';
9
+ import styles from './vitals-biometrics-input.scss';
10
+ import { VitalsBiometricsFormData } from '../schema';
11
+
12
+ type fieldId =
13
+ | 'computedBodyMassIndex'
14
+ | 'diastolicBloodPressure'
15
+ | 'generalPatientNote'
16
+ | 'height'
17
+ | 'midUpperArmCircumference'
18
+ | 'oxygenSaturation'
19
+ | 'pulse'
20
+ | 'respiratoryRate'
21
+ | 'systolicBloodPressure'
22
+ | 'temperature'
23
+ | 'weight';
24
+
25
+ type AbnormalValue = 'critically_low' | 'critically_high' | 'high' | 'low';
26
+ type FieldTypes = 'number' | 'textarea';
27
+
28
+ interface ReferenceRange {
29
+ absoluteHigh?: number;
30
+ criticalHigh?: number;
31
+ normalHigh?: number;
32
+ normalLow?: number;
33
+ criticalLow?: number;
34
+ absoluteLow?: number;
35
+ }
36
+
37
+ interface ValueCategory {
38
+ level: 'absoluteHigh' | 'criticalHigh' | 'normalHigh' | 'normalLow' | 'criticalLow' | 'absoluteLow' | 'normal';
39
+ severity: 'critical' | 'warning' | 'info' | 'normal';
40
+ message: string;
41
+ icon: React.ComponentType<any>;
42
+ color: string;
43
+ }
44
+
45
+ interface VitalsAndBiometricsInputProps {
46
+ control: Control<VitalsBiometricsFormData>;
47
+ fieldStyles?: React.CSSProperties;
48
+ fieldWidth?: string;
49
+ fieldProperties: Array<{
50
+ className?: string;
51
+ id: fieldId;
52
+ invalid?: boolean;
53
+ max?: number | null;
54
+ min?: number | null;
55
+ name: string;
56
+ separator?: string;
57
+ type?: FieldTypes;
58
+ }>;
59
+ interpretation?: string;
60
+ isValueWithinReferenceRange?: boolean;
61
+ label: string;
62
+ muacColorCode?: string;
63
+ placeholder?: string;
64
+ readOnly?: boolean;
65
+ referenceRange?: ReferenceRange;
66
+ showErrorMessage?: boolean;
67
+ unitSymbol?: string;
68
+ useMuacColors?: boolean;
69
+ }
70
+
71
+ const VitalsAndBiometricsInput: React.FC<VitalsAndBiometricsInputProps> = ({
72
+ control,
73
+ fieldProperties,
74
+ fieldStyles,
75
+ fieldWidth,
76
+ interpretation,
77
+ isValueWithinReferenceRange = true,
78
+ label,
79
+ muacColorCode,
80
+ placeholder,
81
+ readOnly,
82
+ referenceRange,
83
+ showErrorMessage,
84
+ unitSymbol,
85
+ useMuacColors,
86
+ }) => {
87
+ const { t } = useTranslation();
88
+ const fieldId = useId();
89
+ const isTablet = useLayoutType() === 'tablet';
90
+ const [invalid, setInvalid] = useState(false);
91
+ const [isFocused, setIsFocused] = useState(false);
92
+ const [currentValue, setCurrentValue] = useState<number | undefined>();
93
+ const [valueCategory, setValueCategory] = useState<ValueCategory | null>(null);
94
+
95
+ const abnormalValues: Array<AbnormalValue> = ['critically_low', 'critically_high', 'high', 'low'];
96
+ const hasAbnormalValue = !isFocused && interpretation && abnormalValues.includes(interpretation as AbnormalValue);
97
+
98
+ function categorizeValue(value: number): ValueCategory | null {
99
+ if (!referenceRange) {
100
+ return null;
101
+ }
102
+
103
+ const { absoluteHigh, criticalHigh, normalHigh, normalLow, criticalLow, absoluteLow } = referenceRange;
104
+
105
+ // Check absolute high
106
+ if (absoluteHigh !== undefined && value > absoluteHigh) {
107
+ return {
108
+ level: 'absoluteHigh',
109
+ severity: 'critical',
110
+ message: t('absoluteHighError', 'Absolute High: Value {{value}} exceeds maximum safe limit of {{max}}', {
111
+ value,
112
+ max: absoluteHigh,
113
+ }),
114
+ icon: Misuse,
115
+ color: '#da1e28',
116
+ };
117
+ }
118
+
119
+ // Check critical high
120
+ if (criticalHigh !== undefined && value > criticalHigh) {
121
+ return {
122
+ level: 'criticalHigh',
123
+ severity: 'critical',
124
+ message: t(
125
+ 'criticalHighError',
126
+ 'Critical High: Value {{value}} is dangerously high (safe range: {{normalLow}}-{{normalHigh}})',
127
+ { value, normalLow, normalHigh },
128
+ ),
129
+ icon: ErrorFilled,
130
+ color: '#ff832b',
131
+ };
132
+ }
133
+
134
+ // Check normal high
135
+ if (normalHigh !== undefined && value > normalHigh) {
136
+ return {
137
+ level: 'normalHigh',
138
+ severity: 'warning',
139
+ message: t('normalHighWarning', 'Above Normal: {{value}} exceeds normal range ({{normalLow}}-{{normalHigh}})', {
140
+ value,
141
+ normalLow,
142
+ normalHigh,
143
+ }),
144
+ icon: WarningAlt,
145
+ color: '#f1c21b',
146
+ };
147
+ }
148
+
149
+ // Check if below normal low
150
+ if (normalLow !== undefined && value < normalLow) {
151
+ // Check absolute low
152
+ if (absoluteLow !== undefined && value < absoluteLow) {
153
+ return {
154
+ level: 'absoluteLow',
155
+ severity: 'critical',
156
+ message: t('absoluteLowError', 'Absolute Low: Value {{value}} is below minimum safe limit of {{min}}', {
157
+ value,
158
+ min: absoluteLow,
159
+ }),
160
+ icon: Misuse,
161
+ color: '#da1e28',
162
+ };
163
+ }
164
+
165
+ // Check critical low
166
+ if (criticalLow !== undefined && value < criticalLow) {
167
+ return {
168
+ level: 'criticalLow',
169
+ severity: 'critical',
170
+ message: t(
171
+ 'criticalLowError',
172
+ 'Critical Low: Value {{value}} is dangerously low (safe range: {{normalLow}}-{{normalHigh}})',
173
+ { value, normalLow, normalHigh },
174
+ ),
175
+ icon: ErrorFilled,
176
+ color: '#ff832b',
177
+ };
178
+ }
179
+
180
+ // Normal low
181
+ return {
182
+ level: 'normalLow',
183
+ severity: 'warning',
184
+ message: t('normalLowWarning', 'Below Normal: {{value}} is under normal range ({{normalLow}}-{{normalHigh}})', {
185
+ value,
186
+ normalLow,
187
+ normalHigh,
188
+ }),
189
+ icon: WarningAlt,
190
+ color: '#f1c21b',
191
+ };
192
+ }
193
+
194
+ // Value is normal
195
+ return {
196
+ level: 'normal',
197
+ severity: 'normal',
198
+ message: t('normalValue', 'Value is within normal range'),
199
+ icon: CheckmarkFilled,
200
+ color: '#24a148',
201
+ };
202
+ }
203
+
204
+ function checkValidity(value, onChange) {
205
+ const numValue = Number(value);
206
+ setInvalid(!(numValue || value === ''));
207
+
208
+ if (!invalid) {
209
+ const finalValue = value === '' ? undefined : numValue;
210
+ onChange(finalValue);
211
+ setCurrentValue(finalValue);
212
+
213
+ if (finalValue !== undefined && referenceRange) {
214
+ const category = categorizeValue(finalValue);
215
+ setValueCategory(category);
216
+ } else {
217
+ setValueCategory(null);
218
+ }
219
+ }
220
+ }
221
+
222
+ function handleFocusChange(isFocused: boolean) {
223
+ setIsFocused(isFocused);
224
+ }
225
+
226
+ function calculateMarkerPosition(value: number | undefined, range: ReferenceRange): string {
227
+ if (!value) {
228
+ return '50%';
229
+ }
230
+
231
+ const min = range.absoluteLow ?? range.criticalLow ?? range.normalLow ?? 0;
232
+ const max = range.absoluteHigh ?? range.criticalHigh ?? range.normalHigh ?? 100;
233
+
234
+ const clampedValue = Math.max(min, Math.min(max, value));
235
+ const percentage = ((clampedValue - min) / (max - min)) * 100;
236
+
237
+ return `${Math.max(0, Math.min(100, percentage))}%`;
238
+ }
239
+
240
+ const ValueStatusBadge = () => {
241
+ if (!showInvalidInputError || !valueCategory) {
242
+ return null;
243
+ }
244
+
245
+ const IconComponent = valueCategory.icon;
246
+
247
+ return (
248
+ <div
249
+ className={styles.valueStatusContainer}
250
+ style={{
251
+ borderLeftColor: valueCategory.color,
252
+ }}>
253
+ <div className={styles.statusHeader}>
254
+ <IconComponent
255
+ style={{
256
+ color: valueCategory.color,
257
+ minWidth: '20px',
258
+ minHeight: '20px',
259
+ width: '20px',
260
+ height: '20px',
261
+ }}
262
+ aria-label={`${valueCategory.severity} status icon`}
263
+ />
264
+ <span className={styles.statusLevel} style={{ color: valueCategory.color }}>
265
+ {valueCategory.level
266
+ .replace(/([A-Z])/g, ' $1')
267
+ .replace(/^./, (str) => str.toUpperCase())
268
+ .trim()}
269
+ </span>
270
+ </div>
271
+ <p className={styles.statusMessage}>{valueCategory.message}</p>
272
+ {referenceRange && (
273
+ <div className={styles.rangeIndicator}>
274
+ <div className={styles.rangeBar}>
275
+ <span className={styles.rangeSegment} data-level="critical-low" title="Critical Low" />
276
+ <span className={styles.rangeSegment} data-level="low" title="Below Normal" />
277
+ <span className={styles.rangeSegment} data-level="normal" title="Normal Range" />
278
+ <span className={styles.rangeSegment} data-level="high" title="Above Normal" />
279
+ <span className={styles.rangeSegment} data-level="critical-high" title="Critical High" />
280
+ <span
281
+ className={styles.currentValueMarker}
282
+ style={{
283
+ left: calculateMarkerPosition(currentValue, referenceRange),
284
+ backgroundColor: valueCategory.color,
285
+ }}
286
+ title={`Current: ${currentValue}`}
287
+ />
288
+ </div>
289
+ <div className={styles.rangeLabels}>
290
+ <span className={styles.rangeLabel}>
291
+ {referenceRange.absoluteLow ?? referenceRange.criticalLow ?? referenceRange.normalLow}
292
+ </span>
293
+ <span className={styles.rangeLabel}>
294
+ {referenceRange.normalLow} - {referenceRange.normalHigh}
295
+ </span>
296
+ <span className={styles.rangeLabel}>
297
+ {referenceRange.absoluteHigh ?? referenceRange.criticalHigh ?? referenceRange.normalHigh}
298
+ </span>
299
+ </div>
300
+ </div>
301
+ )}
302
+ </div>
303
+ );
304
+ };
305
+
306
+ const isInvalidInput = !isValueWithinReferenceRange || invalid || (valueCategory && valueCategory.level !== 'normal');
307
+ const showInvalidInputError = Boolean(showErrorMessage && isInvalidInput && !isFocused);
308
+ const errorMessageClass = showInvalidInputError ? styles.invalidInput : '';
309
+
310
+ const containerClasses = classNames(styles.container, {
311
+ [styles.inputInTabletView]: isTablet,
312
+ [styles.inputWithAbnormalValue]: hasAbnormalValue,
313
+ });
314
+
315
+ const inputClasses = classNames(styles.inputContainer, {
316
+ [styles['critical-value']]: hasAbnormalValue,
317
+ [styles.focused]: isFocused,
318
+ [styles.readonly]: readOnly,
319
+ [muacColorCode]: useMuacColors,
320
+ [errorMessageClass]: true,
321
+ });
322
+
323
+ return (
324
+ <>
325
+ <div className={containerClasses} style={{ width: fieldWidth }}>
326
+ <section className={styles.labelContainer}>
327
+ <span className={styles.label} id={`${fieldId}-label`}>
328
+ {label}
329
+ </span>
330
+
331
+ {Boolean(hasAbnormalValue) ? (
332
+ <span
333
+ className={styles[interpretation.replace('_', '-')]}
334
+ title={t('abnormalValue', 'Abnormal value')}
335
+ data-interpretation={interpretation}>
336
+ &nbsp;
337
+ </span>
338
+ ) : null}
339
+
340
+ {showInvalidInputError ? (
341
+ <span className={styles.invalidInputIcon}>
342
+ <Warning />
343
+ </span>
344
+ ) : null}
345
+ </section>
346
+ <section className={inputClasses} style={{ ...fieldStyles }}>
347
+ <div
348
+ className={classNames({
349
+ [styles.centered]: !isTablet || unitSymbol === 'mmHg',
350
+ })}>
351
+ {fieldProperties.map((fieldProperty) => {
352
+ if (fieldProperty.type === 'number') {
353
+ const numberInputClasses = classNames(styles.numberInput, fieldProperty.className);
354
+
355
+ return (
356
+ <Fragment key={fieldProperty.id}>
357
+ <ResponsiveWrapper>
358
+ <Controller
359
+ name={fieldProperty.id}
360
+ control={control}
361
+ render={({ field: { onChange, ref, value } }) => {
362
+ return (
363
+ <NumberInput
364
+ allowEmpty
365
+ className={numberInputClasses}
366
+ defaultValue={''}
367
+ disableWheel
368
+ hideSteppers
369
+ id={`${fieldId}-${fieldProperty.id}`}
370
+ max={fieldProperty.max ?? undefined}
371
+ min={fieldProperty.min ?? undefined}
372
+ name={fieldProperty.name}
373
+ onBlur={() => handleFocusChange(false)}
374
+ onChange={(event, { value }) => checkValidity(value, onChange)}
375
+ onFocus={() => handleFocusChange(true)}
376
+ placeholder={generatePlaceholder(fieldProperty.name)}
377
+ readOnly={readOnly}
378
+ ref={ref}
379
+ style={{ ...fieldStyles }}
380
+ title={fieldProperty.name}
381
+ type="number"
382
+ value={value}
383
+ />
384
+ );
385
+ }}
386
+ />
387
+ </ResponsiveWrapper>
388
+ {fieldProperty?.separator}
389
+ </Fragment>
390
+ );
391
+ }
392
+
393
+ if (fieldProperty.type === 'textarea') {
394
+ return (
395
+ <ResponsiveWrapper key={fieldProperty.id}>
396
+ <Controller
397
+ name={fieldProperty.id}
398
+ control={control}
399
+ render={({ field: { onChange, ref, value } }) => (
400
+ <TextArea
401
+ className={styles.textarea}
402
+ id={`${fieldId}-${fieldProperty.id}`}
403
+ labelText={''}
404
+ aria-labelledby={`${fieldId}-label`}
405
+ maxCount={100}
406
+ name={fieldProperty.name}
407
+ onBlur={() => handleFocusChange(false)}
408
+ onChange={onChange}
409
+ onFocus={() => handleFocusChange(true)}
410
+ placeholder={placeholder}
411
+ ref={ref}
412
+ rows={2}
413
+ style={{ ...fieldStyles }}
414
+ title={fieldProperty.name}
415
+ value={value}
416
+ />
417
+ )}
418
+ />
419
+ </ResponsiveWrapper>
420
+ );
421
+ }
422
+ })}
423
+ </div>
424
+ {Boolean(unitSymbol) && <p className={styles.unitName}>{unitSymbol}</p>}
425
+ </section>
426
+ </div>
427
+
428
+ <ValueStatusBadge />
429
+ </>
430
+ );
431
+ };
432
+
433
+ export default VitalsAndBiometricsInput;