@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,120 @@
1
+ /**
2
+ * A range of calendar dates, held as `YYYY-MM-DD` rather than as instants because that is what
3
+ * someone picking a date means. Both ends are inclusive.
4
+ */
5
+ export interface DateRange {
6
+ fromDate?: string;
7
+ toDate?: string;
8
+ }
9
+
10
+ function pad(value: number): string {
11
+ return String(value).padStart(2, '0');
12
+ }
13
+
14
+ /** The calendar date a `Date` falls on, in the browser's time zone. */
15
+ export function toDateKey(date: Date | null | undefined): string | undefined {
16
+ if (!date || Number.isNaN(date.getTime())) {
17
+ return undefined;
18
+ }
19
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
20
+ }
21
+
22
+ /** Local midnight on a calendar date, for handing a stored range back to a date picker. */
23
+ export function fromDateKey(key: string | undefined): Date | null {
24
+ if (!key) {
25
+ return null;
26
+ }
27
+ const [year, month, day] = key.split('-').map(Number);
28
+ if (!year || !month || !day) {
29
+ return null;
30
+ }
31
+ return new Date(year, month - 1, day);
32
+ }
33
+
34
+ /** The part of a react-aria date value `fromPickerRange` reads, whatever calendar it is in. */
35
+ interface PickerDate {
36
+ toDate(timeZone: string): Date;
37
+ }
38
+
39
+ /**
40
+ * The earliest year the picker accepts; it is the framework's floor for a date picker with no
41
+ * `minDate`, which the picker flags as out of range but reports all the same.
42
+ */
43
+ const EARLIEST_PICKER_YEAR = 1793;
44
+
45
+ /**
46
+ * The range an `OpenmrsDateRangePicker` reports through `onChangeRaw`.
47
+ *
48
+ * Its `onChange` cannot be used: react-aria reports `null` until both ends of the range are
49
+ * complete, which is on every keystroke while typing the first date, and the framework's `onChange`
50
+ * reads `start` off that `null` and throws. An incomplete range is treated as no range at all.
51
+ *
52
+ * So is one the picker would reject. Once the first date is in, react-aria reports a complete range
53
+ * on every keystroke of the last year typed, as years 2, 20 and 202 on the way to 2026, and those
54
+ * are no more the range someone means than a half-typed one is.
55
+ */
56
+ export function fromPickerRange(
57
+ range: { start?: PickerDate | null; end?: PickerDate | null } | null | undefined,
58
+ ): DateRange {
59
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
60
+ const start = range?.start?.toDate(timeZone);
61
+ const end = range?.end?.toDate(timeZone);
62
+ if (!start || !end || start > end || start.getFullYear() < EARLIEST_PICKER_YEAR) {
63
+ return {};
64
+ }
65
+ return { fromDate: toDateKey(start), toDate: toDateKey(end) };
66
+ }
67
+
68
+ /**
69
+ * What to seed an `OpenmrsDateRangePicker` with, as its `defaultValue`: nothing at all when no range
70
+ * is set.
71
+ *
72
+ * The picker must be left uncontrolled. React-aria keeps a half-typed range to one side, and only
73
+ * shows it while the picker's value is empty; the framework hands an empty `value` on as a range with
74
+ * two empty ends rather than as no range, which hides it, so the day and month typed so far are
75
+ * wiped the moment the year completes the first date. Remount the picker (with a `key`) to clear it.
76
+ */
77
+ export function toPickerDefault(range: DateRange): [Date | null, Date | null] | undefined {
78
+ return hasDateRange(range) ? [fromDateKey(range.fromDate), fromDateKey(range.toDate)] : undefined;
79
+ }
80
+
81
+ export function hasDateRange(range: DateRange): boolean {
82
+ return Boolean(range.fromDate || range.toDate);
83
+ }
84
+
85
+ /**
86
+ * What each pihapps audit endpoint calls its date bounds. Both take a separate range per column,
87
+ * so a caller names the one it means: the obs streams bound the action each searched on, and the
88
+ * provider view is asking when encounters happened, hence `encounterDatetime`.
89
+ */
90
+ export const OBS_CREATED_DATE_PARAMS = { from: 'createdOnOrAfter', to: 'createdOnOrBefore' } as const;
91
+ export const OBS_VOIDED_DATE_PARAMS = { from: 'voidedOnOrAfter', to: 'voidedOnOrBefore' } as const;
92
+ export const ENCOUNTER_DATE_PARAMS = {
93
+ from: 'encounterDatetimeOnOrAfter',
94
+ to: 'encounterDatetimeOnOrBefore',
95
+ } as const;
96
+
97
+ interface DateParamNames {
98
+ from: string;
99
+ to: string;
100
+ }
101
+
102
+ /**
103
+ * The date query one of those endpoints understands. `params` names its two bounds; there is no
104
+ * default, since no two of them agree any more.
105
+ *
106
+ * Bare dates are sent deliberately: those endpoints read a date-only upper bound as the whole of
107
+ * that day, so there is no need to spell out a time to avoid an inclusive bound matching only
108
+ * midnight. They read both ends against whatever the search is about: the audit action where one is
109
+ * named, and the encounter's own datetime for a provider search.
110
+ */
111
+ export function buildAuditDateQuery(range: DateRange, params: DateParamNames): string {
112
+ let query = '';
113
+ if (range.fromDate) {
114
+ query += `&${params.from}=${range.fromDate}`;
115
+ }
116
+ if (range.toDate) {
117
+ query += `&${params.to}=${range.toDate}`;
118
+ }
119
+ return query;
120
+ }
@@ -1,26 +1,34 @@
1
- import React, { useMemo } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Button, ComboBox } from '@carbon/react';
4
4
  import { OpenmrsDateRangePicker } from '@openmrs/esm-framework';
