@pih/esm-audit-app 2.1.1-pre.144 → 2.1.1-pre.151

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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +96 -2
  3. package/dist/115.js +1 -1
  4. package/dist/117.js +1 -1
  5. package/dist/117.js.map +1 -1
  6. package/dist/508.js +1 -1
  7. package/dist/508.js.map +1 -1
  8. package/dist/514.js +1 -0
  9. package/dist/514.js.map +1 -0
  10. package/dist/912.js +1 -1
  11. package/dist/912.js.map +1 -1
  12. package/dist/main.js +1 -1
  13. package/dist/pih-esm-audit-app.js +1 -1
  14. package/dist/pih-esm-audit-app.js.buildmanifest.json +34 -34
  15. package/dist/routes.json +1 -1
  16. package/locales/en/translation.json +118 -0
  17. package/locales/fr/translation.json +118 -0
  18. package/package.json +1 -1
  19. package/src/audit/audit-search.component.tsx +58 -0
  20. package/src/audit/audit.component.tsx +63 -16
  21. package/src/audit/audit.resource.ts +445 -10
  22. package/src/audit/audit.scss +6 -0
  23. package/src/audit/date-range-picker.test-stub.tsx +40 -0
  24. package/src/audit/date-range.test.ts +128 -0
  25. package/src/audit/date-range.ts +120 -0
  26. package/src/audit/encounter-filters.component.tsx +40 -18
  27. package/src/audit/encounter-filters.test.ts +1 -2
  28. package/src/audit/encounter-filters.ts +2 -27
  29. package/src/audit/patient-record.component.test.tsx +3 -5
  30. package/src/audit/patient-record.component.tsx +13 -2
  31. package/src/audit/patient-search.component.tsx +1 -1
  32. package/src/audit/provider-encounters.component.test.tsx +254 -0
  33. package/src/audit/provider-encounters.component.tsx +160 -0
  34. package/src/audit/provider-search.component.test.tsx +72 -0
  35. package/src/audit/provider-search.component.tsx +116 -0
  36. package/src/audit/user-encounters.component.test.tsx +431 -0
  37. package/src/audit/user-encounters.component.tsx +239 -0
  38. package/src/audit/user-encounters.test.ts +176 -0
  39. package/src/audit/user-encounters.ts +166 -0
  40. package/src/audit/user-search.component.test.tsx +89 -0
  41. package/src/audit/user-search.component.tsx +111 -0
  42. package/src/root.component.test.tsx +48 -3
  43. package/src/types.ts +34 -0
  44. package/translations/en.json +29 -0
  45. package/translations/es.json +29 -0
  46. package/translations/fr.json +29 -0
  47. package/dist/446.js +0 -1
  48. package/dist/446.js.map +0 -1
