@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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
DataTableSkeleton,
|
|
6
|
+
InlineNotification,
|
|
7
|
+
Pagination,
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
Tag,
|
|
16
|
+
} from '@carbon/react';
|
|
17
|
+
import { ArrowLeftIcon, ErrorState, useConfig } from '@openmrs/esm-framework';
|
|
18
|
+
import { type Config } from '../config-schema';
|
|
19
|
+
import { formatAuditDatetime, formatUserAndDate } from './audit-format';
|
|
20
|
+
import { useAuditProvider, useProviderEncounters, useUsedEncounterTypes } from './audit.resource';
|
|
21
|
+
import { type EncounterFilters, hasActiveFilters } from './encounter-filters';
|
|
22
|
+
import EncounterFiltersBar from './encounter-filters.component';
|
|
23
|
+
import styles from './audit.scss';
|
|
24
|
+
|
|
25
|
+
interface ProviderEncountersProps {
|
|
26
|
+
providerUuid: string;
|
|
27
|
+
onSelectEncounter(encounterUuid: string): void;
|
|
28
|
+
onBackToSearch(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The encounters a provider is recorded on, as a way into each one's audit trail.
|
|
33
|
+
*
|
|
34
|
+
* This is "was a provider on", which is not the same as "entered or changed" — the Users tab
|
|
35
|
+
* answers that. The list names who entered each encounter so the distinction stays visible.
|
|
36
|
+
*/
|
|
37
|
+
export default function ProviderEncounters({
|
|
38
|
+
providerUuid,
|
|
39
|
+
onSelectEncounter,
|
|
40
|
+
onBackToSearch,
|
|
41
|
+
}: ProviderEncountersProps) {
|
|
42
|
+
const { t } = useTranslation();
|
|
43
|
+
const config = useConfig<Config>();
|
|
44
|
+
const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10);
|
|
45
|
+
const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
|
|
46
|
+
|
|
47
|
+
const [filters, setFilters] = useState<EncounterFilters>({});
|
|
48
|
+
|
|
49
|
+
const { provider, error: providerError, isLoading: isLoadingProvider } = useAuditProvider(providerUuid);
|
|
50
|
+
const { encounterTypes, isLoading: isLoadingTypes } = useUsedEncounterTypes();
|
|
51
|
+
const { encounters, totalCount, currentPage, goTo, error, isLoading } = useProviderEncounters(
|
|
52
|
+
providerUuid,
|
|
53
|
+
pageSize,
|
|
54
|
+
filters,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={styles.section}>
|
|
59
|
+
<Button className={styles.backButton} kind="ghost" onClick={onBackToSearch} renderIcon={ArrowLeftIcon} size="sm">
|
|
60
|
+
{t('backToProviderSearch', 'Back to provider search')}
|
|
61
|
+
</Button>
|
|
62
|
+
<div className={styles.contextHeader}>
|
|
63
|
+
<span className={styles.contextTitle}>{provider?.person?.display ?? provider?.display ?? ''}</span>
|
|
64
|
+
<span className={styles.contextSubtitle}>{provider?.identifier}</span>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/*
|
|
68
|
+
Both filters narrow on the server, so the page count and totals stay right — which is why
|
|
69
|
+
the encounter types come from those in use system-wide rather than from what is on screen.
|
|
70
|
+
*/}
|
|
71
|
+
<EncounterFiltersBar
|
|
72
|
+
encounterTypes={encounterTypes}
|
|
73
|
+
filters={filters}
|
|
74
|
+
isLoadingTypes={isLoadingTypes}
|
|
75
|
+
onChange={setFilters}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<InlineNotification
|
|
79
|
+
className={styles.inlineNotification}
|
|
80
|
+
hideCloseButton
|
|
81
|
+
kind="info"
|
|
82
|
+
lowContrast
|
|
83
|
+
subtitle={t(
|
|
84
|
+
'providerEncountersScope',
|
|
85
|
+
'These are the encounters this provider is recorded on, which is not the same as the ones they entered or changed — the last column names who did that, and the Users tab searches by it.',
|
|
86
|
+
)}
|
|
87
|
+
title={t('recordedAsProvider', 'Recorded as provider')}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{providerError || error ? (
|
|
91
|
+
<ErrorState error={providerError ?? error} headerTitle={t('encounters', 'Encounters')} />
|
|
92
|
+
) : isLoadingProvider || isLoading ? (
|
|
93
|
+
<DataTableSkeleton columnCount={7} compact role="progressbar" showHeader={false} showToolbar={false} />
|
|
94
|
+
) : encounters.length === 0 ? (
|
|
95
|
+
<p className={styles.emptyState}>
|
|
96
|
+
{hasActiveFilters(filters)
|
|
97
|
+
? t('noProviderEncountersMatch', 'No encounters for this provider match these filters.')
|
|
98
|
+
: t('noProviderEncounters', 'This provider is not recorded on any encounters.')}
|
|
99
|
+
</p>
|
|
100
|
+
) : (
|
|
101
|
+
<>
|
|
102
|
+
<TableContainer className={styles.tableContainer}>
|
|
103
|
+
<Table size="sm" useZebraStyles>
|
|
104
|
+
<TableHead>
|
|
105
|
+
<TableRow>
|
|
106
|
+
<TableHeader>{t('encounterDate', 'Encounter date')}</TableHeader>
|
|
107
|
+
<TableHeader>{t('patient', 'Patient')}</TableHeader>
|
|
108
|
+
<TableHeader>{t('encounterType', 'Encounter type')}</TableHeader>
|
|
109
|
+
<TableHeader>{t('form', 'Form')}</TableHeader>
|
|
110
|
+
<TableHeader>{t('location', 'Location')}</TableHeader>
|
|
111
|
+
<TableHeader>{t('status', 'Status')}</TableHeader>
|
|
112
|
+
<TableHeader>{t('createdBy', 'Created by')}</TableHeader>
|
|
113
|
+
</TableRow>
|
|
114
|
+
</TableHead>
|
|
115
|
+
<TableBody>
|
|
116
|
+
{encounters.map((encounter) => (
|
|
117
|
+
<TableRow
|
|
118
|
+
className={styles.clickableRow}
|
|
119
|
+
key={encounter.uuid}
|
|
120
|
+
onClick={() => onSelectEncounter(encounter.uuid)}>
|
|
121
|
+
<TableCell>
|
|
122
|
+
<button
|
|
123
|
+
className={styles.linkButton}
|
|
124
|
+
onClick={(event) => {
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
onSelectEncounter(encounter.uuid);
|
|
127
|
+
}}
|
|
128
|
+
type="button">
|
|
129
|
+
{formatAuditDatetime(encounter.encounterDatetime)}
|
|
130
|
+
</button>
|
|
131
|
+
</TableCell>
|
|
132
|
+
<TableCell>{encounter.patient?.display}</TableCell>
|
|
133
|
+
<TableCell>{encounter.encounterType?.display}</TableCell>
|
|
134
|
+
<TableCell>{encounter.form?.display}</TableCell>
|
|
135
|
+
<TableCell>{encounter.location?.display}</TableCell>
|
|
136
|
+
<TableCell>{encounter.voided ? <Tag type="red">{t('deleted', 'Deleted')}</Tag> : null}</TableCell>
|
|
137
|
+
<TableCell>
|
|
138
|
+
{formatUserAndDate(encounter.auditInfo?.creator, encounter.auditInfo?.dateCreated)}
|
|
139
|
+
</TableCell>
|
|
140
|
+
</TableRow>
|
|
141
|
+
))}
|
|
142
|
+
</TableBody>
|
|
143
|
+
</Table>
|
|
144
|
+
</TableContainer>
|
|
145
|
+
<Pagination
|
|
146
|
+
onChange={({ page: nextPage, pageSize: nextPageSize }) => {
|
|
147
|
+
setPageSize(nextPageSize);
|
|
148
|
+
goTo(nextPage);
|
|
149
|
+
}}
|
|
150
|
+
page={currentPage}
|
|
151
|
+
pageSize={pageSize}
|
|
152
|
+
pageSizes={pageSizes}
|
|
153
|
+
size="sm"
|
|
154
|
+
totalItems={totalCount}
|
|
155
|
+
/>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { SWRConfig } from 'swr';
|
|
5
|
+
import { openmrsFetch } from '@openmrs/esm-framework';
|
|
6
|
+
import ProviderSearch from './provider-search.component';
|
|
7
|
+
|
|
8
|
+
const mockOpenmrsFetch = jest.mocked(openmrsFetch);
|
|
9
|
+
|
|
10
|
+
const mockProviders = [
|
|
11
|
+
{
|
|
12
|
+
uuid: 'prov-1',
|
|
13
|
+
display: 'MCPRPG - Louidor Jean paul',
|
|
14
|
+
identifier: 'MCPRPG',
|
|
15
|
+
person: { uuid: 'person-1', display: 'Louidor Jean paul', gender: 'M' },
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function renderProviderSearch() {
|
|
20
|
+
const onSelectProvider = jest.fn();
|
|
21
|
+
render(
|
|
22
|
+
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
|
|
23
|
+
<ProviderSearch onSelectProvider={onSelectProvider} />
|
|
24
|
+
</SWRConfig>,
|
|
25
|
+
);
|
|
26
|
+
return onSelectProvider;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('<ProviderSearch />', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockOpenmrsFetch.mockResolvedValue({
|
|
32
|
+
data: { results: mockProviders, totalCount: 1 },
|
|
33
|
+
} as unknown as ReturnType<typeof openmrsFetch>);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('asks for a search term before searching', () => {
|
|
37
|
+
renderProviderSearch();
|
|
38
|
+
|
|
39
|
+
expect(screen.getByText(/enter a provider name or identifier/i)).toBeInTheDocument();
|
|
40
|
+
expect(mockOpenmrsFetch).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('searches providers by name or identifier and lists what it finds', async () => {
|
|
44
|
+
renderProviderSearch();
|
|
45
|
+
|
|
46
|
+
await userEvent.type(screen.getByRole('searchbox'), 'MCPRPG');
|
|
47
|
+
|
|
48
|
+
expect(await screen.findByRole('button', { name: 'Louidor Jean paul' })).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByRole('cell', { name: 'MCPRPG' })).toBeInTheDocument();
|
|
50
|
+
expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/provider?q=MCPRPG'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drills down into the provider that is clicked', async () => {
|
|
54
|
+
const onSelectProvider = renderProviderSearch();
|
|
55
|
+
|
|
56
|
+
await userEvent.type(screen.getByRole('searchbox'), 'MCPRPG');
|
|
57
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Louidor Jean paul' }));
|
|
58
|
+
|
|
59
|
+
expect(onSelectProvider).toHaveBeenCalledWith('prov-1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('says so when nothing matches', async () => {
|
|
63
|
+
mockOpenmrsFetch.mockResolvedValue({ data: { results: [], totalCount: 0 } } as unknown as ReturnType<
|
|
64
|
+
typeof openmrsFetch
|
|
65
|
+
>);
|
|
66
|
+
renderProviderSearch();
|
|
67
|
+
|
|
68
|
+
await userEvent.type(screen.getByRole('searchbox'), 'nobody');
|
|
69
|
+
|
|
70
|
+
expect(await screen.findByText(/no providers match "nobody"/i)).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DataTableSkeleton,
|
|
5
|
+
Pagination,
|
|
6
|
+
Search,
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableContainer,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableHeader,
|
|
13
|
+
TableRow,
|
|
14
|
+
} from '@carbon/react';
|
|
15
|
+
import { ErrorState, useConfig, useDebounce } from '@openmrs/esm-framework';
|
|
16
|
+
import { type Config } from '../config-schema';
|
|
17
|
+
import { useProviderSearch } from './audit.resource';
|
|
18
|
+
import styles from './audit.scss';
|
|
19
|
+
|
|
20
|
+
interface ProviderSearchProps {
|
|
21
|
+
onSelectProvider(providerUuid: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Finds the provider whose encounters are being audited, by name or by provider identifier.
|
|
26
|
+
*/
|
|
27
|
+
export default function ProviderSearch({ onSelectProvider }: ProviderSearchProps) {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const config = useConfig<Config>();
|
|
30
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
31
|
+
const [page, setPage] = useState(1);
|
|
32
|
+
const [pageSize, setPageSize] = useState(config.patientSearchPageSize ?? 10);
|
|
33
|
+
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 300);
|
|
34
|
+
const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
|
|
35
|
+
const { providers, totalCount, error, isLoading } = useProviderSearch(debouncedSearchTerm, page, pageSize);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setPage(1);
|
|
39
|
+
}, [debouncedSearchTerm]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<Search
|
|
44
|
+
className={styles.searchInput}
|
|
45
|
+
labelText={t('searchForProvider', 'Search for a provider by name or identifier')}
|
|
46
|
+
onChange={(event) => setSearchTerm(event.target.value)}
|
|
47
|
+
placeholder={t('searchForProvider', 'Search for a provider by name or identifier')}
|
|
48
|
+
size="lg"
|
|
49
|
+
value={searchTerm}
|
|
50
|
+
/>
|
|
51
|
+
{!debouncedSearchTerm ? (
|
|
52
|
+
<p className={styles.emptyState}>
|
|
53
|
+
{t(
|
|
54
|
+
'searchProviderToBegin',
|
|
55
|
+
'Enter a provider name or identifier to see the encounters they are recorded on.',
|
|
56
|
+
)}
|
|
57
|
+
</p>
|
|
58
|
+
) : error ? (
|
|
59
|
+
<ErrorState error={error} headerTitle={t('providerSearchResults', 'Provider search results')} />
|
|
60
|
+
) : isLoading ? (
|
|
61
|
+
<DataTableSkeleton columnCount={3} compact role="progressbar" showHeader={false} showToolbar={false} />
|
|
62
|
+
) : providers.length === 0 ? (
|
|
63
|
+
<p className={styles.emptyState}>
|
|
64
|
+
{t('noProvidersFound', 'No providers match "{{searchTerm}}".', { searchTerm: debouncedSearchTerm })}
|
|
65
|
+
</p>
|
|
66
|
+
) : (
|
|
67
|
+
<>
|
|
68
|
+
<TableContainer className={styles.tableContainer}>
|
|
69
|
+
<Table size="sm" useZebraStyles>
|
|
70
|
+
<TableHead>
|
|
71
|
+
<TableRow>
|
|
72
|
+
<TableHeader>{t('providerName', 'Provider name')}</TableHeader>
|
|
73
|
+
<TableHeader>{t('identifier', 'Identifier')}</TableHeader>
|
|
74
|
+
<TableHeader>{t('gender', 'Gender')}</TableHeader>
|
|
75
|
+
</TableRow>
|
|
76
|
+
</TableHead>
|
|
77
|
+
<TableBody>
|
|
78
|
+
{providers.map((provider) => (
|
|
79
|
+
<TableRow
|
|
80
|
+
className={styles.clickableRow}
|
|
81
|
+
key={provider.uuid}
|
|
82
|
+
onClick={() => onSelectProvider(provider.uuid)}>
|
|
83
|
+
<TableCell>
|
|
84
|
+
<button
|
|
85
|
+
className={styles.linkButton}
|
|
86
|
+
onClick={(event) => {
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
onSelectProvider(provider.uuid);
|
|
89
|
+
}}
|
|
90
|
+
type="button">
|
|
91
|
+
{provider.person?.display ?? provider.display}
|
|
92
|
+
</button>
|
|
93
|
+
</TableCell>
|
|
94
|
+
<TableCell>{provider.identifier}</TableCell>
|
|
95
|
+
<TableCell>{provider.person?.gender}</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</TableContainer>
|
|
101
|
+
<Pagination
|
|
102
|
+
onChange={({ page: nextPage, pageSize: nextPageSize }) => {
|
|
103
|
+
setPage(nextPage);
|
|
104
|
+
setPageSize(nextPageSize);
|
|
105
|
+
}}
|
|
106
|
+
page={page}
|
|
107
|
+
pageSize={pageSize}
|
|
108
|
+
pageSizes={pageSizes}
|
|
109
|
+
size="sm"
|
|
110
|
+
totalItems={totalCount}
|
|
111
|
+
/>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|