@pih/esm-audit-app 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-lint.log +14 -0
  2. package/.turbo/turbo-test.log +12735 -0
  3. package/.turbo/turbo-typescript.log +1 -0
  4. package/README.md +116 -0
  5. package/dist/115.js +1 -0
  6. package/dist/115.js.map +1 -0
  7. package/dist/117.js +1 -0
  8. package/dist/117.js.map +1 -0
  9. package/dist/240.js +1 -0
  10. package/dist/240.js.map +1 -0
  11. package/dist/349.js +1 -0
  12. package/dist/349.js.map +1 -0
  13. package/dist/446.js +1 -0
  14. package/dist/446.js.map +1 -0
  15. package/dist/455.js +1 -0
  16. package/dist/455.js.map +1 -0
  17. package/dist/466.js +1 -0
  18. package/dist/466.js.map +1 -0
  19. package/dist/508.js +1 -0
  20. package/dist/508.js.map +1 -0
  21. package/dist/61.js +1 -0
  22. package/dist/61.js.map +1 -0
  23. package/dist/689.js +1 -0
  24. package/dist/689.js.map +1 -0
  25. package/dist/711.js +1 -0
  26. package/dist/711.js.map +1 -0
  27. package/dist/712.js +1 -0
  28. package/dist/712.js.map +1 -0
  29. package/dist/771.js +1 -0
  30. package/dist/771.js.map +1 -0
  31. package/dist/859.js +1 -0
  32. package/dist/859.js.map +1 -0
  33. package/dist/912.js +1 -0
  34. package/dist/912.js.map +1 -0
  35. package/dist/913.js +1 -0
  36. package/dist/913.js.map +1 -0
  37. package/dist/94.js +49 -0
  38. package/dist/94.js.map +1 -0
  39. package/dist/989.js +1 -0
  40. package/dist/989.js.map +1 -0
  41. package/dist/main.js +3 -0
  42. package/dist/main.js.map +1 -0
  43. package/dist/pih-esm-audit-app.js +3 -0
  44. package/dist/pih-esm-audit-app.js.buildmanifest.json +628 -0
  45. package/dist/pih-esm-audit-app.js.map +1 -0
  46. package/dist/routes.json +1 -0
  47. package/jest.config.js +3 -0
  48. package/package.json +53 -0
  49. package/rspack.config.js +1 -0
  50. package/src/audit/audit-format.ts +24 -0
  51. package/src/audit/audit.component.tsx +69 -0
  52. package/src/audit/audit.resource.test.tsx +174 -0
  53. package/src/audit/audit.resource.ts +409 -0
  54. package/src/audit/audit.scss +171 -0
  55. package/src/audit/encounter-audit.component.test.tsx +242 -0
  56. package/src/audit/encounter-audit.component.tsx +204 -0
  57. package/src/audit/encounter-filters.component.tsx +75 -0
  58. package/src/audit/encounter-filters.test.ts +119 -0
  59. package/src/audit/encounter-filters.ts +105 -0
  60. package/src/audit/obs-audit-table.component.tsx +181 -0
  61. package/src/audit/obs-audit.test.ts +170 -0
  62. package/src/audit/obs-audit.ts +156 -0
  63. package/src/audit/patient-activity.component.test.tsx +191 -0
  64. package/src/audit/patient-activity.component.tsx +203 -0
  65. package/src/audit/patient-activity.test.ts +155 -0
  66. package/src/audit/patient-activity.ts +156 -0
  67. package/src/audit/patient-encounters.component.tsx +134 -0
  68. package/src/audit/patient-record.component.test.tsx +193 -0
  69. package/src/audit/patient-record.component.tsx +115 -0
  70. package/src/audit/patient-search.component.test.tsx +73 -0
  71. package/src/audit/patient-search.component.tsx +120 -0
  72. package/src/config-schema.ts +36 -0
  73. package/src/dashboard-link.component.tsx +38 -0
  74. package/src/dashboard.meta.ts +11 -0
  75. package/src/declarations.d.tsx +3 -0
  76. package/src/index.ts +33 -0
  77. package/src/root.component.test.tsx +58 -0
  78. package/src/root.component.tsx +16 -0
  79. package/src/routes.json +20 -0
  80. package/src/types.ts +122 -0
  81. package/translations/en.json +89 -0
  82. package/translations/es.json +89 -0
  83. package/translations/fr.json +89 -0
  84. package/tsconfig.json +5 -0
