@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.
- package/.turbo/turbo-lint.log +14 -0
- package/.turbo/turbo-test.log +12735 -0
- package/.turbo/turbo-typescript.log +1 -0
- package/README.md +116 -0
- package/dist/115.js +1 -0
- package/dist/115.js.map +1 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/240.js +1 -0
- package/dist/240.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/446.js +1 -0
- package/dist/446.js.map +1 -0
- package/dist/455.js +1 -0
- package/dist/455.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/711.js +1 -0
- package/dist/711.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/859.js +1 -0
- package/dist/859.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/94.js +49 -0
- package/dist/94.js.map +1 -0
- package/dist/989.js +1 -0
- package/dist/989.js.map +1 -0
- package/dist/main.js +3 -0
- package/dist/main.js.map +1 -0
- package/dist/pih-esm-audit-app.js +3 -0
- package/dist/pih-esm-audit-app.js.buildmanifest.json +628 -0
- package/dist/pih-esm-audit-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +53 -0
- package/rspack.config.js +1 -0
- package/src/audit/audit-format.ts +24 -0
- package/src/audit/audit.component.tsx +69 -0
- package/src/audit/audit.resource.test.tsx +174 -0
- package/src/audit/audit.resource.ts +409 -0
- package/src/audit/audit.scss +171 -0
- package/src/audit/encounter-audit.component.test.tsx +242 -0
- package/src/audit/encounter-audit.component.tsx +204 -0
- package/src/audit/encounter-filters.component.tsx +75 -0
- package/src/audit/encounter-filters.test.ts +119 -0
- package/src/audit/encounter-filters.ts +105 -0
- package/src/audit/obs-audit-table.component.tsx +181 -0
- package/src/audit/obs-audit.test.ts +170 -0
- package/src/audit/obs-audit.ts +156 -0
- package/src/audit/patient-activity.component.test.tsx +191 -0
- package/src/audit/patient-activity.component.tsx +203 -0
- package/src/audit/patient-activity.test.ts +155 -0
- package/src/audit/patient-activity.ts +156 -0
- package/src/audit/patient-encounters.component.tsx +134 -0
- package/src/audit/patient-record.component.test.tsx +193 -0
- package/src/audit/patient-record.component.tsx +115 -0
- package/src/audit/patient-search.component.test.tsx +73 -0
- package/src/audit/patient-search.component.tsx +120 -0
- package/src/config-schema.ts +36 -0
- package/src/dashboard-link.component.tsx +38 -0
- package/src/dashboard.meta.ts +11 -0
- package/src/declarations.d.tsx +3 -0
- package/src/index.ts +33 -0
- package/src/root.component.test.tsx +58 -0
- package/src/root.component.tsx +16 -0
- package/src/routes.json +20 -0
- package/src/types.ts +122 -0
- package/translations/en.json +89 -0
- package/translations/es.json +89 -0
- package/translations/fr.json +89 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, 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, type AuditObs, type AuditPatient } from '../types';
|
|
7
|
+
import PatientActivity from './patient-activity.component';
|
|
8
|
+
|
|
9
|
+
const mockOpenmrsFetch = jest.mocked(openmrsFetch);
|
|
10
|
+
const mockUseConfig = jest.mocked(useConfig);
|
|
11
|
+
|
|
12
|
+
const clerk = { uuid: 'user-clerk', display: 'Cos John' };
|
|
13
|
+
const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' };
|
|
14
|
+
const created = '2026-09-01T11:09:00.000+0000';
|
|
15
|
+
const dayAfter = '2026-09-02T08:30:00.000+0000';
|
|
16
|
+
|
|
17
|
+
const patient: AuditPatient = {
|
|
18
|
+
uuid: 'patient-1',
|
|
19
|
+
display: 'Y2AHXV - Dave TestPatient',
|
|
20
|
+
identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const encounters: Array<AuditEncounter> = [
|
|
24
|
+
{
|
|
25
|
+
uuid: 'enc-1',
|
|
26
|
+
encounterDatetime: created,
|
|
27
|
+
encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
|
|
28
|
+
auditInfo: { creator: clerk, dateCreated: created },
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const obs: Array<AuditObs> = [
|
|
33
|
+
{
|
|
34
|
+
uuid: 'obs-pulse-new',
|
|
35
|
+
concept: { uuid: 'concept-pulse', display: 'Pulse' },
|
|
36
|
+
previousVersion: { uuid: 'obs-pulse-old' },
|
|
37
|
+
auditInfo: { creator: nurse, dateCreated: dayAfter },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
uuid: 'obs-pulse-old',
|
|
41
|
+
concept: { uuid: 'concept-pulse', display: 'Pulse' },
|
|
42
|
+
voided: true,
|
|
43
|
+
auditInfo: { creator: clerk, dateCreated: created, voidedBy: nurse, dateVoided: dayAfter },
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function mockRestApi({ encounterList = encounters }: { encounterList?: Array<AuditEncounter> } = {}) {
|
|
48
|
+
mockOpenmrsFetch.mockImplementation((url: string) => {
|
|
49
|
+
if (url.includes('/obs?encounter=')) {
|
|
50
|
+
return Promise.resolve({ data: { results: obs } }) as ReturnType<typeof openmrsFetch>;
|
|
51
|
+
}
|
|
52
|
+
return Promise.resolve({ data: { results: encounterList } }) as ReturnType<typeof openmrsFetch>;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderPatientActivity() {
|
|
57
|
+
const onSelectEncounter = jest.fn();
|
|
58
|
+
render(
|
|
59
|
+
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
|
|
60
|
+
<PatientActivity filters={{}} includeDeleted={false} onSelectEncounter={onSelectEncounter} patient={patient} />
|
|
61
|
+
</SWRConfig>,
|
|
62
|
+
);
|
|
63
|
+
return onSelectEncounter;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rowFor(name: string) {
|
|
67
|
+
return screen.getAllByRole('row').find((row) => row.textContent?.includes(name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The activity log's rows, which are the ones naming an action. */
|
|
71
|
+
function logRows() {
|
|
72
|
+
return screen
|
|
73
|
+
.getAllByRole('row')
|
|
74
|
+
.filter((row) =>
|
|
75
|
+
/Created encounter|Edited observation|Recorded observation|Deleted observation/.test(row.textContent ?? ''),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('<PatientActivity />', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
mockRestApi();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('says who touched the record and what each of them did', async () => {
|
|
85
|
+
renderPatientActivity();
|
|
86
|
+
|
|
87
|
+
expect(await screen.findByText('Who modified this patient record')).toBeInTheDocument();
|
|
88
|
+
|
|
89
|
+
const clerkRow = rowFor('Cos John');
|
|
90
|
+
expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('1');
|
|
91
|
+
expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('1');
|
|
92
|
+
|
|
93
|
+
const nurseRow = rowFor('Louidor Jean paul');
|
|
94
|
+
expect(within(nurseRow).getAllByRole('cell')[3]).toHaveTextContent('1');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('lists the individual events, most recent first', async () => {
|
|
98
|
+
renderPatientActivity();
|
|
99
|
+
await screen.findByText('Activity log');
|
|
100
|
+
|
|
101
|
+
const rows = logRows();
|
|
102
|
+
expect(rows[0]).toHaveTextContent('Edited observation');
|
|
103
|
+
expect(rows[0]).toHaveTextContent('Louidor Jean paul');
|
|
104
|
+
expect(rows[0]).toHaveTextContent('Pulse');
|
|
105
|
+
expect(rows[0]).toHaveTextContent('COVID-19 Admission');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('narrows the activity log to one user, and back again', async () => {
|
|
109
|
+
renderPatientActivity();
|
|
110
|
+
await screen.findByText('Activity log');
|
|
111
|
+
expect(logRows()).toHaveLength(3);
|
|
112
|
+
|
|
113
|
+
await userEvent.click(screen.getByRole('button', { name: 'Louidor Jean paul' }));
|
|
114
|
+
|
|
115
|
+
const nurseOnly = logRows();
|
|
116
|
+
expect(nurseOnly).toHaveLength(1);
|
|
117
|
+
expect(nurseOnly[0]).toHaveTextContent('Edited observation');
|
|
118
|
+
expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'true');
|
|
119
|
+
|
|
120
|
+
await userEvent.click(screen.getByRole('button', { name: /show everyone/i }));
|
|
121
|
+
|
|
122
|
+
expect(logRows()).toHaveLength(3);
|
|
123
|
+
expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'false');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('clicking the selected user again shows everyone', async () => {
|
|
127
|
+
renderPatientActivity();
|
|
128
|
+
await screen.findByText('Activity log');
|
|
129
|
+
|
|
130
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cos John' }));
|
|
131
|
+
expect(logRows()).toHaveLength(2);
|
|
132
|
+
|
|
133
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cos John' }));
|
|
134
|
+
expect(logRows()).toHaveLength(3);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('drills into the encounter behind an event', async () => {
|
|
138
|
+
const onSelectEncounter = renderPatientActivity();
|
|
139
|
+
await screen.findByText('Activity log');
|
|
140
|
+
|
|
141
|
+
await userEvent.click(screen.getAllByRole('button', { name: /COVID-19 Admission/ })[0]);
|
|
142
|
+
|
|
143
|
+
expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("reads every encounter's observations, however many there are", async () => {
|
|
147
|
+
mockRestApi({
|
|
148
|
+
encounterList: [
|
|
149
|
+
encounters[0],
|
|
150
|
+
{ ...encounters[0], uuid: 'enc-2', auditInfo: { creator: clerk, dateCreated: created } },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
renderPatientActivity();
|
|
154
|
+
|
|
155
|
+
await screen.findByText('Activity log');
|
|
156
|
+
|
|
157
|
+
// Both encounters are created by the clerk and both contribute their observations.
|
|
158
|
+
const clerkRow = rowFor('Cos John');
|
|
159
|
+
expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('2');
|
|
160
|
+
expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('2');
|
|
161
|
+
expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-1'));
|
|
162
|
+
expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-2'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('says how far the observation reads have got while they run', async () => {
|
|
166
|
+
let releaseObs: (value: unknown) => void = () => {};
|
|
167
|
+
const obsGate = new Promise((resolve) => {
|
|
168
|
+
releaseObs = resolve;
|
|
169
|
+
});
|
|
170
|
+
mockOpenmrsFetch.mockImplementation((url: string) => {
|
|
171
|
+
if (url.includes('/obs?encounter=')) {
|
|
172
|
+
return obsGate.then(() => ({ data: { results: obs } })) as ReturnType<typeof openmrsFetch>;
|
|
173
|
+
}
|
|
174
|
+
return Promise.resolve({ data: { results: encounters } }) as ReturnType<typeof openmrsFetch>;
|
|
175
|
+
});
|
|
176
|
+
renderPatientActivity();
|
|
177
|
+
|
|
178
|
+
expect(await screen.findByText(/reading observations from 0 of 1 encounters/i)).toBeInTheDocument();
|
|
179
|
+
|
|
180
|
+
releaseObs(null);
|
|
181
|
+
|
|
182
|
+
expect(await screen.findByText('Activity log')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('says when there is nothing to show', async () => {
|
|
186
|
+
mockRestApi({ encounterList: [] });
|
|
187
|
+
renderPatientActivity();
|
|
188
|
+
|
|
189
|
+
expect(await screen.findByText(/no recorded activity/i)).toBeInTheDocument();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DismissibleTag,
|
|
5
|
+
InlineLoading,
|
|
6
|
+
Pagination,
|
|
7
|
+
SkeletonText,
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
Tag,
|
|
16
|
+
} from '@carbon/react';
|
|
17
|
+
import { ErrorState } from '@openmrs/esm-framework';
|
|
18
|
+
import { type AuditPatient } from '../types';
|
|
19
|
+
import { formatAuditDatetime } from './audit-format';
|
|
20
|
+
import { usePatientActivity } from './audit.resource';
|
|
21
|
+
import { type EncounterFilters } from './encounter-filters';
|
|
22
|
+
import { type AuditAction, eventUserUuid } from './patient-activity';
|
|
23
|
+
import styles from './audit.scss';
|
|
24
|
+
|
|
25
|
+
interface PatientActivityProps {
|
|
26
|
+
patient: AuditPatient | undefined;
|
|
27
|
+
includeDeleted: boolean;
|
|
28
|
+
filters: EncounterFilters;
|
|
29
|
+
onSelectEncounter(encounterUuid: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const eventsPerPage = 20;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Who touched this patient's record: one row per user with what they did, and the individual
|
|
36
|
+
* events behind those counts.
|
|
37
|
+
*/
|
|
38
|
+
export default function PatientActivity({ patient, includeDeleted, filters, onSelectEncounter }: PatientActivityProps) {
|
|
39
|
+
const { t } = useTranslation();
|
|
40
|
+
const [selectedUserUuid, setSelectedUserUuid] = useState<string | null>(null);
|
|
41
|
+
const [page, setPage] = useState(1);
|
|
42
|
+
const { events, userActivity, scanProgress, error, isLoading } = usePatientActivity(patient, includeDeleted, filters);
|
|
43
|
+
|
|
44
|
+
const selectedUser = useMemo(
|
|
45
|
+
() => userActivity.find((activity) => activity.userUuid === selectedUserUuid),
|
|
46
|
+
[selectedUserUuid, userActivity],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const shownEvents = useMemo(
|
|
50
|
+
() => (selectedUserUuid ? events.filter((auditEvent) => eventUserUuid(auditEvent) === selectedUserUuid) : events),
|
|
51
|
+
[events, selectedUserUuid],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setPage(1);
|
|
56
|
+
}, [selectedUserUuid]);
|
|
57
|
+
|
|
58
|
+
/** Clamped rather than reset, so a revalidation that shortens the log cannot blank the page. */
|
|
59
|
+
const totalPages = Math.max(1, Math.ceil(shownEvents.length / eventsPerPage));
|
|
60
|
+
const currentPage = Math.min(page, totalPages);
|
|
61
|
+
const pagedEvents = shownEvents.slice((currentPage - 1) * eventsPerPage, currentPage * eventsPerPage);
|
|
62
|
+
|
|
63
|
+
const actionLabels = useMemo<Record<AuditAction, { label: string; type: 'blue' | 'teal' | 'red' | 'gray' }>>(
|
|
64
|
+
() => ({
|
|
65
|
+
encounterCreated: { label: t('encounterCreatedAction', 'Created encounter'), type: 'teal' },
|
|
66
|
+
encounterChanged: { label: t('encounterChangedAction', 'Changed encounter'), type: 'blue' },
|
|
67
|
+
encounterDeleted: { label: t('encounterDeletedAction', 'Deleted encounter'), type: 'red' },
|
|
68
|
+
obsRecorded: { label: t('obsRecordedAction', 'Recorded observation'), type: 'gray' },
|
|
69
|
+
obsEdited: { label: t('obsEditedAction', 'Edited observation'), type: 'blue' },
|
|
70
|
+
obsDeleted: { label: t('obsDeletedAction', 'Deleted observation'), type: 'red' },
|
|
71
|
+
}),
|
|
72
|
+
[t],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (error) {
|
|
76
|
+
return <ErrorState error={error} headerTitle={t('recordActivity', 'Patient activity')} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isLoading) {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{scanProgress.total > 0 ? (
|
|
83
|
+
<InlineLoading
|
|
84
|
+
description={t('readingObservations', 'Reading observations from {{read}} of {{total}} encounters…', {
|
|
85
|
+
read: scanProgress.read,
|
|
86
|
+
total: scanProgress.total,
|
|
87
|
+
})}
|
|
88
|
+
/>
|
|
89
|
+
) : null}
|
|
90
|
+
<SkeletonText heading paragraph role="progressbar" />
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (events.length === 0) {
|
|
96
|
+
return <p className={styles.emptyState}>{t('noActivity', 'There is no recorded activity to show.')}</p>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<h3 className={styles.sectionHeading}>{t('whoTouchedThisRecord', 'Who modified this patient record')}</h3>
|
|
102
|
+
<TableContainer className={styles.tableContainer}>
|
|
103
|
+
<Table size="sm" useZebraStyles>
|
|
104
|
+
<TableHead>
|
|
105
|
+
<TableRow>
|
|
106
|
+
<TableHeader>{t('user', 'User')}</TableHeader>
|
|
107
|
+
<TableHeader>{t('encountersCreated', 'Encounters created')}</TableHeader>
|
|
108
|
+
<TableHeader>{t('obsRecordedCount', 'Observations recorded')}</TableHeader>
|
|
109
|
+
<TableHeader>{t('obsEditedCount', 'Observations edited')}</TableHeader>
|
|
110
|
+
<TableHeader>{t('obsDeletedCount', 'Observations deleted')}</TableHeader>
|
|
111
|
+
<TableHeader>{t('firstActivity', 'First activity')}</TableHeader>
|
|
112
|
+
<TableHeader>{t('lastActivity', 'Last activity')}</TableHeader>
|
|
113
|
+
</TableRow>
|
|
114
|
+
</TableHead>
|
|
115
|
+
<TableBody>
|
|
116
|
+
{userActivity.map((activity) => {
|
|
117
|
+
const isSelected = activity.userUuid === selectedUserUuid;
|
|
118
|
+
return (
|
|
119
|
+
<TableRow className={isSelected ? styles.selectedRow : undefined} key={activity.userUuid}>
|
|
120
|
+
<TableCell>
|
|
121
|
+
<button
|
|
122
|
+
aria-pressed={isSelected}
|
|
123
|
+
className={styles.linkButton}
|
|
124
|
+
onClick={() => setSelectedUserUuid(isSelected ? null : activity.userUuid)}
|
|
125
|
+
title={t('showOnlyThisUser', "Show only this user's activity")}
|
|
126
|
+
type="button">
|
|
127
|
+
{activity.userDisplay || t('unknownUser', 'Unknown user')}
|
|
128
|
+
</button>
|
|
129
|
+
</TableCell>
|
|
130
|
+
<TableCell>{activity.counts.encounterCreated}</TableCell>
|
|
131
|
+
<TableCell>{activity.counts.obsRecorded}</TableCell>
|
|
132
|
+
<TableCell>{activity.counts.obsEdited}</TableCell>
|
|
133
|
+
<TableCell>{activity.counts.obsDeleted}</TableCell>
|
|
134
|
+
<TableCell>{formatAuditDatetime(activity.firstActivity)}</TableCell>
|
|
135
|
+
<TableCell>{formatAuditDatetime(activity.lastActivity)}</TableCell>
|
|
136
|
+
</TableRow>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</TableBody>
|
|
140
|
+
</Table>
|
|
141
|
+
</TableContainer>
|
|
142
|
+
|
|
143
|
+
<div className={styles.logHeader}>
|
|
144
|
+
<h3 className={styles.sectionHeading}>{t('activityLog', 'Activity log')}</h3>
|
|
145
|
+
{selectedUserUuid ? (
|
|
146
|
+
<DismissibleTag
|
|
147
|
+
dismissTooltipLabel={t('showEveryone', 'Show everyone')}
|
|
148
|
+
onClose={() => setSelectedUserUuid(null)}
|
|
149
|
+
text={selectedUser?.userDisplay || t('unknownUser', 'Unknown user')}
|
|
150
|
+
title={t('showEveryone', 'Show everyone')}
|
|
151
|
+
type="blue"
|
|
152
|
+
/>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
<TableContainer className={styles.tableContainer}>
|
|
156
|
+
<Table size="sm">
|
|
157
|
+
<TableHead>
|
|
158
|
+
<TableRow>
|
|
159
|
+
<TableHeader>{t('when', 'When')}</TableHeader>
|
|
160
|
+
<TableHeader>{t('user', 'User')}</TableHeader>
|
|
161
|
+
<TableHeader>{t('action', 'Action')}</TableHeader>
|
|
162
|
+
<TableHeader>{t('detail', 'Detail')}</TableHeader>
|
|
163
|
+
<TableHeader>{t('encounter', 'Encounter')}</TableHeader>
|
|
164
|
+
</TableRow>
|
|
165
|
+
</TableHead>
|
|
166
|
+
<TableBody>
|
|
167
|
+
{pagedEvents.map((auditEvent) => (
|
|
168
|
+
<TableRow key={auditEvent.key}>
|
|
169
|
+
<TableCell>{formatAuditDatetime(auditEvent.timestamp)}</TableCell>
|
|
170
|
+
<TableCell>{auditEvent.user?.display || t('unknownUser', 'Unknown user')}</TableCell>
|
|
171
|
+
<TableCell>
|
|
172
|
+
<Tag type={actionLabels[auditEvent.action].type}>{actionLabels[auditEvent.action].label}</Tag>
|
|
173
|
+
</TableCell>
|
|
174
|
+
<TableCell>{auditEvent.concept}</TableCell>
|
|
175
|
+
<TableCell>
|
|
176
|
+
<button
|
|
177
|
+
className={styles.linkButton}
|
|
178
|
+
onClick={() => onSelectEncounter(auditEvent.encounter.uuid)}
|
|
179
|
+
type="button">
|
|
180
|
+
{[
|
|
181
|
+
auditEvent.encounter.encounterType?.display,
|
|
182
|
+
formatAuditDatetime(auditEvent.encounter.encounterDatetime),
|
|
183
|
+
]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join(' · ')}
|
|
186
|
+
</button>
|
|
187
|
+
</TableCell>
|
|
188
|
+
</TableRow>
|
|
189
|
+
))}
|
|
190
|
+
</TableBody>
|
|
191
|
+
</Table>
|
|
192
|
+
</TableContainer>
|
|
193
|
+
<Pagination
|
|
194
|
+
onChange={({ page: nextPage }) => setPage(nextPage)}
|
|
195
|
+
page={currentPage}
|
|
196
|
+
pageSize={eventsPerPage}
|
|
197
|
+
pageSizes={[eventsPerPage]}
|
|
198
|
+
size="sm"
|
|
199
|
+
totalItems={shownEvents.length}
|
|
200
|
+
/>
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type AuditEncounter, type AuditObs } from '../types';
|
|
2
|
+
import { buildAuditEvents, eventUserUuid, summariseByUser, unknownUserUuid } from './patient-activity';
|
|
3
|
+
|
|
4
|
+
const clerk = { uuid: 'user-clerk', display: 'Cos John' };
|
|
5
|
+
const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' };
|
|
6
|
+
|
|
7
|
+
const encounterCreated = '2026-09-01T11:09:00.000+0000';
|
|
8
|
+
const dayAfter = '2026-09-02T08:30:00.000+0000';
|
|
9
|
+
|
|
10
|
+
const encounter: AuditEncounter = {
|
|
11
|
+
uuid: 'enc-1',
|
|
12
|
+
encounterDatetime: encounterCreated,
|
|
13
|
+
encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
|
|
14
|
+
auditInfo: { creator: clerk, dateCreated: encounterCreated },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function obs(overrides: Partial<AuditObs> & { uuid: string }): AuditObs {
|
|
18
|
+
return {
|
|
19
|
+
concept: { uuid: 'concept-pulse', display: 'Pulse' },
|
|
20
|
+
auditInfo: { creator: clerk, dateCreated: encounterCreated },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('buildAuditEvents', () => {
|
|
26
|
+
it('records who created an encounter', () => {
|
|
27
|
+
expect(buildAuditEvents([encounter], {})).toEqual([
|
|
28
|
+
expect.objectContaining({ action: 'encounterCreated', user: clerk, timestamp: encounterCreated }),
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('records an encounter that was changed and one that was deleted', () => {
|
|
33
|
+
const changed: AuditEncounter = {
|
|
34
|
+
...encounter,
|
|
35
|
+
voided: true,
|
|
36
|
+
auditInfo: {
|
|
37
|
+
creator: clerk,
|
|
38
|
+
dateCreated: encounterCreated,
|
|
39
|
+
changedBy: nurse,
|
|
40
|
+
dateChanged: dayAfter,
|
|
41
|
+
voidedBy: nurse,
|
|
42
|
+
dateVoided: dayAfter,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
expect(buildAuditEvents([changed], {}).map((auditEvent) => auditEvent.action)).toEqual([
|
|
47
|
+
'encounterChanged',
|
|
48
|
+
'encounterDeleted',
|
|
49
|
+
'encounterCreated',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('counts an edit once, as an edit, rather than as a deletion and a recording', () => {
|
|
54
|
+
const events = buildAuditEvents([encounter], {
|
|
55
|
+
'enc-1': [
|
|
56
|
+
obs({
|
|
57
|
+
uuid: 'obs-new',
|
|
58
|
+
previousVersion: { uuid: 'obs-old' },
|
|
59
|
+
auditInfo: { creator: nurse, dateCreated: dayAfter },
|
|
60
|
+
}),
|
|
61
|
+
obs({
|
|
62
|
+
uuid: 'obs-old',
|
|
63
|
+
voided: true,
|
|
64
|
+
auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter },
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([
|
|
70
|
+
['obsEdited', 'user-nurse'],
|
|
71
|
+
['encounterCreated', 'user-clerk'],
|
|
72
|
+
['obsRecorded', 'user-clerk'],
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('records an outright deletion alongside the original recording', () => {
|
|
77
|
+
const events = buildAuditEvents([encounter], {
|
|
78
|
+
'enc-1': [
|
|
79
|
+
obs({
|
|
80
|
+
uuid: 'obs-gone',
|
|
81
|
+
voided: true,
|
|
82
|
+
auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter },
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([
|
|
88
|
+
['obsDeleted', 'user-nurse'],
|
|
89
|
+
['encounterCreated', 'user-clerk'],
|
|
90
|
+
['obsRecorded', 'user-clerk'],
|
|
91
|
+
]);
|
|
92
|
+
expect(events[0].concept).toBe('Pulse');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves out events the api gave no timestamp for', () => {
|
|
96
|
+
expect(buildAuditEvents([{ uuid: 'enc-2' }], {})).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('puts the most recent event first', () => {
|
|
100
|
+
const events = buildAuditEvents([encounter], {
|
|
101
|
+
'enc-1': [obs({ uuid: 'obs-later', auditInfo: { creator: nurse, dateCreated: dayAfter } })],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(events[0].timestamp).toBe(dayAfter);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('eventUserUuid', () => {
|
|
109
|
+
it('keys an event by its user, or by the unknown user when the api named none', () => {
|
|
110
|
+
const [withUser] = buildAuditEvents([encounter], {});
|
|
111
|
+
const [withoutUser] = buildAuditEvents([{ uuid: 'enc-2', auditInfo: { dateCreated: dayAfter } }], {});
|
|
112
|
+
|
|
113
|
+
expect(eventUserUuid(withUser)).toBe(clerk.uuid);
|
|
114
|
+
expect(eventUserUuid(withoutUser)).toBe(unknownUserUuid);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('summariseByUser', () => {
|
|
119
|
+
const events = buildAuditEvents([encounter], {
|
|
120
|
+
'enc-1': [
|
|
121
|
+
obs({ uuid: 'obs-1' }),
|
|
122
|
+
obs({ uuid: 'obs-2' }),
|
|
123
|
+
obs({
|
|
124
|
+
uuid: 'obs-new',
|
|
125
|
+
previousVersion: { uuid: 'obs-2' },
|
|
126
|
+
auditInfo: { creator: nurse, dateCreated: dayAfter },
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('counts what each user did, busiest first', () => {
|
|
132
|
+
const summary = summariseByUser(events);
|
|
133
|
+
|
|
134
|
+
expect(summary.map((activity) => activity.userDisplay)).toEqual(['Cos John', 'Louidor Jean paul']);
|
|
135
|
+
expect(summary[0].counts).toMatchObject({ encounterCreated: 1, obsRecorded: 2, obsEdited: 0 });
|
|
136
|
+
expect(summary[0].totalEvents).toBe(3);
|
|
137
|
+
expect(summary[1].counts).toMatchObject({ obsEdited: 1 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('brackets each user’s activity with when they first and last touched the record', () => {
|
|
141
|
+
const summary = summariseByUser(events);
|
|
142
|
+
|
|
143
|
+
expect(summary[0].firstActivity).toBe(encounterCreated);
|
|
144
|
+
expect(summary[0].lastActivity).toBe(encounterCreated);
|
|
145
|
+
expect(summary[1].firstActivity).toBe(dayAfter);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('gathers events with no named user rather than dropping them', () => {
|
|
149
|
+
const summary = summariseByUser(buildAuditEvents([{ uuid: 'enc-3', auditInfo: { dateCreated: dayAfter } }], {}));
|
|
150
|
+
|
|
151
|
+
expect(summary).toHaveLength(1);
|
|
152
|
+
expect(summary[0].userUuid).toBe(unknownUserUuid);
|
|
153
|
+
expect(summary[0].userDisplay).toBe('');
|
|
154
|
+
});
|
|
155
|
+
});
|