@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,409 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import {
|
|
4
|
+
type FetchResponse,
|
|
5
|
+
openmrsFetch,
|
|
6
|
+
restBaseUrl,
|
|
7
|
+
useOpenmrsFetchAll,
|
|
8
|
+
useOpenmrsPagination,
|
|
9
|
+
} from '@openmrs/esm-framework';
|
|
10
|
+
import { type AuditEncounter, type AuditObs, type AuditPatient, type ObsTreeNode, type PagedResponse } from '../types';
|
|
11
|
+
import {
|
|
12
|
+
buildEncounterFilterQuery,
|
|
13
|
+
distinctEncounterTypes,
|
|
14
|
+
type EncounterFilters,
|
|
15
|
+
matchesEncounterFilters,
|
|
16
|
+
} from './encounter-filters';
|
|
17
|
+
import { buildObsTree } from './obs-audit';
|
|
18
|
+
import { buildAuditEvents, type ObsByEncounter, summariseByUser } from './patient-activity';
|
|
19
|
+
|
|
20
|
+
const patientRep =
|
|
21
|
+
'custom:(uuid,display,identifiers:(uuid,identifier,preferred,identifierType:(uuid,display))' +
|
|
22
|
+
',person:(display,gender,age,birthdate,dead))';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `patient` is needed to narrow the free-text search used to list deleted encounters, and
|
|
26
|
+
* `auditInfo` is what the activity view reads to say who created, changed or deleted each one.
|
|
27
|
+
*/
|
|
28
|
+
const encounterListRep =
|
|
29
|
+
'custom:(uuid,display,encounterDatetime,voided,patient:(uuid),encounterType:(uuid,display)' +
|
|
30
|
+
',form:(uuid,display),location:(uuid,display)' +
|
|
31
|
+
',encounterProviders:(uuid,voided,provider:(uuid,display),encounterRole:(uuid,display)),auditInfo)';
|
|
32
|
+
|
|
33
|
+
const encounterDetailRep =
|
|
34
|
+
'custom:(uuid,display,encounterDatetime,voided,patient:(uuid,display,identifiers:(uuid,identifier,preferred))' +
|
|
35
|
+
',location:(uuid,display),form:(uuid,display,version),encounterType:(uuid,display)' +
|
|
36
|
+
',visit:(uuid,display,startDatetime,visitType:(uuid,display))' +
|
|
37
|
+
',encounterProviders:(uuid,voided,provider:(uuid,display,identifier),encounterRole:(uuid,display)),auditInfo)';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `value:ref` keeps coded values down to a `{uuid, display}` reference while leaving numeric, text
|
|
41
|
+
* and datetime values as the scalars they are. `previousVersion` and `auditInfo` are what make the
|
|
42
|
+
* audit trail possible: the former links an obs to the value it replaced, the latter carries the
|
|
43
|
+
* creator, the editor and the voiding user with their timestamps.
|
|
44
|
+
*/
|
|
45
|
+
const obsRep =
|
|
46
|
+
'custom:(uuid,display,obsDatetime,voided,comment,formFieldPath,value:ref' +
|
|
47
|
+
',concept:(uuid,display,descriptions:(description,locale)),obsGroup:(uuid)' +
|
|
48
|
+
',previousVersion:(uuid,display),auditInfo)';
|
|
49
|
+
|
|
50
|
+
/** A stable identity, so the unfiltered reads do not rebuild their memos on every render. */
|
|
51
|
+
const noFilters: EncounterFilters = {};
|
|
52
|
+
|
|
53
|
+
/** Just enough of an obs to say who touched it and when. */
|
|
54
|
+
const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The framework's paging hooks default to their own binding of `openmrsFetch`; naming ours keeps
|
|
58
|
+
* every request this module makes going through the one function, which is also what lets tests
|
|
59
|
+
* stand in for it.
|
|
60
|
+
*/
|
|
61
|
+
const restFetchOptions = { fetcher: openmrsFetch };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The page size asked for on bulk reads. Without it the server falls back to
|
|
65
|
+
* `webservices.rest.maxResultsDefault`, which is 50 by default and means more round trips.
|
|
66
|
+
*/
|
|
67
|
+
const bulkPageSize = 100;
|
|
68
|
+
const maxBulkPages = 25;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads every page of a paginated REST endpoint imperatively.
|
|
72
|
+
*
|
|
73
|
+
* `useOpenmrsFetchAll` covers this wherever the url is known at render time. The activity scan is
|
|
74
|
+
* the exception: it reads the observations of many encounters, and a hook cannot be called once
|
|
75
|
+
* per encounter, so that one loop needs a plain function.
|
|
76
|
+
*/
|
|
77
|
+
async function fetchAllPages<T>(url: string): Promise<Array<T>> {
|
|
78
|
+
const results: Array<T> = [];
|
|
79
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
80
|
+
|
|
81
|
+
for (let page = 0; page < maxBulkPages; page++) {
|
|
82
|
+
const response = await openmrsFetch<PagedResponse<T>>(
|
|
83
|
+
`${url}${separator}startIndex=${page * bulkPageSize}&limit=${bulkPageSize}`,
|
|
84
|
+
);
|
|
85
|
+
const batch = response?.data?.results ?? [];
|
|
86
|
+
results.push(...batch);
|
|
87
|
+
if (batch.length < bulkPageSize) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How many encounters' observations to read at once, so a scan does not flood the server. */
|
|
96
|
+
const scanConcurrency = 5;
|
|
97
|
+
|
|
98
|
+
/** Runs `worker` over every item, keeping at most `limit` requests in flight. */
|
|
99
|
+
async function mapWithConcurrency<T, R>(items: Array<T>, limit: number, worker: (item: T) => Promise<R>) {
|
|
100
|
+
const results = new Array<R>(items.length);
|
|
101
|
+
let next = 0;
|
|
102
|
+
|
|
103
|
+
async function run() {
|
|
104
|
+
while (next < items.length) {
|
|
105
|
+
const index = next++;
|
|
106
|
+
results[index] = await worker(items[index]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The identifier to show for a patient, and the one to search encounters by. */
|
|
115
|
+
export function getPreferredIdentifier(patient: AuditPatient | undefined): string | undefined {
|
|
116
|
+
const identifiers = patient?.identifiers;
|
|
117
|
+
if (!identifiers?.length) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return (identifiers.find((identifier) => identifier.preferred) ?? identifiers[0]).identifier;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Searches patients by name or identifier, one page at a time. The audit trail starts here
|
|
125
|
+
* because a patient is what an auditor has to hand — a name, or an EMR ID off a paper form.
|
|
126
|
+
*/
|
|
127
|
+
export function usePatientSearch(query: string, page: number, pageSize: number) {
|
|
128
|
+
const startIndex = (page - 1) * pageSize;
|
|
129
|
+
const url = query
|
|
130
|
+
? `${restBaseUrl}/patient?q=${encodeURIComponent(query)}&v=${patientRep}` +
|
|
131
|
+
`&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
|
|
132
|
+
: null;
|
|
133
|
+
|
|
134
|
+
const { data, error, isLoading } = useSWR<FetchResponse<PagedResponse<AuditPatient>>>(url, openmrsFetch);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
patients: data?.data?.results ?? [],
|
|
138
|
+
totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
|
|
139
|
+
error,
|
|
140
|
+
isLoading,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function useAuditPatient(patientUuid: string | null) {
|
|
145
|
+
const url = patientUuid ? `${restBaseUrl}/patient/${patientUuid}?v=${patientRep}` : null;
|
|
146
|
+
const { data, error, isLoading } = useSWR<FetchResponse<AuditPatient>>(url, openmrsFetch);
|
|
147
|
+
|
|
148
|
+
return { patient: data?.data, error, isLoading };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The search phrase used to reach a patient's deleted encounters, and the url that reads them.
|
|
153
|
+
* Both `usePatientEncounters` and `usePatientEncounterTypes` need that list, so they build the
|
|
154
|
+
* same url and SWR serves it to whichever of them asks second.
|
|
155
|
+
*/
|
|
156
|
+
function encounterSearchPhrase(patient: AuditPatient | undefined): string | undefined {
|
|
157
|
+
return getPreferredIdentifier(patient) ?? patient?.person?.display;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a patient's deleted encounters can be reached at all. The free-text search that finds
|
|
162
|
+
* them needs a phrase to search on, so a patient with neither an identifier nor a name has none.
|
|
163
|
+
*/
|
|
164
|
+
export function canListDeletedEncounters(patient: AuditPatient | undefined): boolean {
|
|
165
|
+
return Boolean(encounterSearchPhrase(patient));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function deletedEncountersUrl(patient: AuditPatient | undefined): string | null {
|
|
169
|
+
const searchPhrase = encounterSearchPhrase(patient);
|
|
170
|
+
return patient && searchPhrase
|
|
171
|
+
? `${restBaseUrl}/encounter?q=${encodeURIComponent(searchPhrase)}&includeAll=true&v=${encounterListRep}` +
|
|
172
|
+
`&limit=${bulkPageSize}`
|
|
173
|
+
: null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function encountersForPatient(encounters: Array<AuditEncounter> | undefined, patient: AuditPatient | undefined) {
|
|
177
|
+
return (encounters ?? []).filter((encounter) => encounter.patient?.uuid === patient?.uuid);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every encounter a patient has, in one go rather than a page at a time — what the encounter type
|
|
182
|
+
* options and the activity view both need. The two paths mirror `usePatientEncounters`: the
|
|
183
|
+
* by-patient search for live encounters, and the free-text search when deleted ones are wanted.
|
|
184
|
+
* Both use `encounterListRep`, so a read made for one caller serves the others from cache.
|
|
185
|
+
*/
|
|
186
|
+
export function useAllPatientEncounters(
|
|
187
|
+
patient: AuditPatient | undefined,
|
|
188
|
+
includeDeleted: boolean,
|
|
189
|
+
filters: EncounterFilters,
|
|
190
|
+
) {
|
|
191
|
+
const scanUrl =
|
|
192
|
+
patient && !includeDeleted
|
|
193
|
+
? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` +
|
|
194
|
+
`${buildEncounterFilterQuery(filters)}&order=desc&limit=${bulkPageSize}`
|
|
195
|
+
: null;
|
|
196
|
+
// `useOpenmrsFetchAll` takes a null url to mean "do not fetch", though its type does not say so.
|
|
197
|
+
const scanResult = useOpenmrsFetchAll<AuditEncounter>(scanUrl as string, restFetchOptions);
|
|
198
|
+
const bulkResult = useOpenmrsFetchAll<AuditEncounter>(
|
|
199
|
+
(includeDeleted ? deletedEncountersUrl(patient) : null) as string,
|
|
200
|
+
restFetchOptions,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const encounters = useMemo(() => {
|
|
204
|
+
if (!includeDeleted) {
|
|
205
|
+
return scanResult.data ?? [];
|
|
206
|
+
}
|
|
207
|
+
return encountersForPatient(bulkResult.data, patient)
|
|
208
|
+
.filter((encounter) => matchesEncounterFilters(encounter, filters))
|
|
209
|
+
.sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? ''));
|
|
210
|
+
}, [bulkResult.data, filters, includeDeleted, patient, scanResult.data]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
encounters,
|
|
214
|
+
error: includeDeleted ? bulkResult.error : scanResult.error,
|
|
215
|
+
isLoading: includeDeleted ? bulkResult.isLoading : scanResult.isLoading,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The encounter types to offer as filters: the ones this patient's own encounters use, so the
|
|
221
|
+
* dropdown never offers a type that would return nothing. Read unfiltered, so choosing a type
|
|
222
|
+
* does not narrow the choices left.
|
|
223
|
+
*/
|
|
224
|
+
export function usePatientEncounterTypes(patient: AuditPatient | undefined, includeDeleted: boolean) {
|
|
225
|
+
const { encounters, error, isLoading } = useAllPatientEncounters(patient, includeDeleted, noFilters);
|
|
226
|
+
const encounterTypes = useMemo(() => distinctEncounterTypes(encounters), [encounters]);
|
|
227
|
+
|
|
228
|
+
return { encounterTypes, error, isLoading };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One page of a patient's encounters, optionally narrowed by encounter type and date range, with
|
|
233
|
+
* the page to show and the callback to change it — whichever of the two paths below is in use.
|
|
234
|
+
*/
|
|
235
|
+
interface PatientEncountersResult {
|
|
236
|
+
encounters: Array<AuditEncounter>;
|
|
237
|
+
totalCount: number;
|
|
238
|
+
currentPage: number;
|
|
239
|
+
goTo(page: number): void;
|
|
240
|
+
error: unknown;
|
|
241
|
+
isLoading: boolean;
|
|
242
|
+
/** True when the patient has no identifier to run the deleted-encounter search with. */
|
|
243
|
+
cannotIncludeDeleted: boolean;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Lists a patient's encounters, a page at a time.
|
|
248
|
+
*
|
|
249
|
+
* Live encounters are paged by the server: `useOpenmrsPagination` owns the page number and asks
|
|
250
|
+
* for one page at a time, so the size of a patient's record costs nothing to display.
|
|
251
|
+
*
|
|
252
|
+
* Deleted encounters cannot be paged that way. The encounter-by-patient search always excludes
|
|
253
|
+
* voided encounters, so showing them means falling back to the free-text encounter search, which
|
|
254
|
+
* does honour `includeAll` but matches any patient whose name or identifier contains the phrase
|
|
255
|
+
* and takes no filter parameters. Its results therefore have to be read in full, narrowed to the
|
|
256
|
+
* patient in hand, filtered, and paged here — the one case where the whole list is fetched.
|
|
257
|
+
*/
|
|
258
|
+
export function usePatientEncounters(
|
|
259
|
+
patient: AuditPatient | undefined,
|
|
260
|
+
includeDeleted: boolean,
|
|
261
|
+
filters: EncounterFilters,
|
|
262
|
+
pageSize: number,
|
|
263
|
+
): PatientEncountersResult {
|
|
264
|
+
const pagedUrl =
|
|
265
|
+
patient && !includeDeleted
|
|
266
|
+
? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` +
|
|
267
|
+
`${buildEncounterFilterQuery(filters)}&order=desc`
|
|
268
|
+
: null;
|
|
269
|
+
// `useOpenmrsPagination` appends limit, startIndex and totalCount itself, so the url omits them.
|
|
270
|
+
const pagedResult = useOpenmrsPagination<AuditEncounter>(pagedUrl as string, pageSize, restFetchOptions);
|
|
271
|
+
|
|
272
|
+
const bulkResult = useOpenmrsFetchAll<AuditEncounter>(
|
|
273
|
+
(includeDeleted ? deletedEncountersUrl(patient) : null) as string,
|
|
274
|
+
restFetchOptions,
|
|
275
|
+
);
|
|
276
|
+
const [clientPage, setClientPage] = useState(1);
|
|
277
|
+
|
|
278
|
+
const deletedEncounters = useMemo(() => {
|
|
279
|
+
if (!includeDeleted) {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
return encountersForPatient(bulkResult.data, patient)
|
|
283
|
+
.filter((encounter) => matchesEncounterFilters(encounter, filters))
|
|
284
|
+
.sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? ''));
|
|
285
|
+
}, [bulkResult.data, filters, includeDeleted, patient]);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A filter, the deleted toggle or a different patient changes which encounters exist, so the
|
|
289
|
+
* paging starts over. `goTo` refuses a page it considers out of bounds, so it is only called
|
|
290
|
+
* when there is somewhere to go back from.
|
|
291
|
+
*/
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
setClientPage(1);
|
|
294
|
+
if (pagedResult.currentPage !== 1) {
|
|
295
|
+
pagedResult.goTo(1);
|
|
296
|
+
}
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
}, [filters, includeDeleted, patient?.uuid]);
|
|
299
|
+
|
|
300
|
+
if (includeDeleted) {
|
|
301
|
+
const totalPages = Math.max(1, Math.ceil(deletedEncounters.length / pageSize));
|
|
302
|
+
const currentPage = Math.min(clientPage, totalPages);
|
|
303
|
+
return {
|
|
304
|
+
encounters: deletedEncounters.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
|
305
|
+
totalCount: deletedEncounters.length,
|
|
306
|
+
currentPage,
|
|
307
|
+
goTo: setClientPage,
|
|
308
|
+
error: bulkResult.error,
|
|
309
|
+
isLoading: bulkResult.isLoading,
|
|
310
|
+
cannotIncludeDeleted: Boolean(patient) && !canListDeletedEncounters(patient),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
encounters: pagedResult.data ?? [],
|
|
316
|
+
// The hook reports NaN until the first page has been read.
|
|
317
|
+
totalCount: Number.isNaN(pagedResult.totalCount) ? 0 : pagedResult.totalCount,
|
|
318
|
+
currentPage: pagedResult.currentPage,
|
|
319
|
+
goTo: pagedResult.goTo,
|
|
320
|
+
error: pagedResult.error,
|
|
321
|
+
isLoading: pagedResult.isLoading,
|
|
322
|
+
cannotIncludeDeleted: false,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Loads one encounter together with every obs ever recorded against it — group members and voided
|
|
328
|
+
* obs included, which is what `includeAll` buys — and assembles the audit trail.
|
|
329
|
+
*/
|
|
330
|
+
export function useEncounterAudit(encounterUuid: string | null): {
|
|
331
|
+
encounter: AuditEncounter | undefined;
|
|
332
|
+
obsTree: Array<ObsTreeNode>;
|
|
333
|
+
error: Error | undefined;
|
|
334
|
+
isLoading: boolean;
|
|
335
|
+
} {
|
|
336
|
+
const encounterUrl = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterDetailRep}` : null;
|
|
337
|
+
const encounterResult = useSWR<FetchResponse<AuditEncounter>>(encounterUrl, openmrsFetch);
|
|
338
|
+
|
|
339
|
+
const obsUrl = encounterUuid
|
|
340
|
+
? `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${obsRep}&limit=${bulkPageSize}`
|
|
341
|
+
: null;
|
|
342
|
+
const obsResult = useOpenmrsFetchAll<AuditObs>(obsUrl as string, restFetchOptions);
|
|
343
|
+
|
|
344
|
+
const encounter = encounterResult.data?.data;
|
|
345
|
+
const obsTree = useMemo(() => buildObsTree(obsResult.data ?? [], encounter), [obsResult.data, encounter]);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
encounter,
|
|
349
|
+
obsTree,
|
|
350
|
+
error: encounterResult.error ?? obsResult.error,
|
|
351
|
+
isLoading: encounterResult.isLoading || obsResult.isLoading,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Who touched this patient's record: every creation, edit and deletion across their encounters,
|
|
357
|
+
* summarised per user and listed in order.
|
|
358
|
+
*
|
|
359
|
+
* Encounter rows carry their own `auditInfo`, and every matching encounter has already been read,
|
|
360
|
+
* so who created, changed and deleted encounters comes free.
|
|
361
|
+
*
|
|
362
|
+
* Observations do not: each encounter's observations need their own request, because the
|
|
363
|
+
* obs-by-encounter search is the only one that honours `includeAll` and so the only one that can
|
|
364
|
+
* see deleted observations. Every matching encounter is read all the same, so the view is complete
|
|
365
|
+
* — which means a patient with a long record costs one request per encounter, a few at a time.
|
|
366
|
+
* `scanProgress` is reported so the caller can show how far it has got.
|
|
367
|
+
*/
|
|
368
|
+
export function usePatientActivity(
|
|
369
|
+
patient: AuditPatient | undefined,
|
|
370
|
+
includeDeleted: boolean,
|
|
371
|
+
filters: EncounterFilters,
|
|
372
|
+
) {
|
|
373
|
+
const {
|
|
374
|
+
encounters,
|
|
375
|
+
error: encountersError,
|
|
376
|
+
isLoading: isLoadingEncounters,
|
|
377
|
+
} = useAllPatientEncounters(patient, includeDeleted, filters);
|
|
378
|
+
|
|
379
|
+
const [encountersRead, setEncountersRead] = useState(0);
|
|
380
|
+
const encounterUuids = useMemo(() => encounters.map((encounter) => encounter.uuid), [encounters]);
|
|
381
|
+
|
|
382
|
+
const obsResult = useSWR<ObsByEncounter>(
|
|
383
|
+
encounterUuids.length ? ['audit-activity-obs', encounterUuids] : null,
|
|
384
|
+
async () => {
|
|
385
|
+
setEncountersRead(0);
|
|
386
|
+
let read = 0;
|
|
387
|
+
const perEncounter = await mapWithConcurrency(encounterUuids, scanConcurrency, async (encounterUuid) => {
|
|
388
|
+
const obs = await fetchAllPages<AuditObs>(
|
|
389
|
+
`${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${activityObsRep}`,
|
|
390
|
+
);
|
|
391
|
+
setEncountersRead((read += 1));
|
|
392
|
+
return obs;
|
|
393
|
+
});
|
|
394
|
+
return Object.fromEntries(encounterUuids.map((encounterUuid, index) => [encounterUuid, perEncounter[index]]));
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const events = useMemo(() => buildAuditEvents(encounters, obsResult.data ?? {}), [encounters, obsResult.data]);
|
|
399
|
+
const userActivity = useMemo(() => summariseByUser(events), [events]);
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
events,
|
|
403
|
+
userActivity,
|
|
404
|
+
/** How far the observation reads have got, for a view that has to wait on a long record. */
|
|
405
|
+
scanProgress: { read: encountersRead, total: encounterUuids.length },
|
|
406
|
+
error: encountersError ?? obsResult.error,
|
|
407
|
+
isLoading: isLoadingEncounters || obsResult.isLoading,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
4
|
+
|
|
5
|
+
.container {
|
|
6
|
+
background-color: $ui-background;
|
|
7
|
+
min-height: 100%;
|
|
8
|
+
padding-bottom: layout.$spacing-07;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.pageHeader {
|
|
12
|
+
margin-bottom: layout.$spacing-05;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.section {
|
|
16
|
+
padding: 0 layout.$spacing-05;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.sectionHeading {
|
|
20
|
+
@include type.type-style('productive-heading-02');
|
|
21
|
+
color: $text-02;
|
|
22
|
+
margin: layout.$spacing-05 0 layout.$spacing-03;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.contextHeader {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
gap: layout.$spacing-02;
|
|
29
|
+
margin-bottom: layout.$spacing-05;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.contextTitle {
|
|
33
|
+
@include type.type-style('productive-heading-03');
|
|
34
|
+
color: $color-gray-100;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.contextSubtitle {
|
|
38
|
+
@include type.type-style('body-long-01');
|
|
39
|
+
color: $text-02;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.backButton {
|
|
43
|
+
margin-left: -#{layout.$spacing-05};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.searchInput {
|
|
47
|
+
max-width: 30rem;
|
|
48
|
+
margin-bottom: layout.$spacing-05;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.toolbar {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: layout.$spacing-05;
|
|
56
|
+
margin-bottom: layout.$spacing-03;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.tableContainer {
|
|
60
|
+
margin-bottom: layout.$spacing-03;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* The whole row is the target for opening a patient or an encounter. */
|
|
64
|
+
.clickableRow {
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.summaryList {
|
|
69
|
+
background-color: $ui-01;
|
|
70
|
+
margin-bottom: layout.$spacing-05;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.summaryLabel {
|
|
74
|
+
@include type.type-style('productive-heading-01');
|
|
75
|
+
color: $text-02;
|
|
76
|
+
width: 14rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.summaryValue {
|
|
80
|
+
@include type.type-style('body-short-01');
|
|
81
|
+
color: $color-gray-100;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Voided rows keep the legacy admin page's struck-through, muted look. */
|
|
85
|
+
.voidedRow td {
|
|
86
|
+
color: $text-02;
|
|
87
|
+
text-decoration: line-through;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.obsConcept {
|
|
91
|
+
@include type.type-style('body-short-01');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.obsDescription {
|
|
95
|
+
@include type.type-style('label-01');
|
|
96
|
+
color: $text-02;
|
|
97
|
+
padding: 0 0 layout.$spacing-03;
|
|
98
|
+
text-decoration: none;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.legend {
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: layout.$spacing-03 layout.$spacing-05;
|
|
106
|
+
margin-bottom: layout.$spacing-05;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.legendItem {
|
|
110
|
+
@include type.type-style('label-01');
|
|
111
|
+
color: $text-02;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: layout.$spacing-02;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.emptyState {
|
|
118
|
+
@include type.type-style('body-long-01');
|
|
119
|
+
color: $text-02;
|
|
120
|
+
padding: layout.$spacing-05 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.inlineNotification {
|
|
124
|
+
max-width: none;
|
|
125
|
+
margin-bottom: layout.$spacing-05;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* A table cell that opens a drill-down: looks like a link, behaves like a button. */
|
|
129
|
+
.linkButton {
|
|
130
|
+
@include type.type-style('body-short-01');
|
|
131
|
+
background: none;
|
|
132
|
+
border: none;
|
|
133
|
+
color: $interactive-01;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
padding: 0;
|
|
136
|
+
text-align: left;
|
|
137
|
+
|
|
138
|
+
&:hover,
|
|
139
|
+
&:focus {
|
|
140
|
+
text-decoration: underline;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.filters {
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-wrap: wrap;
|
|
147
|
+
align-items: flex-end;
|
|
148
|
+
gap: layout.$spacing-05;
|
|
149
|
+
margin-bottom: layout.$spacing-05;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.filterControl {
|
|
153
|
+
min-width: 16rem;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Sits on the same baseline as the filter inputs, which carry their own labels. */
|
|
157
|
+
.clearFilters {
|
|
158
|
+
margin-bottom: layout.$spacing-01;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.logHeader {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-wrap: wrap;
|
|
164
|
+
align-items: center;
|
|
165
|
+
gap: layout.$spacing-03;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Marks the user whose events the activity log is showing. */
|
|
169
|
+
.selectedRow td {
|
|
170
|
+
background-color: $color-blue-10;
|
|
171
|
+
}
|