@@ -0,0 +1,242 @@
1
+ import React from 'react';
2
+ import { render, screen } 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, type AuditObs } from '../types';
7
+ import EncounterAudit from './encounter-audit.component';
8
+
9
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
10
+ const mockUseConfig = jest.mocked(useConfig);
11
+
12
+ const encounterCreated = '2026-09-01T11:09:00.000+0000';
13
+ const dayAfterEncounter = '2026-09-02T08:30:00.000+0000';
14
+ const creator = { uuid: 'user-1', display: 'Cos John' };
15
+
16
+ const mockEncounter: AuditEncounter = {
17
+ uuid: 'enc-1',
18
+ display: 'COVID-19 Admission',
19
+ encounterDatetime: encounterCreated,
20
+ voided: false,
21
+ patient: {
22
+ uuid: 'patient-1',
23
+ display: 'Y2AHXV - Dave TestPatient',
24
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
25
+ },
26
+ location: { uuid: 'loc-1', display: 'CDI Klinik Ekstèn Jeneral' },
27
+ form: { uuid: 'form-1', display: 'COVID19 Admission', version: '1.0' },
28
+ encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
29
+ visit: { uuid: 'visit-1', display: 'Clinic or Hospital Visit' },
30
+ encounterProviders: [
31
+ {
32
+ uuid: 'ep-1',
33
+ voided: false,
34
+ provider: { uuid: 'prov-1', display: 'Louidor Jean paul', identifier: 'MCPRPG' },
35
+ encounterRole: { uuid: 'role-1', display: 'Consulting Clinician' },
36
+ },
37
+ ],
38
+ auditInfo: { creator, dateCreated: encounterCreated },
39
+ };
40
+
41
+ const mockObs: Array<AuditObs> = [
42
+ {
43
+ uuid: 'obs-worker',
44
+ concept: { uuid: 'concept-worker', display: 'Health care worker' },
45
+ formFieldPath: 'covid.1-worker',
46
+ value: { uuid: 'concept-yes', display: 'Yes' },
47
+ auditInfo: { creator, dateCreated: encounterCreated },
48
+ },
49
+ {
50
+ uuid: 'obs-pulse-new',
51
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
52
+ formFieldPath: 'covid.2-pulse',
53
+ value: 68,
54
+ previousVersion: { uuid: 'obs-pulse-old', display: 'Pulse: 67' },
55
+ auditInfo: { creator, dateCreated: dayAfterEncounter },
56
+ },
57
+ {
58
+ uuid: 'obs-pulse-old',
59
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
60
+ formFieldPath: 'covid.2-pulse',
61
+ value: 67,
62
+ voided: true,
63
+ auditInfo: {
64
+ creator,
65
+ dateCreated: encounterCreated,
66
+ voidedBy: creator,
67
+ dateVoided: dayAfterEncounter,
68
+ voidReason: 'Corrected the reading',
69
+ },
70
+ },
71
+ {
72
+ uuid: 'obs-systolic',
73
+ concept: { uuid: 'concept-systolic', display: 'Systolic' },
74
+ formFieldPath: 'covid.3-systolic',
75
+ value: 123,
76
+ auditInfo: { creator, dateCreated: dayAfterEncounter },
77
+ },
78
+ {
79
+ uuid: 'obs-symptom-group',
80
+ concept: { uuid: 'concept-symptom-group', display: 'Sign/Symptom construct' },
81
+ formFieldPath: 'covid.4-symptom',
82
+ auditInfo: { creator, dateCreated: encounterCreated },
83
+ },
84
+ {
85
+ uuid: 'obs-symptom',
86
+ concept: { uuid: 'concept-symptom', display: 'Sign/Symptom name' },
87
+ obsGroup: { uuid: 'obs-symptom-group' },
88
+ value: { uuid: 'concept-fever', display: 'Fever' },
89
+ auditInfo: { creator, dateCreated: encounterCreated },
90
+ },
91
+ ];
92
+
93
+ function mockRestApi({ encounter = mockEncounter, obs = mockObs } = {}) {
94
+ mockOpenmrsFetch.mockImplementation((url: string) => {
95
+ if (url.includes('/obs?')) {
96
+ return Promise.resolve({ data: { results: obs } }) as ReturnType<typeof openmrsFetch>;
97
+ }
98
+ return Promise.resolve({ data: encounter }) as ReturnType<typeof openmrsFetch>;
99
+ });
100
+ }
101
+
102
+ function renderEncounterAudit() {
103
+ const onBackToEncounters = jest.fn();
104
+ render(
105
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
106
+ <EncounterAudit encounterUuid="enc-1" onBackToEncounters={onBackToEncounters} />
107
+ </SWRConfig>,
108
+ );
109
+ return onBackToEncounters;
110
+ }
111
+
112
+ function obsRowsFor(conceptName: string): Array<HTMLElement> {
113
+ return screen.getAllByRole('row').filter((row) => row.textContent?.startsWith(conceptName));
114
+ }
115
+
116
+ describe('<EncounterAudit />', () => {
117
+ beforeEach(() => {
118
+ mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' });
119
+ mockRestApi();
120
+ });
121
+
122
+ it('summarises how the encounter was recorded', async () => {
123
+ renderEncounterAudit();
124
+
125
+ expect(await screen.findByText('CDI Klinik Ekstèn Jeneral')).toBeInTheDocument();
126
+ expect(screen.getByRole('link', { name: 'Y2AHXV - Dave TestPatient' })).toHaveAttribute(
127
+ 'href',
128
+ '/openmrs/spa/patient/patient-1/chart',
129
+ );
130
+ expect(screen.getByText('Y2AHXV')).toBeInTheDocument();
131
+ expect(screen.getByText('COVID19 Admission v1.0')).toBeInTheDocument();
132
+ expect(screen.getByText('Clinic or Hospital Visit')).toBeInTheDocument();
133
+ const createdByRow = screen.getAllByRole('row').find((row) => row.textContent?.startsWith('Created by'));
134
+ expect(createdByRow).toHaveTextContent('Cos John');
135
+ });
136
+
137
+ it('lists the providers on the encounter', async () => {
138
+ renderEncounterAudit();
139
+
140
+ const providerRow = (await screen.findAllByRole('row')).find((row) =>
141
+ row.textContent?.includes('Louidor Jean paul'),
142
+ );
143
+ expect(providerRow).toHaveTextContent('Consulting Clinician');
144
+ expect(providerRow).toHaveTextContent('MCPRPG');
145
+ });
146
+
147
+ it('shows an edited obs above the value it replaced', async () => {
148
+ renderEncounterAudit();
149
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
150
+
151
+ const pulseRows = obsRowsFor('Pulse');
152
+ expect(pulseRows).toHaveLength(2);
153
+ expect(pulseRows[0]).toHaveTextContent('68');
154
+ expect(pulseRows[0]).toHaveTextContent('Edited');
155
+ expect(pulseRows[0]).toHaveTextContent('was 67');
156
+ expect(pulseRows[1]).toHaveTextContent('67');
157
+ expect(pulseRows[1]).toHaveTextContent('Previous value');
158
+ expect(pulseRows[1]).toHaveTextContent('Corrected the reading');
159
+ });
160
+
161
+ it('flags an obs recorded after the encounter was created, and leaves the rest unflagged', async () => {
162
+ renderEncounterAudit();
163
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
164
+
165
+ expect(obsRowsFor('Systolic')[0]).toHaveTextContent('Added later');
166
+
167
+ const workerRow = obsRowsFor('Health care worker')[0];
168
+ expect(workerRow).toHaveTextContent('Yes');
169
+ expect(workerRow).not.toHaveTextContent('Added later');
170
+ expect(workerRow).not.toHaveTextContent('Edited');
171
+ });
172
+
173
+ it('nests obs group members under their group', async () => {
174
+ renderEncounterAudit();
175
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
176
+
177
+ expect(obsRowsFor('Sign/Symptom construct')).toHaveLength(1);
178
+ expect(obsRowsFor('Sign/Symptom name')[0]).toHaveTextContent('Fever');
179
+ });
180
+
181
+ it('hides deleted observations on request', async () => {
182
+ renderEncounterAudit();
183
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
184
+ expect(obsRowsFor('Pulse')).toHaveLength(2);
185
+
186
+ await userEvent.click(screen.getByRole('checkbox', { name: /show deleted observations/i }));
187
+
188
+ expect(obsRowsFor('Pulse')).toHaveLength(1);
189
+ expect(obsRowsFor('Pulse')[0]).toHaveTextContent('68');
190
+ });
191
+
192
+ it('shows concept descriptions on request', async () => {
193
+ mockRestApi({
194
+ obs: [
195
+ {
196
+ ...mockObs[0],
197
+ concept: {
198
+ uuid: 'concept-worker',
199
+ display: 'Health care worker',
200
+ descriptions: [{ description: 'Whether the patient works in health care', locale: 'en' }],
201
+ },
202
+ },
203
+ ],
204
+ });
205
+ renderEncounterAudit();
206
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
207
+
208
+ expect(screen.queryByText(/whether the patient works in health care/i)).not.toBeInTheDocument();
209
+
210
+ await userEvent.click(screen.getByRole('checkbox', { name: /show concept descriptions/i }));
211
+
212
+ expect(screen.getByText(/whether the patient works in health care/i)).toBeInTheDocument();
213
+ });
214
+
215
+ it('warns that a deleted encounter is being audited', async () => {
216
+ mockRestApi({
217
+ encounter: {
218
+ ...mockEncounter,
219
+ voided: true,
220
+ auditInfo: {
221
+ ...mockEncounter.auditInfo,
222
+ voidedBy: creator,
223
+ dateVoided: dayAfterEncounter,
224
+ voidReason: 'Entered on the wrong patient',
225
+ },
226
+ },
227
+ });
228
+ renderEncounterAudit();
229
+
230
+ expect(await screen.findByText(/this encounter has been deleted/i)).toBeInTheDocument();
231
+ expect(screen.getByText('Entered on the wrong patient')).toBeInTheDocument();
232
+ });
233
+
234
+ it('walks back up to the patient the encounter belongs to', async () => {
235
+ const onBackToEncounters = renderEncounterAudit();
236
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
237
+
238
+ await userEvent.click(screen.getByRole('button', { name: /back to encounters/i }));
239
+
240
+ expect(onBackToEncounters).toHaveBeenCalledWith('patient-1');
241
+ });
242
+ });
@@ -0,0 +1,204 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Button,
5
+ InlineNotification,
6
+ StructuredListBody,
7
+ StructuredListCell,
8
+ StructuredListRow,
9
+ StructuredListWrapper,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableContainer,
14
+ TableHead,
15
+ TableHeader,
16
+ TableRow,
17
+ SkeletonText,
18
+ Tag,
19
+ } from '@carbon/react';
20
+ import { ArrowLeftIcon, ConfigurableLink, ErrorState, useConfig } from '@openmrs/esm-framework';
21
+ import { type Config } from '../config-schema';
22
+ import { type AuditEncounter } from '../types';
23
+ import { formatAuditDatetime, formatUserAndDate } from './audit-format';
24
+ import { getPreferredIdentifier, useEncounterAudit } from './audit.resource';
25
+ import ObsAuditTable from './obs-audit-table.component';
26
+ import styles from './audit.scss';
27
+
28
+ interface EncounterAuditProps {
29
+ encounterUuid: string;
30
+ /** Called with the encounter's patient, so that a deep link can navigate back up the trail. */
31
+ onBackToEncounters(patientUuid: string | undefined): void;
32
+ }
33
+
34
+ interface SummaryRow {
35
+ label: string;
36
+ value: React.ReactNode;
37
+ }
38
+
39
+ /**
40
+ * Step three of the audit trail: everything known about how one encounter came to be — who created
41
+ * it, who last changed it, whether it has been deleted, and the full history of its observations.
42
+ */
43
+ export default function EncounterAudit({ encounterUuid, onBackToEncounters }: EncounterAuditProps) {
44
+ const { t } = useTranslation();
45
+ const config = useConfig<Config>();
46
+ const { encounter, obsTree, error, isLoading } = useEncounterAudit(encounterUuid);
47
+
48
+ if (error) {
49
+ return (
50
+ <div className={styles.section}>
51
+ <BackButton onClick={() => onBackToEncounters(undefined)} />
52
+ <ErrorState error={error} headerTitle={t('encounterAudit', 'Encounter audit')} />
53
+ </div>
54
+ );
55
+ }
56
+
57
+ if (isLoading) {
58
+ return (
59
+ <div className={styles.section}>
60
+ <BackButton onClick={() => onBackToEncounters(undefined)} />
61
+ <SkeletonText heading paragraph role="progressbar" />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ if (!encounter) {
67
+ return (
68
+ <div className={styles.section}>
69
+ <BackButton onClick={() => onBackToEncounters(undefined)} />
70
+ <p className={styles.emptyState}>{t('encounterNotFound', 'That encounter could not be found.')}</p>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const patientUuid = encounter.patient?.uuid;
76
+ const summaryRows: Array<SummaryRow> = [
77
+ {
78
+ label: t('patient', 'Patient'),
79
+ value: patientUuid ? (
80
+ <ConfigurableLink to={config.patientChartUrl} templateParams={{ patientUuid }}>
81
+ {encounter.patient?.display}
82
+ </ConfigurableLink>
83
+ ) : (
84
+ encounter.patient?.display
85
+ ),
86
+ },
87
+ { label: t('identifier', 'Identifier'), value: getPreferredIdentifier(encounter.patient) },
88
+ { label: t('encounterDate', 'Encounter date'), value: formatAuditDatetime(encounter.encounterDatetime) },
89
+ { label: t('location', 'Location'), value: encounter.location?.display },
90
+ { label: t('visit', 'Visit'), value: encounter.visit?.display },
91
+ { label: t('encounterType', 'Encounter type'), value: encounter.encounterType?.display },
92
+ { label: t('form', 'Form'), value: formatForm(encounter) },
93
+ {
94
+ label: t('createdBy', 'Created by'),
95
+ value: formatUserAndDate(encounter.auditInfo?.creator, encounter.auditInfo?.dateCreated),
96
+ },
97
+ ];
98
+
99
+ if (encounter.auditInfo?.changedBy) {
100
+ summaryRows.push({
101
+ label: t('changedBy', 'Changed by'),
102
+ value: formatUserAndDate(encounter.auditInfo.changedBy, encounter.auditInfo.dateChanged),
103
+ });
104
+ }
105
+
106
+ if (encounter.voided) {
107
+ summaryRows.push({
108
+ label: t('deletedBy', 'Deleted by'),
109
+ value: formatUserAndDate(encounter.auditInfo?.voidedBy, encounter.auditInfo?.dateVoided),
110
+ });
111
+ if (encounter.auditInfo?.voidReason) {
112
+ summaryRows.push({ label: t('deletionReason', 'Reason for deletion'), value: encounter.auditInfo.voidReason });
113
+ }
114
+ }
115
+
116
+ const providers = (encounter.encounterProviders ?? []).filter((encounterProvider) => !encounterProvider.voided);
117
+
118
+ return (
119
+ <div className={styles.section}>
120
+ <BackButton onClick={() => onBackToEncounters(patientUuid)} />
121
+ <div className={styles.contextHeader}>
122
+ <span className={styles.contextTitle}>
123
+ {encounter.encounterType?.display ?? t('encounter', 'Encounter')}
124
+ {encounter.voided ? <Tag type="red">{t('deleted', 'Deleted')}</Tag> : null}
125
+ </span>
126
+ <span className={styles.contextSubtitle}>
127
+ {[encounter.patient?.display, formatAuditDatetime(encounter.encounterDatetime)].filter(Boolean).join(' · ')}
128
+ </span>
129
+ </div>
130
+
131
+ {encounter.voided ? (
132
+ <InlineNotification
133
+ className={styles.inlineNotification}
134
+ hideCloseButton
135
+ kind="warning"
136
+ lowContrast
137
+ subtitle={t(
138
+ 'encounterDeletedSubtitle',
139
+ 'This encounter has been deleted. Its observations are shown as they were left.',
140
+ )}
141
+ title={t('encounterDeleted', 'Deleted encounter')}
142
+ />
143
+ ) : null}
144
+
145
+ <h3 className={styles.sectionHeading}>{t('encounterSummary', 'Encounter summary')}</h3>
146
+ <StructuredListWrapper className={styles.summaryList} isCondensed>
147
+ <StructuredListBody>
148
+ {summaryRows.map((row) => (
149
+ <StructuredListRow key={row.label}>
150
+ <StructuredListCell className={styles.summaryLabel} noWrap>
151
+ {row.label}
152
+ </StructuredListCell>
153
+ <StructuredListCell className={styles.summaryValue}>{row.value}</StructuredListCell>
154
+ </StructuredListRow>
155
+ ))}
156
+ </StructuredListBody>
157
+ </StructuredListWrapper>
158
+
159
+ <h3 className={styles.sectionHeading}>{t('providers', 'Providers')}</h3>
160
+ {providers.length === 0 ? (
161
+ <p className={styles.emptyState}>{t('noProviders', 'No providers are recorded for this encounter.')}</p>
162
+ ) : (
163
+ <TableContainer className={styles.tableContainer}>
164
+ <Table size="sm">
165
+ <TableHead>
166
+ <TableRow>
167
+ <TableHeader>{t('role', 'Role')}</TableHeader>
168
+ <TableHeader>{t('providerName', 'Provider name')}</TableHeader>
169
+ <TableHeader>{t('identifier', 'Identifier')}</TableHeader>
170
+ </TableRow>
171
+ </TableHead>
172
+ <TableBody>
173
+ {providers.map((encounterProvider) => (
174
+ <TableRow key={encounterProvider.uuid}>
175
+ <TableCell>{encounterProvider.encounterRole?.display}</TableCell>
176
+ <TableCell>{encounterProvider.provider?.display}</TableCell>
177
+ <TableCell>{encounterProvider.provider?.identifier}</TableCell>
178
+ </TableRow>
179
+ ))}
180
+ </TableBody>
181
+ </Table>
182
+ </TableContainer>
183
+ )}
184
+
185
+ <ObsAuditTable obsTree={obsTree} />
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function BackButton({ onClick }: { onClick(): void }) {
191
+ const { t } = useTranslation();
192
+ return (
193
+ <Button className={styles.backButton} kind="ghost" onClick={onClick} renderIcon={ArrowLeftIcon} size="sm">
194
+ {t('backToEncounters', 'Back to encounters')}
195
+ </Button>
196
+ );
197
+ }
198
+
199
+ function formatForm(encounter: AuditEncounter): string | undefined {
200
+ if (!encounter.form?.display) {
201
+ return undefined;
202
+ }
203
+ return encounter.form.version ? `${encounter.form.display} v${encounter.form.version}` : encounter.form.display;
204
+ }
@@ -0,0 +1,75 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button, ComboBox } from '@carbon/react';
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';
8
+ import styles from './audit.scss';
9
+
10
+ interface EncounterFiltersBarProps {
11
+ patient: AuditPatient | undefined;
12
+ includeDeleted: boolean;
13
+ filters: EncounterFilters;
14
+ onChange(filters: EncounterFilters): void;
15
+ }
16
+
17
+ /**
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.
20
+ */
21
+ export default function EncounterFiltersBar({ patient, includeDeleted, filters, onChange }: EncounterFiltersBarProps) {
22
+ const { t } = useTranslation();
23
+ const { encounterTypes, isLoading } = usePatientEncounterTypes(patient, includeDeleted);
24
+
25
+ const today = useMemo(() => new Date(), []);
26
+
27
+ /**
28
+ * Hiding deleted encounters again can take the chosen type out of the list. Keeping it on the
29
+ * list means the filter that is in force is still the one shown, and can still be changed.
30
+ */
31
+ const items = useMemo(() => {
32
+ const selected = filters.encounterType;
33
+ if (selected && !encounterTypes.some((encounterType) => encounterType.uuid === selected.uuid)) {
34
+ return [...encounterTypes, selected];
35
+ }
36
+ return encounterTypes;
37
+ }, [encounterTypes, filters.encounterType]);
38
+
39
+ const dateRange = useMemo<[Date | null, Date | null]>(
40
+ () => [fromDateKey(filters.fromDate), fromDateKey(filters.toDate)],
41
+ [filters.fromDate, filters.toDate],
42
+ );
43
+
44
+ return (
45
+ <div className={styles.filters}>
46
+ <ComboBox
47
+ className={styles.filterControl}
48
+ disabled={isLoading || items.length === 0}
49
+ id="encounter-type-filter"
50
+ items={items}
51
+ itemToString={(encounterType: OpenmrsResourceRef | null) => encounterType?.display ?? ''}
52
+ onChange={({ selectedItem }: { selectedItem: OpenmrsResourceRef | null }) =>
53
+ onChange({ ...filters, encounterType: selectedItem ?? undefined })
54
+ }
55
+ placeholder={t('allEncounterTypes', 'All encounter types')}
56
+ selectedItem={filters.encounterType ?? null}
57
+ size="sm"
58
+ titleText={t('encounterType', 'Encounter type')}
59
+ />
60
+ <OpenmrsDateRangePicker
61
+ className={styles.filterControl}
62
+ labelText={t('encounterDateRange', 'Encounter date range')}
63
+ maxDate={today}
64
+ onChange={([from, to]) => onChange({ ...filters, fromDate: toDateKey(from), toDate: toDateKey(to) })}
65
+ size="sm"
66
+ value={dateRange}
67
+ />
68
+ {hasActiveFilters(filters) ? (
69
+ <Button className={styles.clearFilters} kind="ghost" onClick={() => onChange({})} size="sm">
70
+ {t('clearFilters', 'Clear filters')}
71
+ </Button>
72
+ ) : null}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,119 @@
1
+ import { type AuditEncounter } from '../types';
2
+ import {
3
+ buildEncounterFilterQuery,
4
+ distinctEncounterTypes,
5
+ fromDateKey,
6
+ hasActiveFilters,
7
+ matchesEncounterFilters,
8
+ toDateKey,
9
+ } from './encounter-filters';
10
+
11
+ const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' };
12
+ const checkinType = { uuid: 'type-checkin', display: 'Inscription' };
13
+
14
+ const consultation: AuditEncounter = {
15
+ uuid: 'enc-1',
16
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
17
+ encounterType: consultationType,
18
+ };
19
+
20
+ describe('date keys', () => {
21
+ it('reads the calendar date a moment falls on', () => {
22
+ expect(toDateKey(new Date(2026, 3, 18, 9, 0))).toBe('2026-04-18');
23
+ expect(toDateKey(new Date(2026, 0, 5))).toBe('2026-01-05');
24
+ });
25
+
26
+ it('has nothing to say about a missing or unparseable date', () => {
27
+ expect(toDateKey(null)).toBeUndefined();
28
+ expect(toDateKey(undefined)).toBeUndefined();
29
+ expect(toDateKey(new Date('nonsense'))).toBeUndefined();
30
+ });
31
+
32
+ it('round-trips back to local midnight, not to UTC midnight', () => {
33
+ const date = fromDateKey('2026-04-18');
34
+
35
+ expect(date?.getFullYear()).toBe(2026);
36
+ expect(date?.getMonth()).toBe(3);
37
+ expect(date?.getDate()).toBe(18);
38
+ expect(date?.getHours()).toBe(0);
39
+ expect(toDateKey(date)).toBe('2026-04-18');
40
+ });
41
+
42
+ it('has nothing to round-trip when no date is set', () => {
43
+ expect(fromDateKey(undefined)).toBeNull();
44
+ expect(fromDateKey('')).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('hasActiveFilters', () => {
49
+ it('knows when a filter is set', () => {
50
+ expect(hasActiveFilters({})).toBe(false);
51
+ expect(hasActiveFilters({ encounterType: consultationType })).toBe(true);
52
+ expect(hasActiveFilters({ fromDate: '2026-04-01' })).toBe(true);
53
+ expect(hasActiveFilters({ toDate: '2026-04-30' })).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe('buildEncounterFilterQuery', () => {
58
+ it('sends nothing when nothing is filtered', () => {
59
+ expect(buildEncounterFilterQuery({})).toBe('');
60
+ });
61
+
62
+ it('asks the server for one encounter type', () => {
63
+ expect(buildEncounterFilterQuery({ encounterType: consultationType })).toBe('&encounterType=type-consultation');
64
+ });
65
+
66
+ it('covers whole days at both ends of the range, in the server’s own time zone', () => {
67
+ expect(buildEncounterFilterQuery({ fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe(
68
+ '&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59',
69
+ );
70
+ });
71
+ });
72
+
73
+ describe('matchesEncounterFilters', () => {
74
+ it('keeps everything when nothing is filtered', () => {
75
+ expect(matchesEncounterFilters(consultation, {})).toBe(true);
76
+ });
77
+
78
+ it('keeps only the chosen encounter type', () => {
79
+ expect(matchesEncounterFilters(consultation, { encounterType: consultationType })).toBe(true);
80
+ expect(matchesEncounterFilters(consultation, { encounterType: checkinType })).toBe(false);
81
+ });
82
+
83
+ it('treats both ends of the date range as inclusive', () => {
84
+ const day = toDateKey(new Date(consultation.encounterDatetime));
85
+
86
+ expect(matchesEncounterFilters(consultation, { fromDate: day, toDate: day })).toBe(true);
87
+ expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe(true);
88
+ expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-19' })).toBe(false);
89
+ expect(matchesEncounterFilters(consultation, { toDate: '2026-04-17' })).toBe(false);
90
+ });
91
+
92
+ it('requires every filter to match', () => {
93
+ expect(matchesEncounterFilters(consultation, { encounterType: consultationType, fromDate: '2026-04-19' })).toBe(
94
+ false,
95
+ );
96
+ });
97
+
98
+ it('excludes an encounter with no date once a date range is set', () => {
99
+ expect(matchesEncounterFilters({ uuid: 'enc-2' }, { fromDate: '2026-04-01' })).toBe(false);
100
+ expect(matchesEncounterFilters({ uuid: 'enc-2' }, {})).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe('distinctEncounterTypes', () => {
105
+ it('collects each type a patient has once, sorted by name', () => {
106
+ expect(
107
+ distinctEncounterTypes([
108
+ { uuid: 'enc-1', encounterType: consultationType },
109
+ { uuid: 'enc-2', encounterType: checkinType },
110
+ { uuid: 'enc-3', encounterType: { ...consultationType } },
111
+ ]),
112
+ ).toEqual([checkinType, consultationType]);
113
+ });
114
+
115
+ it('skips encounters with no type, and copes with none at all', () => {
116
+ expect(distinctEncounterTypes([{ uuid: 'enc-1' }])).toEqual([]);
117
+ expect(distinctEncounterTypes([])).toEqual([]);
118
+ });
119
+ });