@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,72 @@
1
+ import { useMemo } from 'react';
2
+ import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
3
+ import useSWR from 'swr';
4
+ import type { Note, EncounterResponse } from './type';
5
+
6
+ export function useTriageNotes(patientUuid: string, conceptUuid: string, encounterTypeUuid?: string) {
7
+ const customRepresentation =
8
+ 'custom:(uuid,encounterDatetime,encounterType:(uuid,display),location:(uuid,display),' +
9
+ 'encounterProviders:(uuid,provider:(uuid,person:(display)),encounterRole:(uuid,display)),' +
10
+ 'obs:(uuid,concept:(uuid,display),value,obsDatetime))';
11
+
12
+ let url = patientUuid ? `${restBaseUrl}/encounter?patient=${patientUuid}&v=${customRepresentation}&limit=100` : null;
13
+
14
+ // Optionally filter by encounter type if provided
15
+ if (url && encounterTypeUuid) {
16
+ url += `&encounterType=${encounterTypeUuid}`;
17
+ }
18
+
19
+ const { data, error, isLoading, isValidating, mutate } = useSWR<FetchResponse<EncounterResponse>, Error>(
20
+ url,
21
+ openmrsFetch,
22
+ );
23
+
24
+ const notes: Array<Note> = useMemo(() => {
25
+ if (!data?.data?.results) {
26
+ return [];
27
+ }
28
+
29
+ // Extract notes from encounters that have the general patient note observation
30
+ const extractedNotes: Array<Note> = [];
31
+
32
+ data.data.results.forEach((encounter) => {
33
+ // Find the note observation in this encounter
34
+ const noteObs = encounter.obs.find((obs) => obs.concept.uuid === conceptUuid);
35
+
36
+ if (noteObs && noteObs.value && noteObs.value.trim() !== '') {
37
+ const provider = encounter.encounterProviders?.[0];
38
+
39
+ extractedNotes.push({
40
+ concept: noteObs.concept,
41
+ note: noteObs.value,
42
+ time: new Date(encounter.encounterDatetime).toLocaleString('en-US', {
43
+ year: 'numeric',
44
+ month: 'short',
45
+ day: 'numeric',
46
+ hour: '2-digit',
47
+ minute: '2-digit',
48
+ }),
49
+ provider: {
50
+ name: provider?.provider?.person?.display || '',
51
+ role: provider?.encounterRole?.display || '',
52
+ },
53
+ });
54
+ }
55
+ });
56
+
57
+ // Sort by encounter datetime (most recent first)
58
+ return extractedNotes.sort((a, b) => {
59
+ const dateA = new Date(a.time);
60
+ const dateB = new Date(b.time);
61
+ return dateB.getTime() - dateA.getTime();
62
+ });
63
+ }, [data, conceptUuid]);
64
+
65
+ return {
66
+ notes,
67
+ error,
68
+ isLoading,
69
+ isValidating,
70
+ mutate,
71
+ };
72
+ }
@@ -0,0 +1,50 @@
1
+ import { type OpenmrsResource } from '@openmrs/esm-framework';
2
+
3
+ export interface Note {
4
+ concept: OpenmrsResource;
5
+ note: string;
6
+ provider: {
7
+ name: string;
8
+ role: string;
9
+ };
10
+ time: string;
11
+ }
12
+
13
+ interface Encounter {
14
+ uuid: string;
15
+ encounterDatetime: string;
16
+ encounterType: {
17
+ uuid: string;
18
+ display: string;
19
+ };
20
+ location: {
21
+ uuid: string;
22
+ display: string;
23
+ };
24
+ encounterProviders: Array<{
25
+ uuid: string;
26
+ provider: {
27
+ uuid: string;
28
+ person: {
29
+ display: string;
30
+ };
31
+ };
32
+ encounterRole: {
33
+ uuid: string;
34
+ display: string;
35
+ };
36
+ }>;
37
+ obs: Array<{
38
+ uuid: string;
39
+ concept: {
40
+ uuid: string;
41
+ display: string;
42
+ };
43
+ value: string;
44
+ obsDatetime: string;
45
+ }>;
46
+ }
47
+
48
+ export interface EncounterResponse {
49
+ results: Array<Encounter>;
50
+ }
@@ -0,0 +1,229 @@
1
+ import React, { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useReactToPrint } from 'react-to-print';
4
+ import { Button, ContentSwitcher, DataTableSkeleton, IconSwitch, InlineLoading } from '@carbon/react';
5
+ import { Analytics, Table } from '@carbon/react/icons';
6
+ import { CardHeader, EmptyState, ErrorState } from '@openmrs/esm-patient-common-lib';
7
+ import {
8
+ AddIcon,
9
+ PrinterIcon,
10
+ age,
11
+ getPatientName,
12
+ formatDate,
13
+ parseDate,
14
+ useConfig,
15
+ useLayoutType,
16
+ } from '@openmrs/esm-framework';
17
+ import type { ConfigObject } from '../../config-schema';
18
+ import { useVitalsAndBiometrics, useConceptUnits, withUnit } from '../../record-vitals-workspace/common';
19
+ import styles from './triage-vitals-history.scss';
20
+ import { VitalsTableHeader, VitalsTableRow } from './type';
21
+ import PaginatedVitals from '../paginated-vitals/paginated-vitals.component';
22
+ import VitalsChart from './vital-chart.component';
23
+
24
+ interface VitalsOverviewProps {
25
+ patientUuid: string;
26
+ patient: fhir.Patient;
27
+ pageSize: number;
28
+ urlLabel: string;
29
+ }
30
+
31
+ const VitalsOverview: React.FC<VitalsOverviewProps> = ({ patientUuid, patient, pageSize, urlLabel }) => {
32
+ const { t } = useTranslation();
33
+ const config = useConfig<ConfigObject>();
34
+ const displayText = t('vitalSigns', 'vital signs');
35
+ const headerTitle = t('vitalSigns', 'Vital Signs');
36
+ const [chartView, setChartView] = useState(false);
37
+ const isTablet = useLayoutType() === 'tablet';
38
+ const [isPrinting, setIsPrinting] = useState(false);
39
+ const contentToPrintRef = useRef(null);
40
+
41
+ const { excludePatientIdentifierCodeTypes } = useConfig();
42
+ const { data: vitals, error, isLoading, isValidating } = useVitalsAndBiometrics(patientUuid);
43
+ const { conceptUnits } = useConceptUnits();
44
+
45
+ const patientDetails = useMemo(() => {
46
+ const getGender = (gender: string): string => {
47
+ switch (gender) {
48
+ case 'male':
49
+ return t('male', 'Male');
50
+ case 'female':
51
+ return t('female', 'Female');
52
+ case 'other':
53
+ return t('other', 'Other');
54
+ case 'unknown':
55
+ return t('unknown', 'Unknown');
56
+ default:
57
+ return gender;
58
+ }
59
+ };
60
+
61
+ const identifiers =
62
+ patient?.identifier?.filter(
63
+ (identifier) => !excludePatientIdentifierCodeTypes?.uuids.includes(identifier.type.coding[0].code),
64
+ ) ?? [];
65
+
66
+ return {
67
+ name: patient ? getPatientName(patient) : '',
68
+ age: age(patient?.birthDate),
69
+ gender: getGender(patient?.gender),
70
+ location: patient?.address?.[0].city,
71
+ identifiers: identifiers?.length ? identifiers.map(({ value }) => value) : [],
72
+ };
73
+ }, [patient, t, excludePatientIdentifierCodeTypes?.uuids]);
74
+
75
+ const tableHeaders: Array<VitalsTableHeader> = [
76
+ {
77
+ key: 'dateRender',
78
+ header: t('dateAndTime', 'Date and time'),
79
+ isSortable: true,
80
+ sortFunc: (valueA, valueB) => new Date(valueA.date).getTime() - new Date(valueB.date).getTime(),
81
+ },
82
+ {
83
+ key: 'temperatureRender',
84
+ header: withUnit(t('temperatureAbbreviated', 'Temp'), conceptUnits.get(config.concepts.temperatureUuid) ?? ''),
85
+ isSortable: true,
86
+ sortFunc: (valueA, valueB) =>
87
+ valueA.temperature && valueB.temperature ? valueA.temperature - valueB.temperature : 0,
88
+ },
89
+ {
90
+ key: 'bloodPressureRender',
91
+ header: withUnit(
92
+ t('bloodPressureAbbreviated', 'BP'),
93
+ conceptUnits.get(config.concepts.systolicBloodPressureUuid) ?? '',
94
+ ),
95
+ isSortable: true,
96
+ sortFunc: (valueA, valueB) =>
97
+ valueA.systolic && valueB.systolic && valueA.diastolic && valueB.diastolic
98
+ ? valueA.systolic !== valueB.systolic
99
+ ? valueA.systolic - valueB.systolic
100
+ : valueA.diastolic - valueB.diastolic
101
+ : 0,
102
+ },
103
+ {
104
+ key: 'pulseRender',
105
+ header: withUnit(t('pulse', 'Pulse'), conceptUnits.get(config.concepts.pulseUuid) ?? ''),
106
+ isSortable: true,
107
+ sortFunc: (valueA, valueB) => (valueA.pulse && valueB.pulse ? valueA.pulse - valueB.pulse : 0),
108
+ },
109
+ {
110
+ key: 'respiratoryRateRender',
111
+ header: withUnit(
112
+ t('respiratoryRateAbbreviated', 'R. Rate'),
113
+ conceptUnits.get(config.concepts.respiratoryRateUuid) ?? '',
114
+ ),
115
+ isSortable: true,
116
+ sortFunc: (valueA, valueB) =>
117
+ valueA.respiratoryRate && valueB.respiratoryRate ? valueA.respiratoryRate - valueB.respiratoryRate : 0,
118
+ },
119
+ {
120
+ key: 'spo2Render',
121
+ header: withUnit(t('spo2', 'SpO2'), conceptUnits.get(config.concepts.oxygenSaturationUuid) ?? ''),
122
+ isSortable: true,
123
+ sortFunc: (valueA, valueB) => (valueA.spo2 && valueB.spo2 ? valueA.spo2 - valueB.spo2 : 0),
124
+ },
125
+ ];
126
+
127
+ const tableRows: Array<VitalsTableRow> = useMemo(
128
+ () =>
129
+ vitals?.map((vitalSigns) => {
130
+ return {
131
+ ...vitalSigns,
132
+ dateRender: formatDate(parseDate(vitalSigns.date.toString()), { mode: 'wide', time: true }),
133
+ bloodPressureRender: `${vitalSigns.systolic ?? '--'} / ${vitalSigns.diastolic ?? '--'}`,
134
+ bloodPressureRenderInterpretation: vitalSigns.bloodPressureRenderInterpretation,
135
+ pulseRender: vitalSigns.pulse ?? '--',
136
+ pulseRenderInterpretation: vitalSigns.pulseRenderInterpretation,
137
+ spo2Render: vitalSigns.spo2 ?? '--',
138
+ spo2RenderInterpretation: vitalSigns.spo2RenderInterpretation,
139
+ temperatureRender: vitalSigns.temperature ?? '--',
140
+ temperatureRenderInterpretation: vitalSigns.temperatureRenderInterpretation,
141
+ respiratoryRateRender: vitalSigns.respiratoryRate ?? '--',
142
+ respiratoryRateRenderInterpretation: vitalSigns.respiratoryRateRenderInterpretation,
143
+ };
144
+ }),
145
+ [vitals],
146
+ );
147
+
148
+ const onBeforeGetContentResolve = useRef(null);
149
+
150
+ useEffect(() => {
151
+ if (isPrinting && onBeforeGetContentResolve.current) {
152
+ onBeforeGetContentResolve.current();
153
+ }
154
+ }, [isPrinting]);
155
+
156
+ const handlePrint = useReactToPrint({
157
+ content: () => contentToPrintRef.current,
158
+ documentTitle: `OpenMRS - ${patientDetails.name} - ${headerTitle}`,
159
+ onBeforeGetContent: () =>
160
+ new Promise((resolve) => {
161
+ if (patient && headerTitle) {
162
+ onBeforeGetContentResolve.current = resolve;
163
+ setIsPrinting(true);
164
+ }
165
+ }),
166
+ onAfterPrint: () => {
167
+ onBeforeGetContentResolve.current = null;
168
+ setIsPrinting(false);
169
+ },
170
+ });
171
+
172
+ return (
173
+ <>
174
+ {(() => {
175
+ if (isLoading) {
176
+ return <DataTableSkeleton role="progressbar" compact={!isTablet} zebra />;
177
+ }
178
+
179
+ if (error) {
180
+ return <ErrorState error={error} headerTitle={headerTitle} />;
181
+ }
182
+
183
+ if (vitals?.length) {
184
+ return (
185
+ <div className={styles.widgetCard}>
186
+ <CardHeader title={headerTitle}>
187
+ <div className={styles.backgroundDataFetchingIndicator}>
188
+ <span>{isValidating ? <InlineLoading /> : null}</span>
189
+ </div>
190
+ <div className={styles.vitalsHeaderActionItems}>
191
+ <ContentSwitcher
192
+ onChange={(evt: ChangeEvent<HTMLButtonElement> & { name: string }) =>
193
+ setChartView(evt.name === 'chartView')
194
+ }
195
+ size={isTablet ? 'md' : 'sm'}
196
+ selectedIndex={chartView ? 1 : 0}>
197
+ <IconSwitch name="tableView" text="Table view">
198
+ <Table size={16} />
199
+ </IconSwitch>
200
+ <IconSwitch name="chartView" text="Chart view">
201
+ <Analytics size={16} />
202
+ </IconSwitch>
203
+ </ContentSwitcher>
204
+ </div>
205
+ </CardHeader>
206
+ {chartView ? (
207
+ <VitalsChart patientVitals={vitals} conceptUnits={conceptUnits} config={config} />
208
+ ) : (
209
+ <div ref={contentToPrintRef}>
210
+ <PaginatedVitals
211
+ isPrinting={isPrinting}
212
+ pageSize={pageSize}
213
+ tableHeaders={tableHeaders}
214
+ tableRows={tableRows}
215
+ urlLabel={urlLabel}
216
+ patient={patient}
217
+ />
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ }
223
+ return <EmptyState displayText={displayText} headerTitle={headerTitle} />;
224
+ })()}
225
+ </>
226
+ );
227
+ };
228
+
229
+ export default VitalsOverview;
@@ -0,0 +1,48 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .widgetCard {
6
+ background-color: colors.$white;
7
+ border: 1px solid colors.$gray-20;
8
+ }
9
+ :global(.cds--tab-content) {
10
+ padding: 0;
11
+ }
12
+ .divider {
13
+ width: 1px;
14
+ height: layout.$spacing-05;
15
+ color: $ui-05;
16
+ margin: 0 layout.$spacing-05;
17
+ }
18
+
19
+ .vitalsHeaderActionItems {
20
+ align-items: center;
21
+ display: flex;
22
+ flex: 1 1 0%;
23
+ justify-content: end;
24
+ padding-right: layout.$spacing-05;
25
+
26
+ & :global(.cds--content-switcher) {
27
+ max-width: max-content;
28
+
29
+ :global(.cds--content-switcher__label) {
30
+ height: layout.$spacing-05;
31
+ }
32
+ }
33
+ }
34
+
35
+ .customRow tbody {
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .backgroundDataFetchingIndicator {
40
+ align-items: center;
41
+ display: flex;
42
+ flex: 1 1 0%;
43
+ justify-content: end;
44
+ }
45
+
46
+ .printButton {
47
+ margin-left: 1.25rem;
48
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ type ObservationInterpretation,
3
+ type PatientVitalsAndBiometrics,
4
+ } from '../../../record-vitals-workspace/common';
5
+
6
+ export interface VitalsTableRow extends PatientVitalsAndBiometrics {
7
+ id: string;
8
+ dateRender: string;
9
+ bloodPressureRender: string;
10
+ bloodPressureRenderInterpretation?: ObservationInterpretation;
11
+ pulseRender: string | number;
12
+ pulseRenderInterpretation?: ObservationInterpretation;
13
+ spo2Render: string | number;
14
+ spo2RenderInterpretation?: ObservationInterpretation;
15
+ temperatureRender: string | number;
16
+ temperatureRenderInterpretation?: ObservationInterpretation;
17
+ respiratoryRateRender: string | number;
18
+ respiratoryRateRenderInterpretation?: ObservationInterpretation;
19
+ }
20
+
21
+ export interface VitalsTableHeader {
22
+ key:
23
+ | 'dateRender'
24
+ | 'temperatureRender'
25
+ | 'bloodPressureRender'
26
+ | 'pulseRender'
27
+ | 'respiratoryRateRender'
28
+ | 'spo2Render';
29
+ header: string;
30
+ isSortable?: boolean;
31
+ sortFunc: (valueA: VitalsTableRow, valueB: VitalsTableRow) => number;
32
+ }
@@ -0,0 +1,198 @@
1
+ import React, { useId, useMemo, useState } from 'react';
2
+ import classNames from 'classnames';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Tab, TabListVertical, TabPanel, TabPanels, TabsVertical } from '@carbon/react';
5
+ import { LineChart, ScaleTypes } from '@carbon/charts-react';
6
+ import { formatDate, parseDate } from '@openmrs/esm-framework';
7
+ import { type ConfigObject } from '../../config-schema';
8
+ import { withUnit, type PatientVitalsAndBiometrics } from '../../record-vitals-workspace/common';
9
+ import styles from './vitals-chart.scss';
10
+
11
+ interface VitalsChartProps {
12
+ conceptUnits: Map<string, string>;
13
+ config: ConfigObject;
14
+ patientVitals: Array<PatientVitalsAndBiometrics>;
15
+ }
16
+
17
+ type VitalSignKey = 'systolic' | 'spo2' | 'temperature' | 'respiratoryRate' | 'pulse';
18
+
19
+ interface VitalsChartData {
20
+ title: string;
21
+ value: VitalSignKey;
22
+ }
23
+
24
+ const VitalsChart: React.FC<VitalsChartProps> = ({ patientVitals, conceptUnits, config }) => {
25
+ const { t } = useTranslation();
26
+ const id = useId();
27
+ const [selectedVitalsSign, setSelectedVitalsSign] = useState<VitalsChartData>({
28
+ title: `${t('bp', 'BP')} (${conceptUnits.get(config.concepts.systolicBloodPressureUuid)})`,
29
+ value: 'systolic',
30
+ });
31
+
32
+ const vitalSigns: { id: string; title: string; value: VitalSignKey }[] = [
33
+ {
34
+ id: 'bloodPressure',
35
+ title: withUnit(t('bp', 'BP'), conceptUnits.get(config.concepts.systolicBloodPressureUuid) ?? '-'),
36
+ value: 'systolic',
37
+ },
38
+ {
39
+ id: 'oxygenSaturation',
40
+ title: withUnit(t('spo2', 'SpO2'), conceptUnits.get(config.concepts.oxygenSaturationUuid) ?? '-'),
41
+ value: 'spo2',
42
+ },
43
+ {
44
+ id: 'temperature',
45
+ title: withUnit(t('temp', 'Temp'), conceptUnits.get(config.concepts.temperatureUuid) ?? '-'),
46
+ value: 'temperature',
47
+ },
48
+ {
49
+ id: 'respiratoryRate',
50
+ title: withUnit(t('respiratoryRate', 'R. rate'), conceptUnits.get(config.concepts.respiratoryRateUuid) ?? '-'),
51
+ value: 'respiratoryRate',
52
+ },
53
+ {
54
+ id: 'pulse',
55
+ title: withUnit(t('pulse', 'Pulse'), conceptUnits.get(config.concepts.pulseUuid) ?? '-'),
56
+ value: 'pulse',
57
+ },
58
+ ];
59
+
60
+ const chartData = useMemo(() => {
61
+ return patientVitals
62
+ .filter((vitals) => vitals[selectedVitalsSign.value])
63
+ .slice(0, 10)
64
+ .sort((vitalA, vitalB) => new Date(vitalA.date).getTime() - new Date(vitalB.date).getTime())
65
+ .map((vitals) => {
66
+ if (['systolic', 'diastolic'].includes(selectedVitalsSign.value)) {
67
+ return [
68
+ {
69
+ group: 'Systolic blood pressure',
70
+ key: formatDate(parseDate(vitals.date), { year: true }),
71
+ value: vitals.systolic,
72
+ date: vitals.date,
73
+ },
74
+ {
75
+ group: 'Diastolic blood pressure',
76
+ key: formatDate(parseDate(vitals.date), { year: true }),
77
+ value: vitals.diastolic,
78
+ date: vitals.date,
79
+ },
80
+ ];
81
+ }
82
+ return {
83
+ group: selectedVitalsSign.value,
84
+ key: formatDate(parseDate(vitals.date)),
85
+ value: vitals[selectedVitalsSign.value],
86
+ date: vitals.date,
87
+ };
88
+ });
89
+ }, [patientVitals, selectedVitalsSign]);
90
+
91
+ const chartOptions = {
92
+ title: selectedVitalsSign.title,
93
+ axes: {
94
+ bottom: {
95
+ title: t('date', 'Date'),
96
+ mapsTo: 'date',
97
+ scaleType: ScaleTypes.TIME,
98
+ },
99
+ left: {
100
+ mapsTo: 'value',
101
+ title: selectedVitalsSign.title,
102
+ scaleType: ScaleTypes.LINEAR,
103
+ includeZero: false,
104
+ },
105
+ },
106
+ legend: {
107
+ enabled: false,
108
+ },
109
+ color: {
110
+ scale: {
111
+ [selectedVitalsSign.title]: '#6929c4',
112
+ },
113
+ },
114
+ tooltip: {
115
+ customHTML: ([{ value, group, key }]) =>
116
+ `<div class="cds--tooltip cds--tooltip--shown" style="min-width: max-content; font-weight:600">${value} - ${String(
117
+ group,
118
+ ).toUpperCase()}
119
+ <span style="color: #c6c6c6; font-size: 1rem; font-weight:600">${key}</span></div>`,
120
+ },
121
+ toolbar: {
122
+ enabled: true,
123
+ numberOfIcons: 4,
124
+ controls: [
125
+ {
126
+ type: 'Zoom in',
127
+ },
128
+ {
129
+ type: 'Zoom out',
130
+ },
131
+ {
132
+ type: 'Reset zoom',
133
+ },
134
+ {
135
+ type: 'Export as CSV',
136
+ },
137
+ {
138
+ type: 'Export as PNG',
139
+ },
140
+ {
141
+ type: 'Make fullscreen',
142
+ },
143
+ ],
144
+ },
145
+ zoomBar: {
146
+ top: {
147
+ enabled: true,
148
+ },
149
+ },
150
+ height: '400px',
151
+ };
152
+
153
+ return (
154
+ <div className={styles.vitalsChartContainer}>
155
+ <div className={styles.vitalSignsArea}>
156
+ <label className={styles.vitalsSignLabel} htmlFor={`${id}-tab`}>
157
+ {t('vitalSignDisplayed', 'Vital sign displayed')}
158
+ </label>
159
+ <TabsVertical>
160
+ <TabListVertical aria-label="Vitals tabs">
161
+ {vitalSigns.map(({ id, title, value }) => (
162
+ <Tab
163
+ className={classNames(styles.tab, { [styles.selectedTab]: selectedVitalsSign.title === title })}
164
+ id={`${id}-tab`}
165
+ key={id}
166
+ onClick={() =>
167
+ setSelectedVitalsSign({
168
+ title: title,
169
+ value: value,
170
+ })
171
+ }>
172
+ {title}
173
+ </Tab>
174
+ ))}
175
+ </TabListVertical>
176
+ <TabPanels>
177
+ {vitalSigns.map(({ id, title, value }) => (
178
+ <TabPanel key={id}>
179
+ <LineChart
180
+ data={chartData
181
+ .flat()
182
+ .filter((data) =>
183
+ value === 'systolic'
184
+ ? data.group === 'Systolic blood pressure' || data.group === 'Diastolic blood pressure'
185
+ : data.group === value,
186
+ )}
187
+ options={chartOptions}
188
+ />
189
+ </TabPanel>
190
+ ))}
191
+ </TabPanels>
192
+ </TabsVertical>
193
+ </div>
194
+ </div>
195
+ );
196
+ };
197
+
198
+ export default VitalsChart;
@@ -0,0 +1,70 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .label01 {
6
+ @include type.type-style('label-01');
7
+ }
8
+
9
+ .bodyShort01 {
10
+ @include type.type-style('body-compact-01');
11
+ }
12
+
13
+ .vitalsChartContainer {
14
+ display: flex;
15
+ margin: 0 layout.$spacing-05;
16
+ flex-direction: row;
17
+ justify-content: space-between;
18
+ }
19
+
20
+ .vitalSignsArea {
21
+ flex-grow: 1;
22
+ margin: layout.$spacing-03 0;
23
+
24
+ // This is a hack to override the max-width of the overflow menu button
25
+ :global(
26
+ .cds--chart-holder
27
+ .cds--cc--toolbar
28
+ .cds--overflow-menu-options
29
+ .cds--overflow-menu-options__option
30
+ .cds--overflow-menu-options__btn
31
+ ) {
32
+ max-width: none !important;
33
+ }
34
+
35
+ // Ensures the chart toolbar tooltips are visible
36
+ :global(.cds--tabs--vertical ~ .cds--tab-content) {
37
+ overflow-y: visible;
38
+ }
39
+ }
40
+
41
+ .vitalsSignLabel {
42
+ @extend .label01;
43
+ margin-bottom: layout.$spacing-05;
44
+ display: inline-block;
45
+ }
46
+
47
+ .tab {
48
+ outline: 0;
49
+ outline-offset: 0;
50
+ min-height: layout.$spacing-07 !important;
51
+ block-size: layout.$spacing-05 !important;
52
+
53
+ &:active,
54
+ &:focus {
55
+ outline: layout.$spacing-01 solid var(--brand-03) !important;
56
+ }
57
+
58
+ &[aria-selected='true'] {
59
+ box-shadow: inset 4px 0 0 0 var(--brand-03) !important;
60
+ border-bottom: none;
61
+ font-weight: 600;
62
+ margin-left: 0 !important;
63
+ }
64
+
65
+ &[aria-selected='false'] {
66
+ border-bottom: none;
67
+ border-left: layout.$spacing-01 solid $ui-03;
68
+ margin-left: 0 !important;
69
+ }
70
+ }