@openmrs/esm-generic-patient-widgets-app 11.3.1-patch.9064 → 11.3.1-patch.9508
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +23 -23
- package/dist/1119.js +1 -1
- package/dist/1197.js +1 -1
- package/dist/1936.js +1 -0
- package/dist/1936.js.map +1 -0
- package/dist/2146.js +1 -1
- package/dist/2606.js +2 -0
- package/dist/2606.js.map +1 -0
- package/dist/2690.js +1 -1
- package/dist/3099.js +1 -1
- package/dist/3204.js +2 -0
- package/dist/3204.js.map +1 -0
- package/dist/3584.js +1 -1
- package/dist/4055.js +1 -1
- package/dist/4132.js +1 -1
- package/dist/4300.js +1 -1
- package/dist/4335.js +1 -1
- package/dist/439.js +1 -0
- package/dist/4618.js +1 -1
- package/dist/4652.js +1 -1
- package/dist/4944.js +1 -1
- package/dist/5173.js +1 -1
- package/dist/5241.js +1 -1
- package/dist/5442.js +1 -1
- package/dist/5661.js +1 -1
- package/dist/5670.js +1 -1
- package/dist/5670.js.map +1 -1
- package/dist/6022.js +1 -1
- package/dist/6336.js +1 -0
- package/dist/6336.js.map +1 -0
- package/dist/6468.js +1 -1
- package/dist/6589.js +1 -0
- package/dist/6679.js +1 -1
- package/dist/6840.js +1 -1
- package/dist/6859.js +1 -1
- package/dist/7097.js +1 -1
- package/dist/7159.js +1 -1
- package/dist/723.js +1 -1
- package/dist/7545.js +2 -0
- package/dist/7545.js.map +1 -0
- package/dist/7617.js +1 -1
- package/dist/795.js +1 -1
- package/dist/8163.js +1 -1
- package/dist/8349.js +1 -1
- package/dist/8371.js +1 -0
- package/dist/8618.js +1 -1
- package/dist/8803.js +1 -1
- package/dist/8803.js.map +1 -1
- package/dist/890.js +1 -1
- package/dist/9214.js +1 -1
- package/dist/9538.js +1 -1
- package/dist/9569.js +1 -1
- package/dist/986.js +1 -1
- package/dist/9879.js +1 -1
- package/dist/9895.js +1 -1
- package/dist/9900.js +1 -1
- package/dist/9913.js +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/openmrs-esm-generic-patient-widgets-app.js +1 -1
- package/dist/openmrs-esm-generic-patient-widgets-app.js.buildmanifest.json +285 -219
- package/dist/openmrs-esm-generic-patient-widgets-app.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +4 -4
- package/src/config-schema-obs-horizontal.ts +12 -0
- package/src/config-schema-obs-switchable.ts +7 -1
- package/src/obs-graph/obs-graph.component.tsx +85 -36
- package/src/obs-graph/obs-graph.scss +19 -11
- package/src/obs-switchable/obs-switchable.component.tsx +12 -11
- package/src/obs-switchable/obs-switchable.test.tsx +145 -42
- package/src/obs-table/obs-table.component.tsx +104 -20
- package/src/obs-table-horizontal/obs-table-horizontal.component.tsx +470 -57
- package/src/obs-table-horizontal/obs-table-horizontal.resource.ts +67 -0
- package/src/obs-table-horizontal/obs-table-horizontal.scss +47 -0
- package/src/obs-table-horizontal/obs-table-horizontal.test.tsx +923 -0
- package/src/resources/useConcepts.ts +51 -0
- package/src/resources/useEncounterTypes.ts +34 -0
- package/src/resources/useObs.ts +40 -31
- package/translations/am.json +7 -1
- package/translations/ar.json +7 -1
- package/translations/ar_SY.json +7 -1
- package/translations/bn.json +7 -1
- package/translations/cs.json +10 -0
- package/translations/de.json +7 -1
- package/translations/en.json +7 -1
- package/translations/en_US.json +7 -1
- package/translations/es.json +7 -1
- package/translations/es_MX.json +7 -1
- package/translations/fr.json +7 -1
- package/translations/he.json +7 -1
- package/translations/hi.json +7 -1
- package/translations/hi_IN.json +7 -1
- package/translations/id.json +7 -1
- package/translations/it.json +7 -1
- package/translations/ka.json +7 -1
- package/translations/km.json +7 -1
- package/translations/ku.json +7 -1
- package/translations/ky.json +7 -1
- package/translations/lg.json +7 -1
- package/translations/ne.json +7 -1
- package/translations/pl.json +7 -1
- package/translations/pt.json +7 -1
- package/translations/pt_BR.json +7 -1
- package/translations/qu.json +7 -1
- package/translations/ro_RO.json +7 -1
- package/translations/ru_RU.json +7 -1
- package/translations/si.json +7 -1
- package/translations/sq.json +10 -0
- package/translations/sw.json +7 -1
- package/translations/sw_KE.json +7 -1
- package/translations/tr.json +7 -1
- package/translations/tr_TR.json +7 -1
- package/translations/uk.json +7 -1
- package/translations/uz.json +7 -1
- package/translations/uz@Latn.json +7 -1
- package/translations/uz_UZ.json +7 -1
- package/translations/vi.json +7 -1
- package/translations/zh.json +7 -1
- package/translations/zh_CN.json +7 -1
- package/translations/zh_TW.json +10 -0
- package/dist/1559.js +0 -2
- package/dist/1559.js.map +0 -1
- package/dist/251.js +0 -2
- package/dist/251.js.map +0 -1
- package/dist/5639.js +0 -1
- package/dist/5639.js.map +0 -1
- package/dist/5986.js +0 -1
- package/dist/5986.js.map +0 -1
- package/dist/6781.js +0 -2
- package/dist/6781.js.map +0 -1
- /package/dist/{251.js.LICENSE.txt → 2606.js.LICENSE.txt} +0 -0
- /package/dist/{6781.js.LICENSE.txt → 3204.js.LICENSE.txt} +0 -0
- /package/dist/{1559.js.LICENSE.txt → 7545.js.LICENSE.txt} +0 -0
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { usePagination, useConfig, formatDate, formatTime } from '@openmrs/esm-framework';
|
|
1
|
+
import React, { useState, useCallback, type ComponentProps, useMemo } from 'react';
|
|
3
2
|
import {
|
|
3
|
+
usePagination,
|
|
4
|
+
useConfig,
|
|
5
|
+
formatDate,
|
|
6
|
+
formatTime,
|
|
7
|
+
showSnackbar,
|
|
8
|
+
useSession,
|
|
9
|
+
useLayoutType,
|
|
10
|
+
isDesktop,
|
|
11
|
+
EditIcon,
|
|
12
|
+
AddIcon,
|
|
13
|
+
type Concept,
|
|
14
|
+
userHasAccess,
|
|
15
|
+
type Privilege,
|
|
16
|
+
getCoreTranslation,
|
|
17
|
+
} from '@openmrs/esm-framework';
|
|
18
|
+
import {
|
|
19
|
+
Button,
|
|
4
20
|
Table,
|
|
5
21
|
TableCell,
|
|
6
22
|
TableContainer,
|
|
@@ -9,26 +25,97 @@ import {
|
|
|
9
25
|
TableHeader,
|
|
10
26
|
TableRow,
|
|
11
27
|
InlineLoading,
|
|
28
|
+
TextInput,
|
|
29
|
+
NumberInput,
|
|
30
|
+
Select,
|
|
31
|
+
SelectItem,
|
|
32
|
+
IconButton,
|
|
12
33
|
} from '@carbon/react';
|
|
34
|
+
import { Checkmark, Close } from '@carbon/react/icons';
|
|
13
35
|
import { CardHeader, PatientChartPagination } from '@openmrs/esm-patient-common-lib';
|
|
14
36
|
import { useObs } from '../resources/useObs';
|
|
15
37
|
import styles from './obs-table-horizontal.scss';
|
|
16
38
|
import { useTranslation } from 'react-i18next';
|
|
17
39
|
import { type ConfigObjectHorizontal } from '../config-schema-obs-horizontal';
|
|
40
|
+
import { updateObservation, createObservationInEncounter, createEncounter } from './obs-table-horizontal.resource';
|
|
41
|
+
import classNames from 'classnames';
|
|
42
|
+
import { useEncounterTypes } from '../resources/useEncounterTypes';
|
|
18
43
|
|
|
19
44
|
interface ObsTableHorizontalProps {
|
|
20
45
|
patientUuid: string;
|
|
21
46
|
}
|
|
22
47
|
|
|
48
|
+
interface ColumnData {
|
|
49
|
+
id: string;
|
|
50
|
+
date: Date;
|
|
51
|
+
encounter: { value: string; editPrivilege: Privilege };
|
|
52
|
+
encounterReference: string;
|
|
53
|
+
encounterUuid: string | null; // null for temporary encounters
|
|
54
|
+
obs: Record<string, CellData>;
|
|
55
|
+
isTemporary?: boolean; // true for encounters that haven't been saved yet
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface CellData {
|
|
59
|
+
value: string | number;
|
|
60
|
+
obsUuid: string;
|
|
61
|
+
dataType: string;
|
|
62
|
+
display?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* This component displays a table of observations, where each column represents an encounter.
|
|
67
|
+
* It may be 'editable' or not. If it is editable, then
|
|
68
|
+
* - Individual observations can be edited by tapping them or clicking an edit button.
|
|
69
|
+
* - New encounters can be created by tapping a "plus" button. When the plus button is
|
|
70
|
+
* tapped, a temporary encounter is created on the front-end only. The new encounter is
|
|
71
|
+
* only saved to the backend when the first obs value for it is entered.
|
|
72
|
+
*/
|
|
23
73
|
const ObsTableHorizontal: React.FC<ObsTableHorizontalProps> = ({ patientUuid }) => {
|
|
24
74
|
const { t } = useTranslation();
|
|
25
75
|
const config = useConfig<ConfigObjectHorizontal>();
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
76
|
+
const isTablet = !isDesktop(useLayoutType());
|
|
77
|
+
const {
|
|
78
|
+
data: { observations, concepts, encounters },
|
|
79
|
+
isValidating,
|
|
80
|
+
mutate,
|
|
81
|
+
} = useObs(patientUuid);
|
|
82
|
+
|
|
83
|
+
const [temporaryEncounters, setTemporaryEncounters] = useState<Array<ColumnData>>([]);
|
|
84
|
+
|
|
85
|
+
let obssGroupedByEncounters = useMemo(
|
|
86
|
+
() =>
|
|
87
|
+
encounters?.length
|
|
88
|
+
? encounters.map((encounter) => observations.filter((o) => o.encounter.reference === encounter.reference))
|
|
89
|
+
: [],
|
|
90
|
+
[encounters, observations],
|
|
30
91
|
);
|
|
31
92
|
|
|
93
|
+
const { encounterTypes, isLoading: isLoadingEncounterTypes, error: errorEncounterTypes } = useEncounterTypes();
|
|
94
|
+
|
|
95
|
+
const encounterTypeToCreateEditPrivilege = useMemo(() => {
|
|
96
|
+
return encounterTypes.find((et) => et.uuid === config.encounterTypeToCreateUuid)?.editPrivilege;
|
|
97
|
+
}, [encounterTypes, config.encounterTypeToCreateUuid]);
|
|
98
|
+
|
|
99
|
+
const editPrivilegePerEncounterReference = useMemo(() => {
|
|
100
|
+
if (!encounters?.length || isLoadingEncounterTypes || errorEncounterTypes) {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
return encounters.reduce(
|
|
104
|
+
(acc, encounter) => {
|
|
105
|
+
const encounterType = encounterTypes.find((et) => et.uuid === encounter.encounterTypeUuid);
|
|
106
|
+
if (encounterType) {
|
|
107
|
+
acc[encounter.reference] = encounterType.editPrivilege;
|
|
108
|
+
}
|
|
109
|
+
return acc;
|
|
110
|
+
},
|
|
111
|
+
{} as Record<string, Privilege>,
|
|
112
|
+
);
|
|
113
|
+
}, [encounterTypes, encounters, isLoadingEncounterTypes, errorEncounterTypes]);
|
|
114
|
+
|
|
115
|
+
const conceptByUuid = useMemo(() => {
|
|
116
|
+
return Object.fromEntries(concepts.map((c) => [c.uuid, c]));
|
|
117
|
+
}, [concepts]);
|
|
118
|
+
|
|
32
119
|
if (config.oldestFirst) {
|
|
33
120
|
obssGroupedByEncounters.sort(
|
|
34
121
|
(a, b) => new Date(a[0].effectiveDateTime).getTime() - new Date(b[0].effectiveDateTime).getTime(),
|
|
@@ -41,57 +128,97 @@ const ObsTableHorizontal: React.FC<ObsTableHorizontalProps> = ({ patientUuid })
|
|
|
41
128
|
|
|
42
129
|
let tableRowLabels = config.data.map(({ concept, label }) => ({
|
|
43
130
|
key: concept,
|
|
44
|
-
header: t(label, label) ||
|
|
131
|
+
header: t(label, label) || concepts.find((c) => c.uuid === concept)?.display,
|
|
45
132
|
}));
|
|
46
133
|
|
|
47
134
|
if (config.showEncounterType) {
|
|
48
135
|
tableRowLabels = [{ key: 'encounter', header: t('encounterType', 'Encounter type') }, ...tableRowLabels];
|
|
49
136
|
}
|
|
50
137
|
|
|
51
|
-
const
|
|
52
|
-
()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
const handleAddEncounter = useCallback(() => {
|
|
139
|
+
const now = new Date();
|
|
140
|
+
const newTemporaryEncounter: ColumnData = {
|
|
141
|
+
id: `temp-${Date.now()}`,
|
|
142
|
+
date: now,
|
|
143
|
+
encounter: { value: '', editPrivilege: encounterTypeToCreateEditPrivilege },
|
|
144
|
+
encounterReference: '',
|
|
145
|
+
encounterUuid: null,
|
|
146
|
+
obs: {},
|
|
147
|
+
isTemporary: true,
|
|
148
|
+
};
|
|
149
|
+
setTemporaryEncounters((prev) => [...prev, newTemporaryEncounter]);
|
|
150
|
+
}, [encounterTypeToCreateEditPrivilege]);
|
|
151
|
+
|
|
152
|
+
const handleEncounterCreated = useCallback(
|
|
153
|
+
async (tempEncounterId: string, encounterUuid: string) => {
|
|
154
|
+
await mutate();
|
|
155
|
+
// Remove the temporary encounter from state since it's now in the real data
|
|
156
|
+
setTemporaryEncounters((prev) => prev.filter((enc) => enc.id !== tempEncounterId));
|
|
157
|
+
},
|
|
158
|
+
[mutate],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const tableColumns = useMemo(() => {
|
|
162
|
+
const existingColumns = obssGroupedByEncounters?.map((obss, index) => {
|
|
163
|
+
const encounterReference = obss[0].encounter.reference;
|
|
164
|
+
const encounterUuid = encounterReference.split('/')[1];
|
|
165
|
+
const columnData: ColumnData = {
|
|
166
|
+
id: `${index}`,
|
|
167
|
+
date: new Date(obss[0].effectiveDateTime),
|
|
168
|
+
encounter: {
|
|
169
|
+
value: obss[0].encounter.name,
|
|
170
|
+
editPrivilege: editPrivilegePerEncounterReference[encounterReference],
|
|
171
|
+
},
|
|
172
|
+
encounterReference,
|
|
173
|
+
encounterUuid,
|
|
174
|
+
obs: {} as Record<string, CellData>,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
for (const obs of obss) {
|
|
178
|
+
switch (conceptByUuid[obs.conceptUuid]?.dataType) {
|
|
179
|
+
case 'Text':
|
|
180
|
+
columnData.obs[obs.conceptUuid] = {
|
|
181
|
+
value: obs.valueString,
|
|
182
|
+
obsUuid: obs.id,
|
|
183
|
+
dataType: 'Text',
|
|
184
|
+
};
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'Numeric': {
|
|
188
|
+
const decimalPlaces: number | undefined = config.data.find(
|
|
189
|
+
(ele: any) => ele.concept === obs.conceptUuid,
|
|
190
|
+
)?.decimalPlaces;
|
|
84
191
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
192
|
+
let value;
|
|
193
|
+
if (obs.valueQuantity?.value % 1 !== 0) {
|
|
194
|
+
value = obs.valueQuantity?.value.toFixed(decimalPlaces);
|
|
195
|
+
} else {
|
|
196
|
+
value = obs.valueQuantity?.value;
|
|
197
|
+
}
|
|
198
|
+
columnData.obs[obs.conceptUuid] = {
|
|
199
|
+
value: value,
|
|
200
|
+
obsUuid: obs.id,
|
|
201
|
+
dataType: 'Numeric',
|
|
202
|
+
};
|
|
203
|
+
break;
|
|
88
204
|
}
|
|
205
|
+
|
|
206
|
+
case 'Coded':
|
|
207
|
+
columnData.obs[obs.conceptUuid] = {
|
|
208
|
+
value: obs.valueCodeableConcept?.coding[0]?.code,
|
|
209
|
+
display: obs.valueCodeableConcept?.coding[0]?.display,
|
|
210
|
+
obsUuid: obs.id,
|
|
211
|
+
dataType: 'Coded',
|
|
212
|
+
};
|
|
213
|
+
break;
|
|
89
214
|
}
|
|
215
|
+
}
|
|
90
216
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
217
|
+
return columnData;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return [...existingColumns, ...temporaryEncounters];
|
|
221
|
+
}, [config.data, obssGroupedByEncounters, conceptByUuid, temporaryEncounters, editPrivilegePerEncounterReference]);
|
|
95
222
|
|
|
96
223
|
const { results, goTo, currentPage } = usePagination(tableColumns, config.maxColumns);
|
|
97
224
|
|
|
@@ -101,8 +228,22 @@ const ObsTableHorizontal: React.FC<ObsTableHorizontalProps> = ({ patientUuid })
|
|
|
101
228
|
<div className={styles.backgroundDataFetchingIndicator}>
|
|
102
229
|
<span>{isValidating ? <InlineLoading /> : null}</span>
|
|
103
230
|
</div>
|
|
231
|
+
{isTablet && config.editable && (
|
|
232
|
+
<div className={styles.editabilityNote}>{t('editabilityNote', 'Tap an observation to edit')}</div>
|
|
233
|
+
)}
|
|
104
234
|
</CardHeader>
|
|
105
|
-
<HorizontalTable
|
|
235
|
+
<HorizontalTable
|
|
236
|
+
tableRowLabels={tableRowLabels}
|
|
237
|
+
tableColumns={results}
|
|
238
|
+
// If encounter types are not loaded or can't be loaded, assume the user does
|
|
239
|
+
// not have the necessary privileges.
|
|
240
|
+
editable={config.editable && !isLoadingEncounterTypes && !errorEncounterTypes}
|
|
241
|
+
patientUuid={patientUuid}
|
|
242
|
+
mutate={mutate}
|
|
243
|
+
concepts={concepts}
|
|
244
|
+
onAddEncounter={handleAddEncounter}
|
|
245
|
+
onEncounterCreated={handleEncounterCreated}
|
|
246
|
+
/>
|
|
106
247
|
<PatientChartPagination
|
|
107
248
|
currentItems={results.length}
|
|
108
249
|
totalItems={tableColumns.length}
|
|
@@ -114,8 +255,34 @@ const ObsTableHorizontal: React.FC<ObsTableHorizontalProps> = ({ patientUuid })
|
|
|
114
255
|
);
|
|
115
256
|
};
|
|
116
257
|
|
|
117
|
-
|
|
258
|
+
interface HorizontalTableProps {
|
|
259
|
+
tableRowLabels: Array<{ key: string; header: string }>;
|
|
260
|
+
tableColumns: Array<ColumnData>;
|
|
261
|
+
editable: boolean;
|
|
262
|
+
patientUuid: string;
|
|
263
|
+
concepts: Array<Concept>;
|
|
264
|
+
mutate: () => Promise<any>;
|
|
265
|
+
onAddEncounter?: () => void;
|
|
266
|
+
onEncounterCreated?: (tempEncounterId: string, encounterUuid: string) => void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const HorizontalTable: React.FC<HorizontalTableProps> = ({
|
|
270
|
+
tableRowLabels,
|
|
271
|
+
tableColumns,
|
|
272
|
+
editable,
|
|
273
|
+
patientUuid,
|
|
274
|
+
concepts,
|
|
275
|
+
mutate,
|
|
276
|
+
onAddEncounter,
|
|
277
|
+
onEncounterCreated,
|
|
278
|
+
}) => {
|
|
118
279
|
const { t } = useTranslation();
|
|
280
|
+
const patientChartConfig = useConfig({ externalModuleName: '@openmrs/esm-patient-chart-app' });
|
|
281
|
+
const encounterEditableDuration = patientChartConfig?.encounterEditableDuration ?? 0;
|
|
282
|
+
const encounterEditableDurationOverridePrivileges =
|
|
283
|
+
patientChartConfig?.encounterEditableDurationOverridePrivileges ?? [];
|
|
284
|
+
const session = useSession();
|
|
285
|
+
|
|
119
286
|
return (
|
|
120
287
|
<TableContainer>
|
|
121
288
|
<Table experimentalAutoAlign={true} size="sm" useZebraStyles>
|
|
@@ -129,21 +296,267 @@ const HorizontalTable = ({ tableRowLabels, tableColumns }: { tableRowLabels: any
|
|
|
129
296
|
<div className={styles.headerTime}>{formatTime(column.date)}</div>
|
|
130
297
|
</TableHeader>
|
|
131
298
|
))}
|
|
299
|
+
{editable && (
|
|
300
|
+
<TableHeader>
|
|
301
|
+
<IconButton
|
|
302
|
+
align="bottom-end"
|
|
303
|
+
kind="ghost"
|
|
304
|
+
size="sm"
|
|
305
|
+
label={t('addEncounter', 'Add encounter')}
|
|
306
|
+
onClick={onAddEncounter}
|
|
307
|
+
>
|
|
308
|
+
<AddIcon size={16} />
|
|
309
|
+
</IconButton>
|
|
310
|
+
</TableHeader>
|
|
311
|
+
)}
|
|
132
312
|
</TableRow>
|
|
133
313
|
</TableHead>
|
|
134
314
|
<TableBody>
|
|
135
|
-
{tableRowLabels.map((label) =>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
<TableCell
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
315
|
+
{tableRowLabels.map((label) => {
|
|
316
|
+
const dataType = concepts.find((c) => c.uuid === label.key)?.dataType;
|
|
317
|
+
return (
|
|
318
|
+
<TableRow key={`obs-hz-row-${label.key}`}>
|
|
319
|
+
<TableCell>{label.header}</TableCell>
|
|
320
|
+
{tableColumns.map((column) => {
|
|
321
|
+
const cellData = column.obs[label.key];
|
|
322
|
+
const encounterAgeInMinutes = (Date.now() - column.date.getTime()) / (1000 * 60);
|
|
323
|
+
const canEditEncounter =
|
|
324
|
+
userHasAccess(column.encounter.editPrivilege?.uuid, session?.user) &&
|
|
325
|
+
(!encounterEditableDuration ||
|
|
326
|
+
encounterEditableDuration === 0 ||
|
|
327
|
+
(encounterEditableDuration > 0 && encounterAgeInMinutes <= encounterEditableDuration) ||
|
|
328
|
+
encounterEditableDurationOverridePrivileges.some((privilege) =>
|
|
329
|
+
userHasAccess(privilege, session?.user),
|
|
330
|
+
));
|
|
331
|
+
|
|
332
|
+
const canEditCell =
|
|
333
|
+
editable &&
|
|
334
|
+
canEditEncounter &&
|
|
335
|
+
label.key !== 'encounter' &&
|
|
336
|
+
['Text', 'Numeric', 'Coded'].includes(dataType);
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<Cell
|
|
340
|
+
key={`obs-hz-value-${column.id}-${label.key}`}
|
|
341
|
+
cellData={cellData}
|
|
342
|
+
dataType={dataType}
|
|
343
|
+
editable={canEditCell}
|
|
344
|
+
patientUuid={patientUuid}
|
|
345
|
+
column={column}
|
|
346
|
+
label={label}
|
|
347
|
+
concepts={concepts}
|
|
348
|
+
mutate={mutate}
|
|
349
|
+
onEncounterCreated={onEncounterCreated}
|
|
350
|
+
/>
|
|
351
|
+
);
|
|
352
|
+
})}
|
|
353
|
+
{onAddEncounter && <TableCell />}
|
|
354
|
+
</TableRow>
|
|
355
|
+
);
|
|
356
|
+
})}
|
|
143
357
|
</TableBody>
|
|
144
358
|
</Table>
|
|
145
359
|
</TableContainer>
|
|
146
360
|
);
|
|
147
361
|
};
|
|
148
362
|
|
|
363
|
+
const Cell: React.FC<{
|
|
364
|
+
cellData: CellData;
|
|
365
|
+
editable: boolean;
|
|
366
|
+
dataType: string;
|
|
367
|
+
patientUuid: string;
|
|
368
|
+
column: ColumnData;
|
|
369
|
+
label: { key: string; header: string };
|
|
370
|
+
concepts: Array<Concept>;
|
|
371
|
+
mutate: () => Promise<any>;
|
|
372
|
+
onEncounterCreated?: (tempEncounterId: string, encounterUuid: string) => void;
|
|
373
|
+
}> = ({ cellData, dataType, editable, patientUuid, column, label, concepts, mutate, onEncounterCreated }) => {
|
|
374
|
+
const { t } = useTranslation();
|
|
375
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
376
|
+
const [editingValue, setEditingValue] = useState<string | number>('');
|
|
377
|
+
const isTablet = !isDesktop(useLayoutType());
|
|
378
|
+
const session = useSession();
|
|
379
|
+
const locationUuid = session?.sessionLocation?.uuid;
|
|
380
|
+
const { encounterTypeToCreateUuid } = useConfig<ConfigObjectHorizontal>();
|
|
381
|
+
|
|
382
|
+
const conceptKey = label.key;
|
|
383
|
+
const cellKey = `obs-hz-value-${column.id}-${label.key}`;
|
|
384
|
+
|
|
385
|
+
const handleEditClick = useCallback(() => {
|
|
386
|
+
setIsEditing(true);
|
|
387
|
+
setEditingValue(cellData?.value ?? '');
|
|
388
|
+
}, [cellData?.value]);
|
|
389
|
+
|
|
390
|
+
const handleCreateEncounter = useCallback(async () => {
|
|
391
|
+
const response = await createEncounter(patientUuid, encounterTypeToCreateUuid, locationUuid, [
|
|
392
|
+
{ concept: conceptKey, value: editingValue },
|
|
393
|
+
]);
|
|
394
|
+
const createdEncounterUuid = response?.data?.uuid;
|
|
395
|
+
if (createdEncounterUuid) {
|
|
396
|
+
// onEncounterCreated will call mutate() and remove the temporary encounter
|
|
397
|
+
onEncounterCreated(column.id, createdEncounterUuid);
|
|
398
|
+
} else {
|
|
399
|
+
throw new Error('Failed to create encounter');
|
|
400
|
+
}
|
|
401
|
+
}, [patientUuid, encounterTypeToCreateUuid, locationUuid, conceptKey, editingValue, onEncounterCreated, column.id]);
|
|
402
|
+
|
|
403
|
+
const handleSave = useCallback(
|
|
404
|
+
async (conceptKey: string) => {
|
|
405
|
+
if (!editingValue || editingValue === cellData?.value) {
|
|
406
|
+
setIsEditing(false);
|
|
407
|
+
setEditingValue('');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
if (cellData?.obsUuid) {
|
|
413
|
+
await updateObservation(cellData.obsUuid, editingValue);
|
|
414
|
+
await mutate();
|
|
415
|
+
} else if (column.isTemporary) {
|
|
416
|
+
await handleCreateEncounter();
|
|
417
|
+
} else if (column.encounterUuid) {
|
|
418
|
+
await createObservationInEncounter(column.encounterUuid, patientUuid, conceptKey, editingValue);
|
|
419
|
+
await mutate();
|
|
420
|
+
} else {
|
|
421
|
+
throw new Error('Cannot save observation: missing encounter information');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
showSnackbar({
|
|
425
|
+
title: t('observationSaved', 'Observation saved successfully'),
|
|
426
|
+
kind: 'success',
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
setIsEditing(false);
|
|
430
|
+
setEditingValue('');
|
|
431
|
+
} catch (error) {
|
|
432
|
+
await mutate();
|
|
433
|
+
showSnackbar({
|
|
434
|
+
title: t('errorSavingObservation', 'Error saving observation'),
|
|
435
|
+
kind: 'error',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
[
|
|
440
|
+
editingValue,
|
|
441
|
+
patientUuid,
|
|
442
|
+
mutate,
|
|
443
|
+
t,
|
|
444
|
+
cellData?.obsUuid,
|
|
445
|
+
cellData?.value,
|
|
446
|
+
column.encounterUuid,
|
|
447
|
+
column.isTemporary,
|
|
448
|
+
handleCreateEncounter,
|
|
449
|
+
],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const handleCancel = useCallback(() => {
|
|
453
|
+
setIsEditing(false);
|
|
454
|
+
setEditingValue('');
|
|
455
|
+
}, []);
|
|
456
|
+
|
|
457
|
+
const handleKeyDown = useCallback(
|
|
458
|
+
(e: React.KeyboardEvent) => {
|
|
459
|
+
if (e.key === 'Enter') {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
handleSave(conceptKey);
|
|
462
|
+
} else if (e.key === 'Escape') {
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
handleCancel();
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
[handleSave, handleCancel, conceptKey],
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
if (isEditing) {
|
|
471
|
+
return (
|
|
472
|
+
<TableCell key={cellKey}>
|
|
473
|
+
<div className={styles.editContainer}>
|
|
474
|
+
{dataType === 'Numeric' ? (
|
|
475
|
+
<NumberInput
|
|
476
|
+
id={cellKey}
|
|
477
|
+
size="sm"
|
|
478
|
+
value={editingValue}
|
|
479
|
+
onChange={(e: any, data: any) => setEditingValue(data.value ?? '')}
|
|
480
|
+
onKeyDown={(e) => handleKeyDown(e)}
|
|
481
|
+
autoFocus
|
|
482
|
+
hideSteppers
|
|
483
|
+
allowEmpty
|
|
484
|
+
/>
|
|
485
|
+
) : dataType === 'Text' ? (
|
|
486
|
+
<TextInput
|
|
487
|
+
id={cellKey}
|
|
488
|
+
labelText=""
|
|
489
|
+
size="sm"
|
|
490
|
+
value={editingValue}
|
|
491
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditingValue(e.target.value)}
|
|
492
|
+
onKeyDown={(e) => handleKeyDown(e)}
|
|
493
|
+
autoFocus
|
|
494
|
+
/>
|
|
495
|
+
) : (
|
|
496
|
+
<Select
|
|
497
|
+
id={cellKey}
|
|
498
|
+
labelText=""
|
|
499
|
+
size="sm"
|
|
500
|
+
value={editingValue}
|
|
501
|
+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
502
|
+
setEditingValue(e.target.value);
|
|
503
|
+
}}
|
|
504
|
+
onKeyDown={(e) => handleKeyDown(e)}
|
|
505
|
+
autoFocus
|
|
506
|
+
>
|
|
507
|
+
<SelectItem text={t('noValue', 'No value')} value="" />
|
|
508
|
+
{concepts
|
|
509
|
+
.find((c) => c.uuid === label.key)
|
|
510
|
+
?.answers?.map((answer) => <SelectItem key={answer.uuid} text={answer.display} value={answer.uuid} />)}
|
|
511
|
+
</Select>
|
|
512
|
+
)}
|
|
513
|
+
<div className={styles.editButtons}>
|
|
514
|
+
<IconButton
|
|
515
|
+
kind="ghost"
|
|
516
|
+
size="sm"
|
|
517
|
+
label={getCoreTranslation('cancel')}
|
|
518
|
+
onClick={handleCancel}
|
|
519
|
+
className={styles.cancelButton}
|
|
520
|
+
>
|
|
521
|
+
<Close size={16} />
|
|
522
|
+
</IconButton>
|
|
523
|
+
<IconButton
|
|
524
|
+
kind="ghost"
|
|
525
|
+
size="sm"
|
|
526
|
+
label={getCoreTranslation('save')}
|
|
527
|
+
onClick={() => handleSave(conceptKey)}
|
|
528
|
+
className={styles.saveButton}
|
|
529
|
+
>
|
|
530
|
+
<Checkmark size={16} />
|
|
531
|
+
</IconButton>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
</TableCell>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<TableCell
|
|
540
|
+
key={cellKey}
|
|
541
|
+
onClick={() => isTablet && editable && handleEditClick()}
|
|
542
|
+
className={classNames(editable ? styles.editableCell : undefined)}
|
|
543
|
+
>
|
|
544
|
+
<div className={styles.cellContent}>
|
|
545
|
+
<div className={styles.cellValue}>{cellData?.display ?? cellData?.value ?? '--'}</div>
|
|
546
|
+
{editable && (
|
|
547
|
+
<IconButton
|
|
548
|
+
kind="ghost"
|
|
549
|
+
size="sm"
|
|
550
|
+
label={getCoreTranslation('edit')}
|
|
551
|
+
onClick={() => handleEditClick()}
|
|
552
|
+
className={styles.editButton}
|
|
553
|
+
>
|
|
554
|
+
<EditIcon size={16} />
|
|
555
|
+
</IconButton>
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
</TableCell>
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
|
|
149
562
|
export default ObsTableHorizontal;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
export function updateObservation(observationUuid: string, value: string | number) {
|
|
4
|
+
return openmrsFetch(`${restBaseUrl}/obs/${observationUuid}`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: {
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
},
|
|
9
|
+
body: JSON.stringify({ value }),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new encounter
|
|
15
|
+
* @param patientUuid - The UUID of the patient
|
|
16
|
+
* @param encounterTypeUuid - The UUID of the encounter type
|
|
17
|
+
* @param locationUuid - The UUID of the location
|
|
18
|
+
* @param obs - Array of observations to include in the encounter
|
|
19
|
+
* @returns Promise with the created encounter
|
|
20
|
+
*/
|
|
21
|
+
export function createEncounter(
|
|
22
|
+
patientUuid: string,
|
|
23
|
+
encounterTypeUuid: string,
|
|
24
|
+
locationUuid: string,
|
|
25
|
+
obs: Array<{ concept: string; value: string | number }>,
|
|
26
|
+
) {
|
|
27
|
+
return openmrsFetch(`${restBaseUrl}/encounter`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
patient: patientUuid,
|
|
34
|
+
encounterType: encounterTypeUuid,
|
|
35
|
+
location: locationUuid,
|
|
36
|
+
encounterDatetime: new Date().toISOString(),
|
|
37
|
+
obs: obs,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates a new observation in an existing encounter
|
|
44
|
+
* @returns Promise with the updated encounter containing the new observation
|
|
45
|
+
*/
|
|
46
|
+
export function createObservationInEncounter(
|
|
47
|
+
encounterUuid: string,
|
|
48
|
+
patientUuid: string,
|
|
49
|
+
conceptUuid: string,
|
|
50
|
+
value: string | number,
|
|
51
|
+
) {
|
|
52
|
+
return openmrsFetch(`${restBaseUrl}/encounter/${encounterUuid}`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
patient: patientUuid,
|
|
59
|
+
obs: [
|
|
60
|
+
{
|
|
61
|
+
concept: conceptUuid,
|
|
62
|
+
value: value,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
}
|