@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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +23 -23
  2. package/dist/1119.js +1 -1
  3. package/dist/1197.js +1 -1
  4. package/dist/1936.js +1 -0
  5. package/dist/1936.js.map +1 -0
  6. package/dist/2146.js +1 -1
  7. package/dist/2606.js +2 -0
  8. package/dist/2606.js.map +1 -0
  9. package/dist/2690.js +1 -1
  10. package/dist/3099.js +1 -1
  11. package/dist/3204.js +2 -0
  12. package/dist/3204.js.map +1 -0
  13. package/dist/3584.js +1 -1
  14. package/dist/4055.js +1 -1
  15. package/dist/4132.js +1 -1
  16. package/dist/4300.js +1 -1
  17. package/dist/4335.js +1 -1
  18. package/dist/439.js +1 -0
  19. package/dist/4618.js +1 -1
  20. package/dist/4652.js +1 -1
  21. package/dist/4944.js +1 -1
  22. package/dist/5173.js +1 -1
  23. package/dist/5241.js +1 -1
  24. package/dist/5442.js +1 -1
  25. package/dist/5661.js +1 -1
  26. package/dist/5670.js +1 -1
  27. package/dist/5670.js.map +1 -1
  28. package/dist/6022.js +1 -1
  29. package/dist/6336.js +1 -0
  30. package/dist/6336.js.map +1 -0
  31. package/dist/6468.js +1 -1
  32. package/dist/6589.js +1 -0
  33. package/dist/6679.js +1 -1
  34. package/dist/6840.js +1 -1
  35. package/dist/6859.js +1 -1
  36. package/dist/7097.js +1 -1
  37. package/dist/7159.js +1 -1
  38. package/dist/723.js +1 -1
  39. package/dist/7545.js +2 -0
  40. package/dist/7545.js.map +1 -0
  41. package/dist/7617.js +1 -1
  42. package/dist/795.js +1 -1
  43. package/dist/8163.js +1 -1
  44. package/dist/8349.js +1 -1
  45. package/dist/8371.js +1 -0
  46. package/dist/8618.js +1 -1
  47. package/dist/8803.js +1 -1
  48. package/dist/8803.js.map +1 -1
  49. package/dist/890.js +1 -1
  50. package/dist/9214.js +1 -1
  51. package/dist/9538.js +1 -1
  52. package/dist/9569.js +1 -1
  53. package/dist/986.js +1 -1
  54. package/dist/9879.js +1 -1
  55. package/dist/9895.js +1 -1
  56. package/dist/9900.js +1 -1
  57. package/dist/9913.js +1 -1
  58. package/dist/main.js +1 -1
  59. package/dist/main.js.map +1 -1
  60. package/dist/openmrs-esm-generic-patient-widgets-app.js +1 -1
  61. package/dist/openmrs-esm-generic-patient-widgets-app.js.buildmanifest.json +285 -219
  62. package/dist/openmrs-esm-generic-patient-widgets-app.js.map +1 -1
  63. package/dist/routes.json +1 -1
  64. package/package.json +4 -4
  65. package/src/config-schema-obs-horizontal.ts +12 -0
  66. package/src/config-schema-obs-switchable.ts +7 -1
  67. package/src/obs-graph/obs-graph.component.tsx +85 -36
  68. package/src/obs-graph/obs-graph.scss +19 -11
  69. package/src/obs-switchable/obs-switchable.component.tsx +12 -11
  70. package/src/obs-switchable/obs-switchable.test.tsx +145 -42
  71. package/src/obs-table/obs-table.component.tsx +104 -20
  72. package/src/obs-table-horizontal/obs-table-horizontal.component.tsx +470 -57
  73. package/src/obs-table-horizontal/obs-table-horizontal.resource.ts +67 -0
  74. package/src/obs-table-horizontal/obs-table-horizontal.scss +47 -0
  75. package/src/obs-table-horizontal/obs-table-horizontal.test.tsx +923 -0
  76. package/src/resources/useConcepts.ts +51 -0
  77. package/src/resources/useEncounterTypes.ts +34 -0
  78. package/src/resources/useObs.ts +40 -31
  79. package/translations/am.json +7 -1
  80. package/translations/ar.json +7 -1
  81. package/translations/ar_SY.json +7 -1
  82. package/translations/bn.json +7 -1
  83. package/translations/cs.json +10 -0
  84. package/translations/de.json +7 -1
  85. package/translations/en.json +7 -1
  86. package/translations/en_US.json +7 -1
  87. package/translations/es.json +7 -1
  88. package/translations/es_MX.json +7 -1
  89. package/translations/fr.json +7 -1
  90. package/translations/he.json +7 -1
  91. package/translations/hi.json +7 -1
  92. package/translations/hi_IN.json +7 -1
  93. package/translations/id.json +7 -1
  94. package/translations/it.json +7 -1
  95. package/translations/ka.json +7 -1
  96. package/translations/km.json +7 -1
  97. package/translations/ku.json +7 -1
  98. package/translations/ky.json +7 -1
  99. package/translations/lg.json +7 -1
  100. package/translations/ne.json +7 -1
  101. package/translations/pl.json +7 -1
  102. package/translations/pt.json +7 -1
  103. package/translations/pt_BR.json +7 -1
  104. package/translations/qu.json +7 -1
  105. package/translations/ro_RO.json +7 -1
  106. package/translations/ru_RU.json +7 -1
  107. package/translations/si.json +7 -1
  108. package/translations/sq.json +10 -0
  109. package/translations/sw.json +7 -1
  110. package/translations/sw_KE.json +7 -1
  111. package/translations/tr.json +7 -1
  112. package/translations/tr_TR.json +7 -1
  113. package/translations/uk.json +7 -1
  114. package/translations/uz.json +7 -1
  115. package/translations/uz@Latn.json +7 -1
  116. package/translations/uz_UZ.json +7 -1
  117. package/translations/vi.json +7 -1
  118. package/translations/zh.json +7 -1
  119. package/translations/zh_CN.json +7 -1
  120. package/translations/zh_TW.json +10 -0
  121. package/dist/1559.js +0 -2
  122. package/dist/1559.js.map +0 -1
  123. package/dist/251.js +0 -2
  124. package/dist/251.js.map +0 -1
  125. package/dist/5639.js +0 -1
  126. package/dist/5639.js.map +0 -1
  127. package/dist/5986.js +0 -1
  128. package/dist/5986.js.map +0 -1
  129. package/dist/6781.js +0 -2
  130. package/dist/6781.js.map +0 -1
  131. /package/dist/{251.js.LICENSE.txt → 2606.js.LICENSE.txt} +0 -0
  132. /package/dist/{6781.js.LICENSE.txt → 3204.js.LICENSE.txt} +0 -0
  133. /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 { data: obss, isValidating } = useObs(patientUuid, config.showEncounterType);
