@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.
- package/.turbo/turbo-build.log +1 -1
- package/README.md +96 -2
- package/dist/115.js +1 -1
- package/dist/117.js +1 -1
- package/dist/117.js.map +1 -1
- package/dist/508.js +1 -1
- package/dist/508.js.map +1 -1
- package/dist/514.js +1 -0
- package/dist/514.js.map +1 -0
- package/dist/912.js +1 -1
- package/dist/912.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/pih-esm-audit-app.js +1 -1
- package/dist/pih-esm-audit-app.js.buildmanifest.json +34 -34
- package/dist/routes.json +1 -1
- package/locales/en/translation.json +118 -0
- package/locales/fr/translation.json +118 -0
- package/package.json +1 -1
- package/src/audit/audit-search.component.tsx +58 -0
- package/src/audit/audit.component.tsx +63 -16
- package/src/audit/audit.resource.ts +445 -10
- package/src/audit/audit.scss +6 -0
- package/src/audit/date-range-picker.test-stub.tsx +40 -0
- package/src/audit/date-range.test.ts +128 -0
- package/src/audit/date-range.ts +120 -0
- package/src/audit/encounter-filters.component.tsx +40 -18
- package/src/audit/encounter-filters.test.ts +1 -2
- package/src/audit/encounter-filters.ts +2 -27
- package/src/audit/patient-record.component.test.tsx +3 -5
- package/src/audit/patient-record.component.tsx +13 -2
- package/src/audit/patient-search.component.tsx +1 -1
- package/src/audit/provider-encounters.component.test.tsx +254 -0
- package/src/audit/provider-encounters.component.tsx +160 -0
- package/src/audit/provider-search.component.test.tsx +72 -0
- package/src/audit/provider-search.component.tsx +116 -0
- package/src/audit/user-encounters.component.test.tsx +431 -0
- package/src/audit/user-encounters.component.tsx +239 -0
- package/src/audit/user-encounters.test.ts +176 -0
- package/src/audit/user-encounters.ts +166 -0
- package/src/audit/user-search.component.test.tsx +89 -0
- package/src/audit/user-search.component.tsx +111 -0
- package/src/root.component.test.tsx +48 -3
- package/src/types.ts +34 -0
- package/translations/en.json +29 -0
- package/translations/es.json +29 -0
- package/translations/fr.json +29 -0
- package/dist/446.js +0 -1
- package/dist/446.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import useSWR from 'swr';
|
|
3
3
|
import {
|
|
4
4
|
type FetchResponse,
|
|
@@ -7,7 +7,23 @@ import {
|
|
|
7
7
|
useOpenmrsFetchAll,
|
|
8
8
|
useOpenmrsPagination,
|
|
9
9
|
} from '@openmrs/esm-framework';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type AuditEncounter,
|
|
12
|
+
type AuditObs,
|
|
13
|
+
type AuditPatient,
|
|
14
|
+
type AuditProvider,
|
|
15
|
+
type AuditUser,
|
|
16
|
+
type ObsTreeNode,
|
|
17
|
+
type OpenmrsResourceRef,
|
|
18
|
+
type PagedResponse,
|
|
19
|
+
} from '../types';
|
|
20
|
+
import {
|
|
21
|
+
buildAuditDateQuery,
|
|
22
|
+
ENCOUNTER_DATE_PARAMS,
|
|
23
|
+
OBS_CREATED_DATE_PARAMS,
|
|
24
|
+
OBS_VOIDED_DATE_PARAMS,
|
|
25
|
+
type DateRange,
|
|
26
|
+
} from './date-range';
|
|
11
27
|
import {
|
|
12
28
|
buildEncounterFilterQuery,
|
|
13
29
|
distinctEncounterTypes,
|
|
@@ -16,6 +32,17 @@ import {
|
|
|
16
32
|
} from './encounter-filters';
|
|
17
33
|
import { buildObsTree } from './obs-audit';
|
|
18
34
|
import { buildAuditEvents, type ObsByEncounter, summariseByUser } from './patient-activity';
|
|
35
|
+
import {
|
|
36
|
+
type AuditAction,
|
|
37
|
+
canDiscoverMore,
|
|
38
|
+
countUserObs,
|
|
39
|
+
discoverEncounters,
|
|
40
|
+
emptyStream,
|
|
41
|
+
filterByEncounterType,
|
|
42
|
+
type ObsStream,
|
|
43
|
+
streamToExtend,
|
|
44
|
+
type UserEncounterActivity,
|
|
45
|
+
} from './user-encounters';
|
|
19
46
|
|
|
20
47
|
const patientRep =
|
|
21
48
|
'custom:(uuid,display,identifiers:(uuid,identifier,preferred,identifierType:(uuid,display))' +
|
|
@@ -50,6 +77,44 @@ const obsRep =
|
|
|
50
77
|
/** A stable identity, so the unfiltered reads do not rebuild their memos on every render. */
|
|
51
78
|
const noFilters: EncounterFilters = {};
|
|
52
79
|
|
|
80
|
+
const userRep = 'custom:(uuid,display,username,systemId,retired,person:(uuid,display))';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The pihapps audit endpoint, which searches observations by the user who created or voided them —
|
|
84
|
+
* something the core REST API cannot do, since neither the obs resource nor core's own
|
|
85
|
+
* `ObsSearchCriteria` has a creator or voidedBy field.
|
|
86
|
+
*/
|
|
87
|
+
const obsAuditUrl = `${restBaseUrl}/pihapps/obs`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The ordering an obs audit stream asks for: the column belonging to the action it searched on,
|
|
91
|
+
* most recent first. Ordering by the observation's own datetime would bury an obs backdated to last
|
|
92
|
+
* year but entered this morning, which is the opposite of what an audit needs; `obsId` breaks ties
|
|
93
|
+
* so that paging cannot repeat or skip a row when several share a timestamp.
|
|
94
|
+
*/
|
|
95
|
+
function obsSortQuery(actionDate: 'dateCreated' | 'dateVoided'): string {
|
|
96
|
+
return `&sortBy=${actionDate}-desc&sortBy=obsId-desc`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The whole encounter rather than a reference, so the observations a user touched can be grouped
|
|
101
|
+
* into encounters and listed without a further request per encounter.
|
|
102
|
+
*/
|
|
103
|
+
const obsAuditRep =
|
|
104
|
+
'custom:(uuid,voided,obsDatetime,concept:(uuid,display),auditInfo' +
|
|
105
|
+
',encounter:(uuid,display,encounterDatetime,voided,encounterType:(uuid,display)' +
|
|
106
|
+
',form:(uuid,display),location:(uuid,display),patient:(uuid,display)))';
|
|
107
|
+
|
|
108
|
+
const providerRep = 'custom:(uuid,display,identifier,retired,person:(uuid,display,gender))';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A provider's encounters span patients, so the list names the patient and who entered it —
|
|
112
|
+
* `encounterListRep` carries only the patient's uuid, which is all a single patient's list needs.
|
|
113
|
+
*/
|
|
114
|
+
const providerEncounterRep =
|
|
115
|
+
'custom:(uuid,display,encounterDatetime,voided,patient:(uuid,display),encounterType:(uuid,display)' +
|
|
116
|
+
',form:(uuid,display),location:(uuid,display),auditInfo)';
|
|
117
|
+
|
|
53
118
|
/** Just enough of an obs to say who touched it and when. */
|
|
54
119
|
const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)';
|
|
55
120
|
|
|
@@ -68,27 +133,48 @@ const bulkPageSize = 100;
|
|
|
68
133
|
const maxBulkPages = 25;
|
|
69
134
|
|
|
70
135
|
/**
|
|
71
|
-
*
|
|
136
|
+
* A last-resort ceiling for a read that is meant to be exhaustive, so that a paging bug on either
|
|
137
|
+
* side cannot spin forever. It sits far above any real audit trail; reaching it means something is
|
|
138
|
+
* wrong, which is why it warns and is reported rather than silently truncating.
|
|
139
|
+
*/
|
|
140
|
+
const runawayRowLimit = 100000;
|
|
141
|
+
|
|
142
|
+
interface FetchAllPagesOptions {
|
|
143
|
+
/** Stop after this many rows. Defaults to a bound suited to one encounter's observations. */
|
|
144
|
+
maxRows?: number;
|
|
145
|
+
/** Called with the running total after each page, for reads long enough to be worth reporting. */
|
|
146
|
+
onProgress?: (rowsRead: number) => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Reads the pages of a paginated REST endpoint imperatively.
|
|
72
151
|
*
|
|
73
|
-
* `useOpenmrsFetchAll` covers this wherever
|
|
74
|
-
* the
|
|
75
|
-
* per encounter
|
|
152
|
+
* `useOpenmrsFetchAll` covers this wherever one url is known at render time. Two callers cannot use
|
|
153
|
+
* it: the activity scan reads the observations of many encounters, and a hook cannot be called once
|
|
154
|
+
* per encounter; and the user audit scan needs to report its progress as it goes.
|
|
76
155
|
*/
|
|
77
|
-
async function fetchAllPages<T>(url: string): Promise<Array<T>> {
|
|
156
|
+
async function fetchAllPages<T>(url: string, options: FetchAllPagesOptions = {}): Promise<Array<T>> {
|
|
78
157
|
const results: Array<T> = [];
|
|
79
158
|
const separator = url.includes('?') ? '&' : '?';
|
|
159
|
+
const rowLimit = options.maxRows ?? maxBulkPages * bulkPageSize;
|
|
80
160
|
|
|
81
|
-
|
|
161
|
+
while (results.length < rowLimit) {
|
|
162
|
+
const pageSize = Math.min(bulkPageSize, rowLimit - results.length);
|
|
82
163
|
const response = await openmrsFetch<PagedResponse<T>>(
|
|
83
|
-
`${url}${separator}startIndex=${
|
|
164
|
+
`${url}${separator}startIndex=${results.length}&limit=${pageSize}`,
|
|
84
165
|
);
|
|
85
166
|
const batch = response?.data?.results ?? [];
|
|
86
167
|
results.push(...batch);
|
|
87
|
-
|
|
168
|
+
options.onProgress?.(results.length);
|
|
169
|
+
if (batch.length < pageSize) {
|
|
88
170
|
break;
|
|
89
171
|
}
|
|
90
172
|
}
|
|
91
173
|
|
|
174
|
+
if (results.length >= runawayRowLimit) {
|
|
175
|
+
console.warn(`Stopped reading ${url} after ${runawayRowLimit} rows; results are incomplete.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
92
178
|
return results;
|
|
93
179
|
}
|
|
94
180
|
|
|
@@ -216,6 +302,41 @@ export function useAllPatientEncounters(
|
|
|
216
302
|
};
|
|
217
303
|
}
|
|
218
304
|
|
|
305
|
+
/** One entry of the pihapps encounter type count endpoint. */
|
|
306
|
+
interface EncounterTypeCount {
|
|
307
|
+
encounterType: OpenmrsResourceRef;
|
|
308
|
+
count: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The encounter types this implementation actually records against, for a filter that cannot be
|
|
313
|
+
* narrowed to what a particular search contains. The user and provider drill-downs need this
|
|
314
|
+
* because they discover encounters lazily or page them on the server, and so do not know which
|
|
315
|
+
* types a trail holds until all of it is read.
|
|
316
|
+
*
|
|
317
|
+
* Every type in the system would be mostly noise, since which ones are in use varies a great deal
|
|
318
|
+
* by country, so the types come from pihapps' count endpoint, which is asked to leave out those
|
|
319
|
+
* with no encounters. Deleted encounters count, as they do everywhere else in an audit: a type whose
|
|
320
|
+
* encounters have all been deleted still has a trail to look at. Retired types are kept for the
|
|
321
|
+
* same reason, since encounters recorded before a type was retired can still be audited.
|
|
322
|
+
*/
|
|
323
|
+
export function useUsedEncounterTypes() {
|
|
324
|
+
const { data, error, isLoading } = useSWR<FetchResponse<{ results: Array<EncounterTypeCount> }>>(
|
|
325
|
+
`${restBaseUrl}/pihapps/encounterTypeCount?includeVoided=true&onlyUsed=true&v=custom:(uuid,display)`,
|
|
326
|
+
openmrsFetch,
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const encounterTypes = useMemo(
|
|
330
|
+
() =>
|
|
331
|
+
(data?.data?.results ?? [])
|
|
332
|
+
.map((entry) => entry.encounterType)
|
|
333
|
+
.sort((a, b) => (a.display ?? '').localeCompare(b.display ?? '')),
|
|
334
|
+
[data],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return { encounterTypes, error, isLoading };
|
|
338
|
+
}
|
|
339
|
+
|
|
219
340
|
/**
|
|
220
341
|
* The encounter types to offer as filters: the ones this patient's own encounters use, so the
|
|
221
342
|
* dropdown never offers a type that would return nothing. Read unfiltered, so choosing a type
|
|
@@ -407,3 +528,317 @@ export function usePatientActivity(
|
|
|
407
528
|
isLoading: isLoadingEncounters || obsResult.isLoading,
|
|
408
529
|
};
|
|
409
530
|
}
|
|
531
|
+
|
|
532
|
+
/** Searches providers by name or identifier, one page at a time. */
|
|
533
|
+
export function useProviderSearch(query: string, page: number, pageSize: number) {
|
|
534
|
+
const startIndex = (page - 1) * pageSize;
|
|
535
|
+
const url = query
|
|
536
|
+
? `${restBaseUrl}/provider?q=${encodeURIComponent(query)}&v=${providerRep}` +
|
|
537
|
+
`&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
|
|
538
|
+
: null;
|
|
539
|
+
|
|
540
|
+
const { data, error, isLoading } = useSWR<FetchResponse<PagedResponse<AuditProvider>>>(url, openmrsFetch);
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
providers: data?.data?.results ?? [],
|
|
544
|
+
totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
|
|
545
|
+
error,
|
|
546
|
+
isLoading,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function useAuditProvider(providerUuid: string | null) {
|
|
551
|
+
const url = providerUuid ? `${restBaseUrl}/provider/${providerUuid}?v=${providerRep}` : null;
|
|
552
|
+
const { data, error, isLoading } = useSWR<FetchResponse<AuditProvider>>(url, openmrsFetch);
|
|
553
|
+
|
|
554
|
+
return { provider: data?.data, error, isLoading };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The encounters a provider is recorded on, newest first, a page at a time.
|
|
559
|
+
*
|
|
560
|
+
* Core cannot search encounters by provider: `EncounterSearchCriteria` carries a providers field
|
|
561
|
+
* but no search handler exposes it, and the free-text encounter search only matches patient name
|
|
562
|
+
* and identifier. The pihapps audit endpoint can, and it returns whole encounters including
|
|
563
|
+
* `auditInfo`, so one paged request per page is the whole cost — no second read per row, and no
|
|
564
|
+
* FHIR detour.
|
|
565
|
+
*/
|
|
566
|
+
const PROVIDER_ENCOUNTER_SORT = ['encounterDatetime-desc', 'encounterId-desc'];
|
|
567
|
+
|
|
568
|
+
export function useProviderEncounters(providerUuid: string | null, pageSize: number, filters: EncounterFilters) {
|
|
569
|
+
const filterQuery =
|
|
570
|
+
(filters.encounterType ? `&encounterType=${filters.encounterType.uuid}` : '') +
|
|
571
|
+
buildAuditDateQuery(filters, ENCOUNTER_DATE_PARAMS);
|
|
572
|
+
// `includeVoided=true` because this is an audit: the endpoint leaves voided encounters out unless
|
|
573
|
+
// asked, and an auditor wants to see what has since been deleted as much as what survives. The
|
|
574
|
+
// rows carry a `voided` flag so the table can mark them.
|
|
575
|
+
//
|
|
576
|
+
// The endpoint sorts nothing unless asked. A provider search names no audit action, so the most
|
|
577
|
+
// recent thing it is about is the encounter's own datetime; `encounterId` breaks ties so that
|
|
578
|
+
// paging cannot repeat or skip a row when several share a timestamp.
|
|
579
|
+
const url = providerUuid
|
|
580
|
+
? `${restBaseUrl}/pihapps/encounter?provider=${providerUuid}&includeVoided=true` +
|
|
581
|
+
`&sortBy=${PROVIDER_ENCOUNTER_SORT.join('&sortBy=')}&v=${providerEncounterRep}${filterQuery}`
|
|
582
|
+
: null;
|
|
583
|
+
// `useOpenmrsPagination` appends limit, startIndex and totalCount itself, so the url omits them.
|
|
584
|
+
const result = useOpenmrsPagination<AuditEncounter>(url as string, pageSize, restFetchOptions);
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Both filters narrow on the server, so a change makes the pages a different set. The hook keeps
|
|
588
|
+
* its own page number, so paging has to be sent back to the start or a filter applied on page
|
|
589
|
+
* three would ask for a page that may no longer exist.
|
|
590
|
+
*/
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
if (result.currentPage !== 1) {
|
|
593
|
+
result.goTo(1);
|
|
594
|
+
}
|
|
595
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
596
|
+
}, [filterQuery, providerUuid]);
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
encounters: result.data ?? [],
|
|
600
|
+
// The hook reports NaN until the first page has been read.
|
|
601
|
+
totalCount: Number.isNaN(result.totalCount) ? 0 : result.totalCount,
|
|
602
|
+
currentPage: result.currentPage,
|
|
603
|
+
goTo: result.goTo,
|
|
604
|
+
error: result.error,
|
|
605
|
+
isLoading: result.isLoading,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Searches user accounts by username, system id, or the person's name, one page at a time. */
|
|
610
|
+
export function useUserSearch(query: string, page: number, pageSize: number) {
|
|
611
|
+
const startIndex = (page - 1) * pageSize;
|
|
612
|
+
const url = query
|
|
613
|
+
? `${restBaseUrl}/user?q=${encodeURIComponent(query)}&v=${userRep}` +
|
|
614
|
+
`&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
|
|
615
|
+
: null;
|
|
616
|
+
|
|
617
|
+
const { data, error, isLoading } = useSWR<FetchResponse<PagedResponse<AuditUser>>>(url, openmrsFetch);
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
users: data?.data?.results ?? [],
|
|
621
|
+
totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
|
|
622
|
+
error,
|
|
623
|
+
isLoading,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function useAuditUser(userUuid: string | null) {
|
|
628
|
+
const url = userUuid ? `${restBaseUrl}/user/${userUuid}?v=${userRep}` : null;
|
|
629
|
+
const { data, error, isLoading } = useSWR<FetchResponse<AuditUser>>(url, openmrsFetch);
|
|
630
|
+
|
|
631
|
+
return { user: data?.data, error, isLoading };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Observations read per request while walking the audit searches. */
|
|
635
|
+
const auditScanPageSize = 100;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* A safety ceiling on how many requests one page of encounters may cost, so that a pathological
|
|
639
|
+
* trail — thousands of observations all on a handful of encounters — cannot spin indefinitely.
|
|
640
|
+
*/
|
|
641
|
+
const maxScanRequestsPerPage = 40;
|
|
642
|
+
|
|
643
|
+
/** Reads the next page of one of the two audit searches and folds it into that stream. */
|
|
644
|
+
async function extendStream(baseUrl: string, stream: ObsStream): Promise<ObsStream> {
|
|
645
|
+
const response = await openmrsFetch<PagedResponse<AuditObs>>(
|
|
646
|
+
`${baseUrl}&startIndex=${stream.obs.length}&limit=${auditScanPageSize}`,
|
|
647
|
+
);
|
|
648
|
+
const batch = response?.data?.results ?? [];
|
|
649
|
+
return { obs: [...stream.obs, ...batch], exhausted: batch.length < auditScanPageSize };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The encounters whose observations a given user recorded or deleted, most recently changed first,
|
|
654
|
+
* discovered a page at a time.
|
|
655
|
+
*
|
|
656
|
+
* `createdBy` and `voidedBy` narrow rather than widen on the audit endpoint, so "touched" means
|
|
657
|
+
* merging two searches. Neither is read to the end: both return observations by audit date
|
|
658
|
+
* descending, so the merge yields encounters in the order the list wants them, and reading stops as
|
|
659
|
+
* soon as enough encounters are certain to fill the page being viewed. Paging further reads further,
|
|
660
|
+
* and paging back costs nothing, since what has been read is kept.
|
|
661
|
+
*
|
|
662
|
+
* A date range is handed to both searches, which bound it against their own audit column, so a
|
|
663
|
+
* range narrows the trail on the server rather than after it has been read.
|
|
664
|
+
*
|
|
665
|
+
* See `discoverEncounters` for why partial reads still give a stable order, and `countUserObs` for
|
|
666
|
+
* how the per-encounter counts are made exact for the rows on screen.
|
|
667
|
+
*/
|
|
668
|
+
export function useUserEncounters(
|
|
669
|
+
userUuid: string | null,
|
|
670
|
+
pageSize: number,
|
|
671
|
+
dateRange: DateRange,
|
|
672
|
+
encounterTypeUuid?: string,
|
|
673
|
+
): {
|
|
674
|
+
activity: Array<UserEncounterActivity>;
|
|
675
|
+
currentPage: number;
|
|
676
|
+
goTo(page: number): void;
|
|
677
|
+
firstRowOnPage: number;
|
|
678
|
+
hasNextPage: boolean;
|
|
679
|
+
obsRead: number;
|
|
680
|
+
/** True when the scan gave up before the trail ran out, so more may match than is shown. */
|
|
681
|
+
stoppedEarly: boolean;
|
|
682
|
+
/**
|
|
683
|
+
* True while the trail is still being read behind a list that already has rows in it — which a
|
|
684
|
+
* filter makes common, since matching rows trickle in as more of the trail is read.
|
|
685
|
+
*/
|
|
686
|
+
isDiscovering: boolean;
|
|
687
|
+
error: unknown;
|
|
688
|
+
isLoading: boolean;
|
|
689
|
+
} {
|
|
690
|
+
const [page, setPage] = useState(1);
|
|
691
|
+
const [created, setCreated] = useState<ObsStream>(emptyStream);
|
|
692
|
+
const [voided, setVoided] = useState<ObsStream>(emptyStream);
|
|
693
|
+
const [isScanning, setIsScanning] = useState(false);
|
|
694
|
+
const [stoppedEarly, setStoppedEarly] = useState(false);
|
|
695
|
+
const [error, setError] = useState<unknown>(undefined);
|
|
696
|
+
|
|
697
|
+
// The range identifies the search rather than forming part of a url: each stream now builds its
|
|
698
|
+
// own date query against the column it searched on.
|
|
699
|
+
const dateKey = `${dateRange.fromDate ?? ''}..${dateRange.toDate ?? ''}`;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Identifies the search the streams belong to — the account and the range both, since changing
|
|
703
|
+
* either changes which observations exist — so a scan that was in flight when it changed cannot
|
|
704
|
+
* append its rows to the results of a different search.
|
|
705
|
+
*/
|
|
706
|
+
const scanToken = useRef<string | null>(null);
|
|
707
|
+
const searchKey = userUuid ? `${userUuid}${dateKey}` : null;
|
|
708
|
+
|
|
709
|
+
useEffect(() => {
|
|
710
|
+
scanToken.current = searchKey;
|
|
711
|
+
setPage(1);
|
|
712
|
+
setCreated(emptyStream);
|
|
713
|
+
setVoided(emptyStream);
|
|
714
|
+
setStoppedEarly(false);
|
|
715
|
+
setError(undefined);
|
|
716
|
+
}, [searchKey]);
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The type filter is applied here rather than by the server, so changing it does not invalidate
|
|
720
|
+
* what has been read — only which page of it is being looked at, and possibly how much more of
|
|
721
|
+
* the trail has to be read to fill that page.
|
|
722
|
+
*/
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
setPage(1);
|
|
725
|
+
}, [encounterTypeUuid]);
|
|
726
|
+
|
|
727
|
+
const discovered = useMemo(
|
|
728
|
+
() => filterByEncounterType(discoverEncounters(created, voided), encounterTypeUuid),
|
|
729
|
+
[created, encounterTypeUuid, voided],
|
|
730
|
+
);
|
|
731
|
+
const needed = page * pageSize;
|
|
732
|
+
// One more than the page needs, so that whether a next page exists is known without reading a
|
|
733
|
+
// whole further page of encounters.
|
|
734
|
+
const wanted = needed + 1;
|
|
735
|
+
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
const token = searchKey;
|
|
738
|
+
if (!userUuid || !token || discovered.length >= wanted || !canDiscoverMore(created, voided)) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let cancelled = false;
|
|
743
|
+
// `includeVoided=true` because this is an audit: the endpoint leaves voided observations out
|
|
744
|
+
// unless asked, and the voidedBy stream would come back empty without it. The rows carry a
|
|
745
|
+
// `voided` flag so what was deleted can be told from what survives.
|
|
746
|
+
//
|
|
747
|
+
// The ordering and the date range are per stream rather than on the base url: the endpoint
|
|
748
|
+
// sorts nothing unless asked and bounds no column unless told which, and each stream wants the
|
|
749
|
+
// column belonging to the action it searched on.
|
|
750
|
+
const baseUrl = `${obsAuditUrl}?includeVoided=true&v=${obsAuditRep}`;
|
|
751
|
+
|
|
752
|
+
(async () => {
|
|
753
|
+
setIsScanning(true);
|
|
754
|
+
try {
|
|
755
|
+
let nextCreated = created;
|
|
756
|
+
let nextVoided = voided;
|
|
757
|
+
for (let request = 0; request < maxScanRequestsPerPage; request++) {
|
|
758
|
+
const action = streamToExtend(nextCreated, nextVoided);
|
|
759
|
+
if (!action) {
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
if (action === 'created') {
|
|
763
|
+
nextCreated = await extendStream(
|
|
764
|
+
`${baseUrl}&createdBy=${userUuid}${obsSortQuery('dateCreated')}` +
|
|
765
|
+
buildAuditDateQuery(dateRange, OBS_CREATED_DATE_PARAMS),
|
|
766
|
+
nextCreated,
|
|
767
|
+
);
|
|
768
|
+
} else {
|
|
769
|
+
nextVoided = await extendStream(
|
|
770
|
+
`${baseUrl}&voidedBy=${userUuid}${obsSortQuery('dateVoided')}` +
|
|
771
|
+
buildAuditDateQuery(dateRange, OBS_VOIDED_DATE_PARAMS),
|
|
772
|
+
nextVoided,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (cancelled || scanToken.current !== token) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (filterByEncounterType(discoverEncounters(nextCreated, nextVoided), encounterTypeUuid).length >= wanted) {
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
setCreated(nextCreated);
|
|
783
|
+
setVoided(nextVoided);
|
|
784
|
+
// Whether it ran out of trail or out of patience is the difference between an empty result
|
|
785
|
+
// that means "nothing more" and one that means "not found yet".
|
|
786
|
+
setStoppedEarly(
|
|
787
|
+
canDiscoverMore(nextCreated, nextVoided) &&
|
|
788
|
+
filterByEncounterType(discoverEncounters(nextCreated, nextVoided), encounterTypeUuid).length < wanted,
|
|
789
|
+
);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
setError(e);
|
|
792
|
+
} finally {
|
|
793
|
+
if (!cancelled) {
|
|
794
|
+
setIsScanning(false);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
})();
|
|
798
|
+
|
|
799
|
+
return () => {
|
|
800
|
+
cancelled = true;
|
|
801
|
+
};
|
|
802
|
+
// `created` and `voided` are the accumulators this effect appends to; re-running on them is how
|
|
803
|
+
// it continues a scan that needed more than one round.
|
|
804
|
+
}, [created, voided, dateKey, dateRange, discovered.length, encounterTypeUuid, searchKey, userUuid, wanted]);
|
|
805
|
+
|
|
806
|
+
const firstRowOnPage = (page - 1) * pageSize;
|
|
807
|
+
const pageRows = useMemo(
|
|
808
|
+
() => discovered.slice(firstRowOnPage, firstRowOnPage + pageSize),
|
|
809
|
+
[discovered, firstRowOnPage, pageSize],
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
// Exact counts for the rows on screen only: one small read per encounter, not per audit trail.
|
|
813
|
+
const countsResult = useSWR<Record<string, { obsCreated: number; obsVoided: number }>>(
|
|
814
|
+
userUuid && pageRows.length ? ['audit-user-obs-counts', userUuid, pageRows.map((row) => row.encounter.uuid)] : null,
|
|
815
|
+
async () => {
|
|
816
|
+
const uuids = pageRows.map((row) => row.encounter.uuid);
|
|
817
|
+
const counted = await mapWithConcurrency(uuids, scanConcurrency, async (encounterUuid) => {
|
|
818
|
+
const obs = await fetchAllPages<AuditObs>(
|
|
819
|
+
`${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=custom:(uuid,voided,auditInfo)`,
|
|
820
|
+
);
|
|
821
|
+
return countUserObs(obs, userUuid);
|
|
822
|
+
});
|
|
823
|
+
return Object.fromEntries(uuids.map((encounterUuid, index) => [encounterUuid, counted[index]]));
|
|
824
|
+
},
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
const activity = useMemo(
|
|
828
|
+
() => pageRows.map((row) => ({ ...row, ...countsResult.data?.[row.encounter.uuid] })),
|
|
829
|
+
[countsResult.data, pageRows],
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
activity,
|
|
834
|
+
currentPage: page,
|
|
835
|
+
goTo: setPage,
|
|
836
|
+
firstRowOnPage,
|
|
837
|
+
hasNextPage: discovered.length > needed,
|
|
838
|
+
obsRead: created.obs.length + voided.obs.length,
|
|
839
|
+
stoppedEarly,
|
|
840
|
+
isDiscovering: isScanning && pageRows.length > 0,
|
|
841
|
+
error: error ?? countsResult.error,
|
|
842
|
+
isLoading: (isScanning && pageRows.length === 0) || (pageRows.length > 0 && countsResult.isLoading),
|
|
843
|
+
};
|
|
844
|
+
}
|
package/src/audit/audit.scss
CHANGED
|
@@ -169,3 +169,9 @@
|
|
|
169
169
|
.selectedRow td {
|
|
170
170
|
background-color: $color-blue-10;
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
/* Sits above the pager while the trail is still being read, so the list does not look finished. */
|
|
174
|
+
.discovering {
|
|
175
|
+
min-height: auto;
|
|
176
|
+
margin-bottom: layout.$spacing-03;
|
|
177
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { OpenmrsDateRangePicker, type OpenmrsDateRangePickerProps } from '@openmrs/esm-framework';
|
|
3
|
+
|
|
4
|
+
/** Reads `MM/DD/YYYY` into something shaped like the react-aria dates the real picker reports. */
|
|
5
|
+
function parsePickerDate(text: string | undefined) {
|
|
6
|
+
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text?.trim() ?? '');
|
|
7
|
+
if (!match) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const [, month, day, year] = match.map(Number);
|
|
11
|
+
return { toDate: () => new Date(year, month - 1, day) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stands in for `OpenmrsDateRangePicker` the way the real one behaves, which the framework's own
|
|
16
|
+
* mock does not: react-aria reports the range through `onChangeRaw`, and reports `null` on every
|
|
17
|
+
* change until both ends are complete. Type the range as `MM/DD/YYYY–MM/DD/YYYY`.
|
|
18
|
+
*/
|
|
19
|
+
export function stubDateRangePicker() {
|
|
20
|
+
jest
|
|
21
|
+
.mocked(OpenmrsDateRangePicker)
|
|
22
|
+
.mockImplementation(({ id, labelText, onChangeRaw }: OpenmrsDateRangePickerProps) => {
|
|
23
|
+
const [text, setText] = useState('');
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<label htmlFor={id}>{labelText}</label>
|
|
27
|
+
<input
|
|
28
|
+
id={id}
|
|
29
|
+
onChange={(event) => {
|
|
30
|
+
setText(event.target.value);
|
|
31
|
+
const [start, end] = event.target.value.split('–').map(parsePickerDate);
|
|
32
|
+
onChangeRaw?.(start && end ? ({ start, end } as never) : null);
|
|
33
|
+
}}
|
|
34
|
+
type="text"
|
|
35
|
+
value={text}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildAuditDateQuery,
|
|
3
|
+
ENCOUNTER_DATE_PARAMS,
|
|
4
|
+
fromDateKey,
|
|
5
|
+
fromPickerRange,
|
|
6
|
+
toPickerDefault,
|
|
7
|
+
hasDateRange,
|
|
8
|
+
OBS_CREATED_DATE_PARAMS,
|
|
9
|
+
OBS_VOIDED_DATE_PARAMS,
|
|
10
|
+
toDateKey,
|
|
11
|
+
} from './date-range';
|
|
12
|
+
|
|
13
|
+
describe('date keys', () => {
|
|
14
|
+
it('reads the calendar date a moment falls on', () => {
|
|
15
|
+
expect(toDateKey(new Date(2026, 8, 10, 14, 30))).toBe('2026-09-10');
|
|
16
|
+
expect(toDateKey(new Date(2026, 0, 5))).toBe('2026-01-05');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('has nothing to say about a missing or unparseable date', () => {
|
|
20
|
+
expect(toDateKey(null)).toBeUndefined();
|
|
21
|
+
expect(toDateKey(undefined)).toBeUndefined();
|
|
22
|
+
expect(toDateKey(new Date('nonsense'))).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('round-trips back to local midnight, not to UTC midnight', () => {
|
|
26
|
+
const date = fromDateKey('2026-09-10');
|
|
27
|
+
|
|
28
|
+
expect(date?.getHours()).toBe(0);
|
|
29
|
+
expect(toDateKey(date)).toBe('2026-09-10');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('has nothing to round-trip when no date is set', () => {
|
|
33
|
+
expect(fromDateKey(undefined)).toBeNull();
|
|
34
|
+
expect(fromDateKey('')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('fromPickerRange', () => {
|
|
39
|
+
// `new Date` would read a year below 100 as 19xx, where react-aria's `toDate` keeps it as typed
|
|
40
|
+
const pickerDate = (year: number, month: number, day: number) => ({
|
|
41
|
+
toDate: () => {
|
|
42
|
+
const date = new Date(2000, month - 1, day);
|
|
43
|
+
date.setFullYear(year);
|
|
44
|
+
return date;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reads a complete range as calendar dates', () => {
|
|
49
|
+
expect(fromPickerRange({ start: pickerDate(2026, 9, 1), end: pickerDate(2026, 9, 30) })).toEqual({
|
|
50
|
+
fromDate: '2026-09-01',
|
|
51
|
+
toDate: '2026-09-30',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** What the picker reports on every keystroke until both ends of the range are complete. */
|
|
56
|
+
it('reads an incomplete range as no range at all', () => {
|
|
57
|
+
expect(hasDateRange(fromPickerRange(null))).toBe(false);
|
|
58
|
+
expect(hasDateRange(fromPickerRange(undefined))).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** What the picker reports on the way to typing a year, as 2 and then 20 on the way to 2026. */
|
|
62
|
+
it('reads a range the picker would reject as no range at all', () => {
|
|
63
|
+
expect(hasDateRange(fromPickerRange({ start: pickerDate(2026, 9, 1), end: pickerDate(20, 9, 30) }))).toBe(false);
|
|
64
|
+
expect(hasDateRange(fromPickerRange({ start: pickerDate(20, 9, 1), end: pickerDate(2026, 9, 30) }))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('toPickerDefault', () => {
|
|
69
|
+
it('seeds the picker with a range that is set', () => {
|
|
70
|
+
expect(toPickerDefault({ fromDate: '2026-09-01', toDate: '2026-09-30' })).toEqual([
|
|
71
|
+
new Date(2026, 8, 1),
|
|
72
|
+
new Date(2026, 8, 30),
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** An empty value hides what react-aria holds of a half-typed range; see `toPickerDefault`. */
|
|
77
|
+
it('leaves the picker empty rather than holding an empty range', () => {
|
|
78
|
+
expect(toPickerDefault({})).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('hasDateRange', () => {
|
|
83
|
+
it('knows when either end is set', () => {
|
|
84
|
+
expect(hasDateRange({})).toBe(false);
|
|
85
|
+
expect(hasDateRange({ fromDate: '2026-09-01' })).toBe(true);
|
|
86
|
+
expect(hasDateRange({ toDate: '2026-09-30' })).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('buildAuditDateQuery', () => {
|
|
91
|
+
it('sends nothing when no range is set', () => {
|
|
92
|
+
expect(buildAuditDateQuery({}, OBS_CREATED_DATE_PARAMS)).toBe('');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bare dates on purpose: the pihapps endpoints read a date-only upper bound as the whole of that day,
|
|
97
|
+
* so spelling out a time would only risk disagreeing with it.
|
|
98
|
+
*/
|
|
99
|
+
it('sends the range as plain calendar dates', () => {
|
|
100
|
+
expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, OBS_CREATED_DATE_PARAMS)).toBe(
|
|
101
|
+
'&createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('sends whichever end is set on its own', () => {
|
|
106
|
+
expect(buildAuditDateQuery({ fromDate: '2026-09-01' }, OBS_CREATED_DATE_PARAMS)).toBe(
|
|
107
|
+
'&createdOnOrAfter=2026-09-01',
|
|
108
|
+
);
|
|
109
|
+
expect(buildAuditDateQuery({ toDate: '2026-09-30' }, OBS_CREATED_DATE_PARAMS)).toBe(
|
|
110
|
+
'&createdOnOrBefore=2026-09-30',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** Each stream of an obs audit bounds the column it searched on, so they differ from each other. */
|
|
115
|
+
it('names the bounds the way the voided obs stream does when asked to', () => {
|
|
116
|
+
expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, OBS_VOIDED_DATE_PARAMS)).toBe(
|
|
117
|
+
'&voidedOnOrAfter=2026-09-01&voidedOnOrBefore=2026-09-30',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** No two endpoints agree on what the bounds are called, hence the parameter. */
|
|
122
|
+
it('names the bounds the way the encounter endpoint does when asked to', () => {
|
|
123
|
+
expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, ENCOUNTER_DATE_PARAMS)).toBe(
|
|
124
|
+
'&encounterDatetimeOnOrAfter=2026-09-01&encounterDatetimeOnOrBefore=2026-09-30',
|
|
125
|
+
);
|
|
126
|
+
expect(buildAuditDateQuery({}, ENCOUNTER_DATE_PARAMS)).toBe('');
|
|
127
|
+
});
|
|
128
|
+
});
|