5
- import { type AuditPatient, type OpenmrsResourceRef } from '../types';
6
- import { usePatientEncounterTypes } from './audit.resource';
7
- import { type EncounterFilters, fromDateKey, hasActiveFilters, toDateKey } from './encounter-filters';
5
+ import { type OpenmrsResourceRef } from '../types';
6
+ import { fromPickerRange, toPickerDefault } from './date-range';
7
+ import { type EncounterFilters, hasActiveFilters } from './encounter-filters';
8
8
  import styles from './audit.scss';
9
9
 
10
10
  interface EncounterFiltersBarProps {
11
- patient: AuditPatient | undefined;
12
- includeDeleted: boolean;
11
+ /**
12
+ * The types to offer. Supplied rather than fetched, because the right set depends on the list
13
+ * being filtered: a patient's own types where they can be known, every type in the system where
14
+ * they cannot.
15
+ */
16
+ encounterTypes: Array<OpenmrsResourceRef>;
17
+ isLoadingTypes: boolean;
13
18
  filters: EncounterFilters;
14
19
  onChange(filters: EncounterFilters): void;
15
20
  }
16
21
 
17
22
  /**
18
- * Narrows a patient's encounter list by encounter type and by the date the encounter happened.
19
- * The types on offer are the ones this patient's encounters actually use.
23
+ * Narrows an encounter list by encounter type and by the date the encounter happened.
20
24
  */