27
- const uniqueEncounterReferences = [...new Set(obss.map((o) => o.encounter.reference))].sort();
28
- let obssGroupedByEncounters = uniqueEncounterReferences.map((reference) =>
29
- obss.filter((o) => o.encounter.reference === reference),
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) || obss.find((o) => o.conceptUuid === concept)?.code?.text,
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 tableColumns = React.useMemo(
52
- () =>
53
- obssGroupedByEncounters?.map((obss, index) => {
54
- const rowData = {
55
- id: `${index}`,
56
- date: new Date(obss[0].effectiveDateTime),
57
- encounter: { value: obss[0].encounter.name },
58
- };
59
-
60
- for (const obs of obss) {
61
- switch (obs.dataType) {
62
- case 'Text':
63
- rowData[obs.conceptUuid] = {
64
- value: obs.valueString,
65
- };
66
- break;
67
-
68
- case 'Number': {
69
- const decimalPlaces: number | undefined = config.data.find(
70
- (ele: any) => ele.concept === obs.conceptUuid,
71
- )?.decimalPlaces;
72
-
73
- let value;
74
- if (obs.valueQuantity?.value % 1 !== 0) {
75
- value = obs.valueQuantity?.value.toFixed(decimalPlaces);
76
- } else {
77
- value = obs.valueQuantity?.value;
78
- }
79
- rowData[obs.conceptUuid] = {
80
- value: value,
81
- };
82
- break;
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
- case 'Coded':
86
- rowData[obs.conceptUuid] = { value: obs.valueCodeableConcept?.coding[0]?.display };
87
- break;
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
- return rowData;
92
- }),
93
- [config.data, obssGroupedByEncounters],
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 tableRowLabels={tableRowLabels} tableColumns={results} />
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
- const HorizontalTable = ({ tableRowLabels, tableColumns }: { tableRowLabels: any; tableColumns: any }) => {
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
- <TableRow key={`obs-hz-row-${label.key}`}>
137
- <TableCell>{label.header}</TableCell>
138
- {tableColumns.map((column) => (
139
- <TableCell key={`obs-hz-value-${column.id}-${label.key}`}>{column[label.key]?.value ?? '--'}</TableCell>
140
- ))}
141
- </TableRow>
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
+ }