@@ -0,0 +1,431 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { SWRConfig } from 'swr';
5
+ import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
6
+ import UserEncounters from './user-encounters.component';
7
+ import { stubDateRangePicker } from './date-range-picker.test-stub';
8
+
9
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
10
+ const mockUseConfig = jest.mocked(useConfig);
11
+
12
+ const user = {
13
+ uuid: 'user-1',
14
+ display: 'butch',
15
+ username: 'butch',
16
+ person: { uuid: 'person-1', display: 'Cos John' },
17
+ };
18
+
19
+ /**
20
+ * Strictly descending timestamps, so observation 0 is the most recent change — which is the order
21
+ * the audit endpoint returns and the order the merge relies on.
22
+ */
23
+ const scanEpoch = Date.UTC(2026, 8, 1, 12, 0, 0);
24
+
25
+ function dayFromIndex(index: number): string {
26
+ return new Date(scanEpoch - index * 60000).toISOString();
27
+ }
28
+
29
+ /** Alternating types, so filtering by one keeps every other encounter. */
30
+ const consultationType = { uuid: 'type-consultation', display: 'Consultation' };
31
+ const vitalsType = { uuid: 'type-vitals', display: 'Vitals' };
32
+
33
+ /** A type nothing in the trail uses, since the dropdown offers every type in use in the system. */
34
+ const unusedType = { uuid: 'type-unused', display: 'Zibra Admission' };
35
+
36
+ /** A type nothing in the system has been recorded against, which the dropdown leaves out. */
37
+ const neverRecordedType = { uuid: 'type-never', display: 'Never Recorded' };
38
+
39
+ function encounter(index: number) {
40
+ return {
41
+ uuid: `enc-${index}`,
42
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
43
+ encounterType: index % 2 === 0 ? consultationType : vitalsType,
44
+ location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
45
+ patient: { uuid: `patient-${index}`, display: `Patient ${index}` },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Observations newest first, `obsPerEncounter` of them on each encounter — the shape a form-entered
51
+ * record has, and the one that makes discovering ten encounters cost more than ten observations.
52
+ */
53
+ function createdObsPage(startIndex: number, count: number, obsPerEncounter: number) {
54
+ return Array.from({ length: count }, (_, offset) => {
55
+ const index = startIndex + offset;
56
+ return {
57
+ uuid: `obs-${index}`,
58
+ encounter: encounter(Math.floor(index / obsPerEncounter)),
59
+ auditInfo: { dateCreated: dayFromIndex(index) },
60
+ };
61
+ });
62
+ }
63
+
64
+ function startIndexOf(url: string): number {
65
+ return Number(/startIndex=(\d+)/.exec(url)?.[1] ?? 0);
66
+ }
67
+
68
+ function limitOf(url: string): number {
69
+ return Number(/limit=(\d+)/.exec(url)?.[1] ?? 100);
70
+ }
71
+
72
+ /**
73
+ * Serves `totalCreated` observations, honouring the startIndex and limit asked for — a page shorter
74
+ * than the limit is what tells the client the search is exhausted, so the mock must not invent one.
75
+ */
76
+ function mockRestApi({ totalCreated = 250, obsPerEncounter = 20 } = {}) {
77
+ mockOpenmrsFetch.mockImplementation((url: string) => {
78
+ if (url.includes('/pihapps/obs?') && url.includes('createdBy=')) {
79
+ const start = startIndexOf(url);
80
+ const count = Math.max(0, Math.min(limitOf(url), totalCreated - start));
81
+ return Promise.resolve({
82
+ data: { results: createdObsPage(start, count, obsPerEncounter) },
83
+ }) as ReturnType<typeof openmrsFetch>;
84
+ }
85
+ if (url.includes('/pihapps/obs?') && url.includes('voidedBy=')) {
86
+ return Promise.resolve({ data: { results: [] } }) as ReturnType<typeof openmrsFetch>;
87
+ }
88
+ if (url.includes('/pihapps/encounterTypeCount')) {
89
+ // deliberately unsorted, and including a type the trail does not use and one nothing uses
90
+ const counts = [
91
+ { encounterType: vitalsType, count: 40 },
92
+ { encounterType: neverRecordedType, count: 0 },
93
+ { encounterType: unusedType, count: 3 },
94
+ { encounterType: consultationType, count: 40 },
95
+ ];
96
+ // the endpoint leaves out unused types only when asked, so the mock does too
97
+ const results = url.includes('onlyUsed=true') ? counts.filter((entry) => entry.count > 0) : counts;
98
+ return Promise.resolve({ data: { results } }) as ReturnType<typeof openmrsFetch>;
99
+ }
100
+ if (url.includes('/obs?encounter=')) {
101
+ return Promise.resolve({
102
+ data: { results: [{ uuid: 'o1', auditInfo: { creator: { uuid: 'user-1' } } }] },
103
+ }) as ReturnType<typeof openmrsFetch>;
104
+ }
105
+ return Promise.resolve({ data: user }) as ReturnType<typeof openmrsFetch>;
106
+ });
107
+ }
108
+
109
+ function renderUserEncounters() {
110
+ const onSelectEncounter = jest.fn();
111
+ const onBackToSearch = jest.fn();
112
+ render(
113
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
114
+ <UserEncounters onBackToSearch={onBackToSearch} onSelectEncounter={onSelectEncounter} userUuid="user-1" />
115
+ </SWRConfig>,
116
+ );
117
+ return { onSelectEncounter, onBackToSearch };
118
+ }
119
+
120
+ function auditRequests() {
121
+ return mockOpenmrsFetch.mock.calls.map(([url]) => url as string).filter((url) => url.includes('/pihapps/obs?'));
122
+ }
123
+
124
+ function dataRows() {
125
+ return screen.getAllByRole('row').filter((row) => row.querySelectorAll('td').length > 0);
126
+ }
127
+
128
+ describe('<UserEncounters />', () => {
129
+ beforeEach(() => {
130
+ stubDateRangePicker();
131
+ mockUseConfig.mockReturnValue({ encountersPageSize: 10 });
132
+ mockRestApi();
133
+ });
134
+
135
+ it('asks the pihapps audit endpoint what the user created and what they voided', async () => {
136
+ renderUserEncounters();
137
+
138
+ await screen.findByText('Encounters 1–10');
139
+ expect(auditRequests().some((url) => url.includes('createdBy=user-1'))).toBe(true);
140
+ expect(auditRequests().some((url) => url.includes('voidedBy=user-1'))).toBe(true);
141
+ });
142
+
143
+ /**
144
+ * The endpoint leaves voided observations out unless asked, so an audit has to ask: without this
145
+ * the voidedBy stream would come back empty and nothing deleted would ever be listed.
146
+ */
147
+ it('asks for voided observations to be included', async () => {
148
+ renderUserEncounters();
149
+
150
+ await screen.findByText('Encounters 1–10');
151
+ expect(auditRequests().every((url) => url.includes('includeVoided=true'))).toBe(true);
152
+ });
153
+
154
+ /**
155
+ * The endpoint sorts nothing unless asked, and each stream wants the column belonging to the
156
+ * action it searched on — ordering both by the same one would bury what the other is about.
157
+ */
158
+ it('orders each stream by the audit action it searched on', async () => {
159
+ renderUserEncounters();
160
+
161
+ await screen.findByText('Encounters 1–10');
162
+ const created = auditRequests().filter((url) => url.includes('createdBy='));
163
+ const voided = auditRequests().filter((url) => url.includes('voidedBy='));
164
+
165
+ expect(created.length).toBeGreaterThan(0);
166
+ expect(voided.length).toBeGreaterThan(0);
167
+ expect(created.every((url) => url.includes('sortBy=dateCreated-desc&sortBy=obsId-desc'))).toBe(true);
168
+ expect(voided.every((url) => url.includes('sortBy=dateVoided-desc&sortBy=obsId-desc'))).toBe(true);
169
+ });
170
+
171
+ it('shows one page of encounters, most recently changed first', async () => {
172
+ renderUserEncounters();
173
+
174
+ await screen.findByText('Encounters 1–10');
175
+ const rows = dataRows();
176
+ expect(rows).toHaveLength(10);
177
+ expect(rows[0]).toHaveTextContent('Patient 0');
178
+ expect(rows[9]).toHaveTextContent('Patient 9');
179
+ });
180
+
181
+ /** The point of the exercise: a first page must not read the account's whole trail. */
182
+ it('reads only as far into the trail as the first page needs', async () => {
183
+ // 5000 observations in all, but 20 per encounter means ~220 place the first eleven encounters
184
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
185
+ renderUserEncounters();
186
+
187
+ await screen.findByText('Encounters 1–10');
188
+ const created = auditRequests().filter((url) => url.includes('createdBy=user-1'));
189
+ expect(created.length).toBeLessThanOrEqual(4);
190
+ expect(startIndexOf(created[created.length - 1])).toBeLessThan(400);
191
+ });
192
+
193
+ it('reads further only when the next page is asked for', async () => {
194
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
195
+ renderUserEncounters();
196
+ await screen.findByText('Encounters 1–10');
197
+ const beforePaging = auditRequests().length;
198
+
199
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
200
+
201
+ expect(await screen.findByText('Encounters 11–20')).toBeInTheDocument();
202
+ expect(auditRequests().length).toBeGreaterThan(beforePaging);
203
+ expect(dataRows()[0]).toHaveTextContent('Patient 10');
204
+ });
205
+
206
+ it('goes back to a page already read without reading again', async () => {
207
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
208
+ renderUserEncounters();
209
+ await screen.findByText('Encounters 1–10');
210
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
211
+ await screen.findByText('Encounters 11–20');
212
+ const afterPaging = auditRequests().length;
213
+
214
+ await userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
215
+
216
+ expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
217
+ expect(auditRequests()).toHaveLength(afterPaging);
218
+ });
219
+
220
+ it('stops offering a next page at the end of the trail', async () => {
221
+ // twelve encounters in all, so the second page is short and there is no third
222
+ mockRestApi({ totalCreated: 12, obsPerEncounter: 1 });
223
+ renderUserEncounters();
224
+
225
+ await screen.findByText('Encounters 1–10');
226
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
227
+
228
+ expect(await screen.findByText('Encounters 11–12')).toBeInTheDocument();
229
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
230
+ });
231
+
232
+ it('counts each row’s observations exactly, from the encounter itself', async () => {
233
+ renderUserEncounters();
234
+
235
+ await screen.findByText('Encounters 1–10');
236
+ const cells = dataRows()[0].querySelectorAll('td');
237
+ expect(cells[5]).toHaveTextContent('1');
238
+ expect(cells[6]).toHaveTextContent('0');
239
+ });
240
+
241
+ /**
242
+ * The range picker is stubbed as a text input read as MM/DD/YYYY–MM/DD/YYYY, which like the real
243
+ * one reports nothing until both ends of the range are complete.
244
+ */
245
+ const septemberRange = '09/01/2026–09/30/2026';
246
+ it('narrows the search on the server when a date range is picked', async () => {
247
+ renderUserEncounters();
248
+ await screen.findByText('Encounters 1–10');
249
+
250
+ await userEvent.clear(screen.getByLabelText('Changed between'));
251
+ await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
252
+
253
+ await waitFor(() =>
254
+ expect(
255
+ auditRequests().some((url) => url.includes('createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30')),
256
+ ).toBe(true),
257
+ );
258
+ // both halves of "touched" are bounded, not just the one, and each against its own column
259
+ const created = auditRequests().filter((url) => url.includes('createdBy=user-1'));
260
+ const voided = auditRequests().filter((url) => url.includes('voidedBy=user-1'));
261
+ expect(created.some((url) => url.includes('createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30'))).toBe(true);
262
+ expect(voided.some((url) => url.includes('voidedOnOrAfter=2026-09-01&voidedOnOrBefore=2026-09-30'))).toBe(true);
263
+ });
264
+
265
+ it('keeps searching everything while only the start of a range has been typed', async () => {
266
+ renderUserEncounters();
267
+ await screen.findByText('Encounters 1–10');
268
+
269
+ await userEvent.type(screen.getByLabelText('Changed between'), '09/01/2026–');
270
+
271
+ expect(screen.getByText('Encounters 1–10')).toBeInTheDocument();
272
+ expect(auditRequests().some((url) => url.includes('createdOnOrAfter=') || url.includes('voidedOnOrAfter='))).toBe(
273
+ false,
274
+ );
275
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
276
+ });
277
+
278
+ it('starts the trail over when the range changes, rather than mixing two searches', async () => {
279
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
280
+ renderUserEncounters();
281
+ await screen.findByText('Encounters 1–10');
282
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
283
+ await screen.findByText('Encounters 11–20');
284
+
285
+ await userEvent.clear(screen.getByLabelText('Changed between'));
286
+ await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
287
+
288
+ // back to the first page, and reading the bounded trail from its start
289
+ expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
290
+ await waitFor(() =>
291
+ expect(
292
+ auditRequests().some((url) => url.includes('createdOnOrAfter=2026-09-01') && url.includes('startIndex=0')),
293
+ ).toBe(true),
294
+ );
295
+ });
296
+
297
+ it('offers to clear the range once one is set, and says when nothing falls in it', async () => {
298
+ renderUserEncounters();
299
+ await screen.findByText('Encounters 1–10');
300
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
301
+
302
+ mockRestApi({ totalCreated: 0, obsPerEncounter: 1 });
303
+ await userEvent.clear(screen.getByLabelText('Changed between'));
304
+ await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
305
+
306
+ expect(await screen.findByText(/did not change any observations matching these filters/i)).toBeInTheDocument();
307
+ await userEvent.click(screen.getByRole('button', { name: /clear filters/i }));
308
+
309
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
310
+ });
311
+
312
+ it('offers every encounter type in use in the system, not just the ones in the trail', async () => {
313
+ renderUserEncounters();
314
+ await screen.findByText('Encounters 1–10');
315
+
316
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
317
+
318
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
319
+ expect(options.map((option) => option.textContent)).toEqual(['Consultation', 'Vitals', 'Zibra Admission']);
320
+ });
321
+
322
+ it('narrows the list to the chosen encounter type', async () => {
323
+ renderUserEncounters();
324
+ await screen.findByText('Encounters 1–10');
325
+ expect(dataRows()[1]).toHaveTextContent('Patient 1');
326
+
327
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
328
+ await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
329
+
330
+ // the fixture alternates types across thirteen encounters, so seven of them are consultations
331
+ await waitFor(() => expect(screen.getByText('Encounters 1–7')).toBeInTheDocument());
332
+ const patients = dataRows().map((row) => row.querySelectorAll('td')[1].textContent);
333
+ expect(patients).toEqual([
334
+ 'Patient 0',
335
+ 'Patient 2',
336
+ 'Patient 4',
337
+ 'Patient 6',
338
+ 'Patient 8',
339
+ 'Patient 10',
340
+ 'Patient 12',
341
+ ]);
342
+ });
343
+
344
+ /** Filling a page of one type takes reading past the encounters of every other type. */
345
+ it('reads further to fill a page of a filtered type', async () => {
346
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
347
+ renderUserEncounters();
348
+ await screen.findByText('Encounters 1–10');
349
+ const beforeFilter = auditRequests().length;
350
+
351
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
352
+ await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
353
+
354
+ await waitFor(() => expect(dataRows()).toHaveLength(10));
355
+ // a full page of one type needed more of the trail than a full page of any type did
356
+ expect(auditRequests().length).toBeGreaterThan(beforeFilter);
357
+ });
358
+
359
+ /**
360
+ * A filter is applied after the trail is read, so rows trickle in. Arming the gate once the first
361
+ * page is up, then filtering, pins the moment a short list is on screen with more still coming.
362
+ */
363
+ it('says more encounters are on the way while it reads further', async () => {
364
+ mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
365
+ const respondNormally = mockOpenmrsFetch.getMockImplementation();
366
+
367
+ let gateArmed = false;
368
+ let releaseTrail: () => void = () => {};
369
+ const gate = new Promise<void>((resolve) => {
370
+ releaseTrail = resolve;
371
+ });
372
+ mockOpenmrsFetch.mockImplementation((url: string, ...rest) => {
373
+ if (gateArmed && url.includes('/pihapps/obs?')) {
374
+ return gate.then(() => respondNormally(url, ...rest)) as ReturnType<typeof openmrsFetch>;
375
+ }
376
+ return respondNormally(url, ...rest);
377
+ });
378
+
379
+ renderUserEncounters();
380
+ await screen.findByText('Encounters 1–10');
381
+ gateArmed = true;
382
+
383
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
384
+ await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
385
+
386
+ // rows are already on screen, and the scan is waiting on the next page of the trail
387
+ expect(await screen.findByText('Looking for more encounters…')).toBeInTheDocument();
388
+ expect(dataRows().length).toBeGreaterThan(0);
389
+
390
+ releaseTrail();
391
+
392
+ // the range caption only returns once the page's exact counts have been read too
393
+ expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
394
+ expect(dataRows()).toHaveLength(10);
395
+ expect(screen.queryByText('Looking for more encounters…')).not.toBeInTheDocument();
396
+ });
397
+
398
+ it('says so when nothing in the trail matches the filters', async () => {
399
+ renderUserEncounters();
400
+ await screen.findByText('Encounters 1–10');
401
+
402
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
403
+ await userEvent.click(await screen.findByRole('option', { name: 'Zibra Admission' }));
404
+
405
+ expect(await screen.findByText(/did not change any observations matching these filters/i)).toBeInTheDocument();
406
+ });
407
+
408
+ it('drills down into the encounter that is clicked', async () => {
409
+ const { onSelectEncounter } = renderUserEncounters();
410
+ await screen.findByText('Encounters 1–10');
411
+
412
+ await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
413
+
414
+ expect(onSelectEncounter).toHaveBeenCalledWith('enc-0');
415
+ });
416
+
417
+ it('goes back to the user search', async () => {
418
+ const { onBackToSearch } = renderUserEncounters();
419
+
420
+ await userEvent.click(screen.getByRole('button', { name: /back to user search/i }));
421
+
422
+ expect(onBackToSearch).toHaveBeenCalled();
423
+ });
424
+
425
+ it('says so when the user has changed nothing', async () => {
426
+ mockRestApi({ totalCreated: 0, obsPerEncounter: 1 });
427
+ renderUserEncounters();
428
+
429
+ expect(await screen.findByText(/has not recorded or deleted any observations/i)).toBeInTheDocument();
430
+ });
431
+ });
@@ -0,0 +1,239 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Button,
5
+ ComboBox,
6
+ DataTableSkeleton,
7
+ InlineLoading,
8
+ InlineNotification,
9
+ Pagination,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableContainer,
14
+ TableHead,
15
+ TableHeader,
16
+ TableRow,
17
+ Tag,
18
+ } from '@carbon/react';
19
+ import { ArrowLeftIcon, ErrorState, OpenmrsDateRangePicker, useConfig } from '@openmrs/esm-framework';
20
+ import { type Config } from '../config-schema';
21
+ import { type OpenmrsResourceRef } from '../types';
22
+ import { formatAuditDatetime } from './audit-format';
23
+ import { type DateRange, fromPickerRange, hasDateRange, toPickerDefault } from './date-range';
24
+ import { useAuditUser, useUsedEncounterTypes, useUserEncounters } from './audit.resource';
25
+ import styles from './audit.scss';
26
+
27
+ interface UserEncountersProps {
28
+ userUuid: string;
29
+ onSelectEncounter(encounterUuid: string): void;
30
+ onBackToSearch(): void;
31
+ }
32
+
33
+ /**
34
+ * The encounters whose observations a user recorded or deleted, as a way into each one's audit
35
+ * trail. Unlike the provider list, this really is "what did this account change" — it is built from
36
+ * the pihapps audit endpoint, which searches observations by their creator and voiding user.
37
+ */
38
+ export default function UserEncounters({ userUuid, onSelectEncounter, onBackToSearch }: UserEncountersProps) {
39
+ const { t } = useTranslation();
40
+ const config = useConfig<Config>();
41
+ const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10);
42
+ const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
43
+ const [dateRange, setDateRange] = useState<DateRange>({});
44
+ // bumped to clear the range picker, which has to be left to hold its own value
45
+ const [datePickerKey, setDatePickerKey] = useState(0);
46
+ const [encounterType, setEncounterType] = useState<OpenmrsResourceRef | null>(null);
47
+ const today = new Date();
48
+
49
+ const { user, error: userError, isLoading: isLoadingUser } = useAuditUser(userUuid);
50
+ const { encounterTypes, isLoading: isLoadingTypes } = useUsedEncounterTypes();
51
+ const {
52
+ activity,
53
+ currentPage,
54
+ goTo,
55
+ firstRowOnPage,
56
+ hasNextPage,
57
+ obsRead,
58
+ stoppedEarly,
59
+ isDiscovering,
60
+ error,
61
+ isLoading,
62
+ } = useUserEncounters(userUuid, pageSize, dateRange, encounterType?.uuid);
63
+
64
+ const hasFilters = hasDateRange(dateRange) || Boolean(encounterType);
65
+
66
+ return (
67
+ <div className={styles.section}>
68
+ <Button className={styles.backButton} kind="ghost" onClick={onBackToSearch} renderIcon={ArrowLeftIcon} size="sm">
69
+ {t('backToUserSearch', 'Back to user search')}
70
+ </Button>
71
+ <div className={styles.contextHeader}>
72
+ <span className={styles.contextTitle}>{user?.person?.display ?? user?.display ?? ''}</span>
73
+ <span className={styles.contextSubtitle}>{user?.username ?? user?.systemId}</span>
74
+ </div>
75
+
76
+ {/*
77
+ The range narrows the trail on the server: the audit endpoint bounds each search against
78
+ its own audit column, so this asks when the account made a change, not when the observation
79
+ it changed was taken.
80
+ */}
81
+ <div className={styles.filters}>
82
+ {/*
83
+ Every type in use rather than only those this account has touched: encounters are
84
+ discovered lazily, so which types the trail holds is not known until all of it is read.
85
+ */}
86
+ <ComboBox
87
+ className={styles.filterControl}
88
+ disabled={isLoadingTypes}
89
+ id="user-encounter-type-filter"
90
+ items={encounterTypes}
91
+ itemToString={(type: OpenmrsResourceRef | null) => type?.display ?? ''}
92
+ onChange={({ selectedItem }: { selectedItem: OpenmrsResourceRef | null }) =>
93
+ setEncounterType(selectedItem ?? null)
94
+ }
95
+ placeholder={t('allEncounterTypes', 'All encounter types')}
96
+ selectedItem={encounterType}
97
+ size="sm"
98
+ titleText={t('encounterType', 'Encounter type')}
99
+ />
100
+ <OpenmrsDateRangePicker
101
+ className={styles.filterControl}
102
+ defaultValue={toPickerDefault(dateRange)}
103
+ id="user-activity-date-range"
104
+ labelText={t('changedBetween', 'Changed between')}
105
+ maxDate={today}
106
+ onChangeRaw={(range) => setDateRange(fromPickerRange(range))}
107
+ key={datePickerKey}
108
+ size="sm"
109
+ />
110
+ {hasFilters ? (
111
+ <Button
112
+ className={styles.clearFilters}
113
+ kind="ghost"
114
+ onClick={() => {
115
+ setDateRange({});
116
+ setDatePickerKey((key) => key + 1);
117
+ setEncounterType(null);
118
+ }}
119
+ size="sm">
120
+ {t('clearFilters', 'Clear filters')}
121
+ </Button>
122
+ ) : null}
123
+ </div>
124
+
125
+ {userError || error ? (
126
+ <ErrorState error={userError ?? error} headerTitle={t('encounters', 'Encounters')} />
127
+ ) : isLoadingUser || isLoading ? (
128
+ <>
129
+ {obsRead > 0 ? (
130
+ <InlineLoading
131
+ description={t('readingUserObservations', 'Read {{obsRead}} observations so far…', { obsRead })}
132
+ />
133
+ ) : null}
134
+ <DataTableSkeleton columnCount={7} compact role="progressbar" showHeader={false} showToolbar={false} />
135
+ </>
136
+ ) : activity.length === 0 ? (
137
+ <p className={styles.emptyState}>
138
+ {hasFilters
139
+ ? t('noUserEncountersMatch', 'This user did not change any observations matching these filters.')
140
+ : t('noUserEncounters', 'This user has not recorded or deleted any observations.')}
141
+ </p>
142
+ ) : (
143
+ <>
144
+ {stoppedEarly ? (
145
+ <InlineNotification
146
+ className={styles.inlineNotification}
147
+ hideCloseButton
148
+ kind="info"
149
+ lowContrast
150
+ subtitle={t(
151
+ 'userScanStoppedEarly',
152
+ 'Stopped after reading {{obsRead}} observation changes without filling the page. Narrow the date range to search further back.',
153
+ { obsRead },
154
+ )}
155
+ title={t('partialUserScan', 'Part of the trail')}
156
+ />
157
+ ) : null}
158
+ <TableContainer className={styles.tableContainer}>
159
+ <Table size="sm" useZebraStyles>
160
+ <TableHead>
161
+ <TableRow>
162
+ <TableHeader>{t('lastChanged', 'Last changed')}</TableHeader>
163
+ <TableHeader>{t('patient', 'Patient')}</TableHeader>
164
+ <TableHeader>{t('encounterType', 'Encounter type')}</TableHeader>
165
+ <TableHeader>{t('encounterDate', 'Encounter date')}</TableHeader>
166
+ <TableHeader>{t('location', 'Location')}</TableHeader>
167
+ <TableHeader>{t('obsRecordedCount', 'Observations recorded')}</TableHeader>
168
+ <TableHeader>{t('obsDeletedCount', 'Observations deleted')}</TableHeader>
169
+ </TableRow>
170
+ </TableHead>
171
+ <TableBody>
172
+ {activity.map(({ encounter, obsCreated, obsVoided, lastActivity }) => (
173
+ <TableRow
174
+ className={styles.clickableRow}
175
+ key={encounter.uuid}
176
+ onClick={() => onSelectEncounter(encounter.uuid)}>
177
+ <TableCell>
178
+ <button
179
+ className={styles.linkButton}
180
+ onClick={(event) => {
181
+ event.stopPropagation();
182
+ onSelectEncounter(encounter.uuid);
183
+ }}
184
+ type="button">
185
+ {formatAuditDatetime(lastActivity)}
186
+ </button>
187
+ </TableCell>
188
+ <TableCell>{encounter.patient?.display}</TableCell>
189
+ <TableCell>
190
+ {encounter.encounterType?.display}
191
+ {encounter.voided ? <Tag type="red">{t('deleted', 'Deleted')}</Tag> : null}
192
+ </TableCell>
193
+ <TableCell>{formatAuditDatetime(encounter.encounterDatetime)}</TableCell>
194
+ <TableCell>{encounter.location?.display}</TableCell>
195
+ <TableCell>{obsCreated}</TableCell>
196
+ <TableCell>{obsVoided}</TableCell>
197
+ </TableRow>
198
+ ))}
199
+ </TableBody>
200
+ </Table>
201
+ </TableContainer>
202
+ {/*
203
+ A filter is applied after the trail has been read, so matching rows arrive as the scan
204
+ goes on. Without this the list looks finished while it is still filling.
205
+ */}
206
+ {isDiscovering ? (
207
+ <InlineLoading
208
+ className={styles.discovering}
209
+ description={t('lookingForMoreEncounters', 'Looking for more encounters…')}
210
+ />
211
+ ) : null}
212
+ {/*
213
+ How many encounters this account has touched cannot be known without reading its whole
214
+ trail, which is the very thing being avoided, so there is no total to page against: the
215
+ pager runs with its page count unknown and says only the range on screen.
216
+ */}
217
+ <Pagination
218
+ isLastPage={!hasNextPage}
219
+ itemText={() =>
220
+ t('encounterRange', 'Encounters {{from}}–{{to}}', {
221
+ from: firstRowOnPage + 1,
222
+ to: firstRowOnPage + activity.length,
223
+ })
224
+ }
225
+ onChange={({ page: nextPage, pageSize: nextPageSize }) => {
226
+ setPageSize(nextPageSize);
227
+ goTo(nextPage);
228
+ }}
229
+ page={currentPage}
230
+ pageSize={pageSize}
231
+ pageSizes={pageSizes}
232
+ pagesUnknown
233
+ size="sm"
234
+ />
235
+ </>
236
+ )}
237
+ </div>
238
+ );
239
+ }