21
- export default function EncounterFiltersBar({ patient, includeDeleted, filters, onChange }: EncounterFiltersBarProps) {
25
+ export default function EncounterFiltersBar({
26
+ encounterTypes,
27
+ isLoadingTypes,
28
+ filters,
29
+ onChange,
30
+ }: EncounterFiltersBarProps) {
22
31
  const { t } = useTranslation();
23
- const { encounterTypes, isLoading } = usePatientEncounterTypes(patient, includeDeleted);
24
32
 
25
33
  const today = useMemo(() => new Date(), []);
26
34
 
@@ -36,16 +44,14 @@ export default function EncounterFiltersBar({ patient, includeDeleted, filters,
36
44
  return encounterTypes;
37
45
  }, [encounterTypes, filters.encounterType]);
38
46
 
39
- const dateRange = useMemo<[Date | null, Date | null]>(
40
- () => [fromDateKey(filters.fromDate), fromDateKey(filters.toDate)],
41
- [filters.fromDate, filters.toDate],
42
- );
47
+ // bumped to clear the range picker, which has to be left to hold its own value
48
+ const [datePickerKey, setDatePickerKey] = useState(0);
43
49
 
44
50
  return (
45
51
  <div className={styles.filters}>
46
52
  <ComboBox
47
53
  className={styles.filterControl}
48
- disabled={isLoading || items.length === 0}
54
+ disabled={isLoadingTypes || items.length === 0}
49
55
  id="encounter-type-filter"
50
56
  items={items}
51
57
  itemToString={(encounterType: OpenmrsResourceRef | null) => encounterType?.display ?? ''}
@@ -59,14 +65,30 @@ export default function EncounterFiltersBar({ patient, includeDeleted, filters,
59
65
  />
60
66
  <OpenmrsDateRangePicker
61
67
  className={styles.filterControl}
68
+ defaultValue={toPickerDefault(filters)}
69
+ id="encounter-date-range-filter"
62
70
  labelText={t('encounterDateRange', 'Encounter date range')}
63
71
  maxDate={today}
64
- onChange={([from, to]) => onChange({ ...filters, fromDate: toDateKey(from), toDate: toDateKey(to) })}
72
+ onChangeRaw={(range) => {
73
+ const { fromDate, toDate } = fromPickerRange(range);
74
+ // the picker reports on keystrokes that leave the range as it was, and a new filters object
75
+ // would send the list back to its first page for nothing
76
+ if (fromDate !== filters.fromDate || toDate !== filters.toDate) {
77
+ onChange({ ...filters, fromDate, toDate });
78
+ }
79
+ }}
80
+ key={datePickerKey}
65
81
  size="sm"
66
- value={dateRange}
67
82
  />
68
83
  {hasActiveFilters(filters) ? (
69
- <Button className={styles.clearFilters} kind="ghost" onClick={() => onChange({})} size="sm">
84
+ <Button
85
+ className={styles.clearFilters}
86
+ kind="ghost"
87
+ onClick={() => {
88
+ setDatePickerKey((key) => key + 1);
89
+ onChange({});
90
+ }}
91
+ size="sm">
70
92
  {t('clearFilters', 'Clear filters')}
71
93
  </Button>
72
94
  ) : null}
@@ -1,11 +1,10 @@
1
1
  import { type AuditEncounter } from '../types';
2
+ import { fromDateKey, toDateKey } from './date-range';
2
3
  import {
3
4
  buildEncounterFilterQuery,
4
5
  distinctEncounterTypes,
5
- fromDateKey,
6
6
  hasActiveFilters,
7
7
  matchesEncounterFilters,
8
- toDateKey,
9
8
  } from './encounter-filters';
10
9
 
11
10
  const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' };
@@ -1,19 +1,18 @@
1
1
  import { type AuditEncounter, type OpenmrsResourceRef } from '../types';
2
+ import { type DateRange, toDateKey } from './date-range';
2
3
 
3
4
  /**
4
5
  * The filters an auditor can narrow a patient's encounter list by. Dates are held as calendar
5
6
  * dates (`YYYY-MM-DD`) rather than instants, because that is what someone picking a date means,
6
7
  * and both bounds are inclusive.
7
8
  */
8
- export interface EncounterFilters {
9
+ export interface EncounterFilters extends DateRange {
9
10
  /**
10
11
  * The whole reference rather than just the uuid, so that the chosen type stays displayable even
11
12
  * if it drops out of the available options — which happens when deleted encounters are hidden
12
13
  * again and the type only occurred on those.
13
14
  */
14
15
  encounterType?: OpenmrsResourceRef;
15
- fromDate?: string;
16
- toDate?: string;
17
16
  }
18
17
 
19
18
  /**
@@ -31,30 +30,6 @@ export function distinctEncounterTypes(encounters: Array<AuditEncounter>): Array
31
30
  return Array.from(byUuid.values()).sort((a, b) => (a.display ?? '').localeCompare(b.display ?? ''));
32
31
  }
33
32
 
34
- function pad(value: number): string {
35
- return String(value).padStart(2, '0');
36
- }
37
-
38
- /** The calendar date a `Date` falls on, in the browser's time zone. */
39
- export function toDateKey(date: Date | null | undefined): string | undefined {
40
- if (!date || Number.isNaN(date.getTime())) {
41
- return undefined;
42
- }
43
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
44
- }
45
-
46
- /** Local midnight on a calendar date, for handing a stored filter back to the date picker. */
47
- export function fromDateKey(key: string | undefined): Date | null {
48
- if (!key) {
49
- return null;
50
- }
51
- const [year, month, day] = key.split('-').map(Number);
52
- if (!year || !month || !day) {
53
- return null;
54
- }
55
- return new Date(year, month - 1, day);
56
- }
57
-
58
33
  export function hasActiveFilters(filters: EncounterFilters): boolean {
59
34
  return Boolean(filters.encounterType || filters.fromDate || filters.toDate);
60
35
  }
@@ -2,12 +2,12 @@ import React from 'react';
2
2
  import { render, screen, waitFor, within } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { SWRConfig } from 'swr';
5
- import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
5
+ import { openmrsFetch } from '@openmrs/esm-framework';
6
6
  import { type AuditEncounter, type AuditPatient } from '../types';
7
7
  import PatientRecord from './patient-record.component';
8
+ import { stubDateRangePicker } from './date-range-picker.test-stub';
8
9
 
9
10
  const mockOpenmrsFetch = jest.mocked(openmrsFetch);
10
- const mockUseConfig = jest.mocked(useConfig);
11
11
 
12
12
  const mockPatient: AuditPatient = {
13
13
  uuid: 'patient-1',
@@ -93,9 +93,7 @@ function renderPatientRecord() {
93
93
 
94
94
  describe('<PatientRecord />', () => {
95
95
  beforeEach(() => {
96
- mockUseConfig.mockReturnValue({
97
- patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart',
98
- });
96
+ stubDateRangePicker();
99
97
  mockRestApi();
100
98
  });
101
99
 
@@ -2,7 +2,12 @@ import React, { useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Button, Checkbox, InlineNotification, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
4
4
  import { ArrowLeftIcon, ErrorState } from '@openmrs/esm-framework';
5
- import { canListDeletedEncounters, getPreferredIdentifier, useAuditPatient } from './audit.resource';
5
+ import {
6
+ canListDeletedEncounters,
7
+ getPreferredIdentifier,
8
+ useAuditPatient,
9
+ usePatientEncounterTypes,
10
+ } from './audit.resource';
6
11
  import { type EncounterFilters } from './encounter-filters';
7
12
  import EncounterFiltersBar from './encounter-filters.component';
8
13
  import PatientActivity from './patient-activity.component';
@@ -38,6 +43,7 @@ export default function PatientRecord({
38
43
  const [filters, setFilters] = useState<EncounterFilters>({});
39
44
 
40
45
  const { patient, error: patientError, isLoading: isLoadingPatient } = useAuditPatient(patientUuid);
46
+ const { encounterTypes, isLoading: isLoadingTypes } = usePatientEncounterTypes(patient, includeDeleted);
41
47
  const cannotIncludeDeleted = includeDeleted && Boolean(patient) && !canListDeletedEncounters(patient);
42
48
 
43
49
  return (
@@ -54,7 +60,12 @@ export default function PatientRecord({
54
60
  </span>
55
61
  </div>
56
62
 
57
- <EncounterFiltersBar filters={filters} includeDeleted={includeDeleted} onChange={setFilters} patient={patient} />
63
+ <EncounterFiltersBar
64
+ encounterTypes={encounterTypes}
65
+ filters={filters}
66
+ isLoadingTypes={isLoadingTypes}
67
+ onChange={setFilters}
68
+ />
58
69
 
59
70
  <div className={styles.toolbar}>
60
71
  <Checkbox
@@ -42,7 +42,7 @@ export default function PatientSearch({ onSelectPatient }: PatientSearchProps) {
42
42
  }, [debouncedSearchTerm]);
43
43
 
44
44
  return (
45
- <div className={styles.section}>
45
+ <div>
46
46
  <Search
47
47
  className={styles.searchInput}
48
48
  labelText={t('searchForPatient', 'Search for a patient by name or identifier')}
@@ -0,0 +1,254 @@
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 { type AuditEncounter } from '../types';
7
+ import ProviderEncounters from './provider-encounters.component';
8
+ import { stubDateRangePicker } from './date-range-picker.test-stub';
9
+
10
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
11
+ const mockUseConfig = jest.mocked(useConfig);
12
+
13
+ const provider = {
14
+ uuid: 'prov-1',
15
+ display: 'MCPRPG - Louidor Jean paul',
16
+ identifier: 'MCPRPG',
17
+ person: { uuid: 'person-1', display: 'Louidor Jean paul' },
18
+ };
19
+
20
+ /**
21
+ * What the pihapps audit endpoint answers: whole encounters, paged, including the auditInfo that
22
+ * names who entered each one. No second read per row, and no FHIR.
23
+ */
24
+ const encounters: Array<AuditEncounter> = [
25
+ {
26
+ uuid: 'enc-1',
27
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
28
+ patient: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient' },
29
+ encounterType: { uuid: 'type-1', display: 'Oncology Consultation' },
30
+ form: { uuid: 'form-1', display: 'Oncology Consult Note' },
31
+ location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
32
+ auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: '2026-04-18T09:05:00.000+0000' },
33
+ },
34
+ {
35
+ uuid: 'enc-2',
36
+ voided: true,
37
+ encounterDatetime: '2026-04-11T08:00:00.000+0000',
38
+ patient: { uuid: 'patient-2', display: 'Y2BQRS - Jean Baptiste' },
39
+ encounterType: { uuid: 'type-2', display: 'Inscription' },
40
+ auditInfo: { creator: { uuid: 'user-2', display: 'data.clerk' }, dateCreated: '2026-04-11T08:02:00.000+0000' },
41
+ },
42
+ ];
43
+
44
+ const encounterTypeCounts = [
45
+ { encounterType: { uuid: 'type-2', display: 'Inscription' }, count: 12 },
46
+ { encounterType: { uuid: 'type-3', display: 'Never Recorded' }, count: 0 },
47
+ { encounterType: { uuid: 'type-1', display: 'Oncology Consultation' }, count: 30 },
48
+ ];
49
+
50
+ function mockRestApi({ results = encounters } = {}) {
51
+ mockOpenmrsFetch.mockImplementation((url: string) => {
52
+ if (url.includes('/pihapps/encounterTypeCount')) {
53
+ // the endpoint leaves out unused types only when asked, so the mock does too
54
+ const results = url.includes('onlyUsed=true')
55
+ ? encounterTypeCounts.filter((entry) => entry.count > 0)
56
+ : encounterTypeCounts;
57
+ return Promise.resolve({ data: { results } }) as ReturnType<typeof openmrsFetch>;
58
+ }
59
+ if (url.includes('/pihapps/encounter?')) {
60
+ return Promise.resolve({ data: { results, totalCount: results.length } }) as ReturnType<typeof openmrsFetch>;
61
+ }
62
+ return Promise.resolve({ data: provider }) as ReturnType<typeof openmrsFetch>;
63
+ });
64
+ }
65
+
66
+ function renderProviderEncounters() {
67
+ const onSelectEncounter = jest.fn();
68
+ const onBackToSearch = jest.fn();
69
+ render(
70
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
71
+ <ProviderEncounters onBackToSearch={onBackToSearch} onSelectEncounter={onSelectEncounter} providerUuid="prov-1" />
72
+ </SWRConfig>,
73
+ );
74
+ return { onSelectEncounter, onBackToSearch };
75
+ }
76
+
77
+ describe('<ProviderEncounters />', () => {
78
+ beforeEach(() => {
79
+ stubDateRangePicker();
80
+ mockUseConfig.mockReturnValue({ encountersPageSize: 10 });
81
+ mockRestApi();
82
+ });
83
+
84
+ /**
85
+ * The most recent audit search, decoded — `useOpenmrsPagination` rebuilds the url through `URL`.
86
+ * The most recent one matters because a filter change issues another.
87
+ */
88
+ function auditSearchUrl() {
89
+ const urls = mockOpenmrsFetch.mock.calls
90
+ .map(([url]) => url as string)
91
+ .filter((url) => url.includes('/pihapps/encounter?'));
92
+ return decodeURIComponent(urls[urls.length - 1] ?? '');
93
+ }
94
+
95
+ it('asks the pihapps audit endpoint which encounters name the provider', async () => {
96
+ renderProviderEncounters();
97
+
98
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
99
+ expect(auditSearchUrl()).toContain('/ws/rest/v1/pihapps/encounter?provider=prov-1');
100
+ expect(auditSearchUrl()).toContain('&limit=10&startIndex=0&totalCount=true');
101
+ });
102
+
103
+ /**
104
+ * The endpoint leaves voided encounters out unless asked, so an audit has to ask: without this
105
+ * the table would quietly stop showing what had been deleted.
106
+ */
107
+ it('asks for voided encounters to be included', async () => {
108
+ renderProviderEncounters();
109
+
110
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
111
+ expect(auditSearchUrl()).toContain('includeVoided=true');
112
+ });
113
+
114
+ /**
115
+ * The endpoint sorts nothing unless asked, so a page without an ordering could repeat or skip
116
+ * rows. A provider search names no audit action, hence the encounter's own datetime.
117
+ */
118
+ it('asks for the ordering a provider search wants', async () => {
119
+ renderProviderEncounters();
120
+
121
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
122
+ expect(auditSearchUrl()).toContain('sortBy=encounterDatetime-desc&sortBy=encounterId-desc');
123
+ });
124
+
125
+ /**
126
+ * One paged request per page: the endpoint returns whole encounters with their auditInfo, so
127
+ * nothing further is read per row.
128
+ */
129
+ it('reads the page in a single request', async () => {
130
+ renderProviderEncounters();
131
+
132
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
133
+ expect(mockOpenmrsFetch.mock.calls.filter(([url]) => (url as string).includes('/pihapps/encounter?'))).toHaveLength(
134
+ 1,
135
+ );
136
+ expect(mockOpenmrsFetch.mock.calls.some(([url]) => (url as string).includes('/encounter/'))).toBe(false);
137
+ });
138
+
139
+ it('lists them across patients, naming who entered each one', async () => {
140
+ renderProviderEncounters();
141
+
142
+ // The skeleton renders rows of its own, so wait for real content before reading the table.
143
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
144
+
145
+ const firstRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('Dave TestPatient'));
146
+ expect(firstRow).toHaveTextContent('Oncology Consultation');
147
+ expect(firstRow).toHaveTextContent('Klinik Ekstèn');
148
+ expect(firstRow).toHaveTextContent('Cos John');
149
+
150
+ const secondRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('Jean Baptiste'));
151
+ expect(secondRow).toHaveTextContent('data.clerk');
152
+ expect(secondRow).toHaveTextContent('Deleted');
153
+ });
154
+
155
+ it('is explicit that this is not the same as who changed the record', async () => {
156
+ renderProviderEncounters();
157
+
158
+ expect(await screen.findByText(/not the same as the ones they entered or changed/i)).toBeInTheDocument();
159
+ });
160
+
161
+ it('offers only the encounter types something has been recorded against', async () => {
162
+ renderProviderEncounters();
163
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
164
+
165
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
166
+
167
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
168
+ expect(options.map((option) => option.textContent)).toEqual(['Inscription', 'Oncology Consultation']);
169
+ });
170
+
171
+ it('counts deleted encounters towards their type, as the rest of the audit does', async () => {
172
+ renderProviderEncounters();
173
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
174
+
175
+ const countUrls = mockOpenmrsFetch.mock.calls
176
+ .map(([url]) => String(url))
177
+ .filter((url) => url.includes('/pihapps/encounterTypeCount'));
178
+ expect(countUrls.length).toBeGreaterThan(0);
179
+ expect(countUrls.every((url) => url.includes('includeVoided=true'))).toBe(true);
180
+ expect(countUrls.every((url) => url.includes('onlyUsed=true'))).toBe(true);
181
+ });
182
+
183
+ it('narrows the search on the server by encounter type', async () => {
184
+ renderProviderEncounters();
185
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
186
+
187
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
188
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
189
+
190
+ await waitFor(() => expect(auditSearchUrl()).toContain('&encounterType=type-2'));
191
+ // the page goes back to the start, since a different filter is a different set of pages
192
+ expect(auditSearchUrl()).toContain('&startIndex=0');
193
+ });
194
+
195
+ it('narrows the search on the server by encounter date', async () => {
196
+ renderProviderEncounters();
197
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
198
+
199
+ // the stubbed range picker reads its input as MM/DD/YYYY, hence the American-looking dates
200
+ await userEvent.clear(screen.getByLabelText('Encounter date range'));
201
+ await userEvent.type(screen.getByLabelText('Encounter date range'), '04/01/2026–04/30/2026');
202
+
203
+ await waitFor(() =>
204
+ expect(auditSearchUrl()).toContain(
205
+ 'encounterDatetimeOnOrAfter=2026-04-01&encounterDatetimeOnOrBefore=2026-04-30',
206
+ ),
207
+ );
208
+ });
209
+
210
+ it('keeps the search unbounded while only the start of a range has been typed', async () => {
211
+ renderProviderEncounters();
212
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
213
+
214
+ await userEvent.type(screen.getByLabelText('Encounter date range'), '04/01/2026–');
215
+
216
+ expect(screen.getByRole('cell', { name: 'Oncology Consultation' })).toBeInTheDocument();
217
+ expect(auditSearchUrl()).not.toContain('encounterDatetimeOnOrAfter');
218
+ });
219
+
220
+ it('says so when nothing matches the filters', async () => {
221
+ renderProviderEncounters();
222
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
223
+
224
+ mockRestApi({ results: [] });
225
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
226
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
227
+
228
+ expect(await screen.findByText(/no encounters for this provider match these filters/i)).toBeInTheDocument();
229
+ });
230
+
231
+ it('drills down into the encounter that is clicked', async () => {
232
+ const { onSelectEncounter } = renderProviderEncounters();
233
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
234
+
235
+ await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
236
+
237
+ expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
238
+ });
239
+
240
+ it('goes back to the provider search', async () => {
241
+ const { onBackToSearch } = renderProviderEncounters();
242
+
243
+ await userEvent.click(screen.getByRole('button', { name: /back to provider search/i }));
244
+
245
+ expect(onBackToSearch).toHaveBeenCalled();
246
+ });
247
+
248
+ it('says so when the provider is on no encounters', async () => {
249
+ mockRestApi({ results: [] });
250
+ renderProviderEncounters();
251
+
252
+ expect(await screen.findByText(/not recorded on any encounters/i)).toBeInTheDocument();
253
+ });
254
+ });