@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,105 @@
|
|
|
1
|
+
import { type AuditEncounter, type OpenmrsResourceRef } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The filters an auditor can narrow a patient's encounter list by. Dates are held as calendar
|
|
5
|
+
* dates (`YYYY-MM-DD`) rather than instants, because that is what someone picking a date means,
|
|
6
|
+
* and both bounds are inclusive.
|
|
7
|
+
*/
|
|
8
|
+
export interface EncounterFilters {
|
|
9
|
+
/**
|
|
10
|
+
* The whole reference rather than just the uuid, so that the chosen type stays displayable even
|
|
11
|
+
* if it drops out of the available options — which happens when deleted encounters are hidden
|
|
12
|
+
* again and the type only occurred on those.
|
|
13
|
+
*/
|
|
14
|
+
encounterType?: OpenmrsResourceRef;
|
|
15
|
+
fromDate?: string;
|
|
16
|
+
toDate?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The encounter types a patient actually has, which is what their encounter list is worth
|
|
21
|
+
* filtering by — a dropdown of every type in the system would mostly offer types that return
|
|
22
|
+
* nothing for this patient.
|
|
23
|
+
*/
|
|
24
|
+
export function distinctEncounterTypes(encounters: Array<AuditEncounter>): Array<OpenmrsResourceRef> {
|
|
25
|
+
const byUuid = new Map<string, OpenmrsResourceRef>();
|
|
26
|
+
for (const encounter of encounters) {
|
|
27
|
+
if (encounter.encounterType?.uuid) {
|
|
28
|
+
byUuid.set(encounter.encounterType.uuid, encounter.encounterType);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Array.from(byUuid.values()).sort((a, b) => (a.display ?? '').localeCompare(b.display ?? ''));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pad(value: number): string {
|
|
35
|
+
return String(value).padStart(2, '0');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The calendar date a `Date` falls on, in the browser's time zone. */
|
|
39
|
+
export function toDateKey(date: Date | null | undefined): string | undefined {
|
|
40
|
+
if (!date || Number.isNaN(date.getTime())) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Local midnight on a calendar date, for handing a stored filter back to the date picker. */
|
|
47
|
+
export function fromDateKey(key: string | undefined): Date | null {
|
|
48
|
+
if (!key) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const [year, month, day] = key.split('-').map(Number);
|
|
52
|
+
if (!year || !month || !day) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return new Date(year, month - 1, day);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hasActiveFilters(filters: EncounterFilters): boolean {
|
|
59
|
+
return Boolean(filters.encounterType || filters.fromDate || filters.toDate);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The query parameters the encounter-by-patient search understands. The dates are sent without a
|
|
64
|
+
* time zone so the server reads them as its own wall clock — the facility's day, which is what the
|
|
65
|
+
* auditor picking a date means — and `todate` covers the whole of its day, since the API compares
|
|
66
|
+
* it against `encounterDatetime` with `<=`.
|
|
67
|
+
*/
|
|
68
|
+
export function buildEncounterFilterQuery(filters: EncounterFilters): string {
|
|
69
|
+
let query = '';
|
|
70
|
+
if (filters.encounterType) {
|
|
71
|
+
query += `&encounterType=${filters.encounterType.uuid}`;
|
|
72
|
+
}
|
|
73
|
+
if (filters.fromDate) {
|
|
74
|
+
query += `&fromdate=${filters.fromDate}T00:00:00`;
|
|
75
|
+
}
|
|
76
|
+
if (filters.toDate) {
|
|
77
|
+
query += `&todate=${filters.toDate}T23:59:59`;
|
|
78
|
+
}
|
|
79
|
+
return query;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The same filters applied on the client, for the free-text search used to list deleted
|
|
84
|
+
* encounters, which the REST API cannot filter server-side.
|
|
85
|
+
*/
|
|
86
|
+
export function matchesEncounterFilters(encounter: AuditEncounter, filters: EncounterFilters): boolean {
|
|
87
|
+
if (filters.encounterType && encounter.encounterType?.uuid !== filters.encounterType.uuid) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (filters.fromDate || filters.toDate) {
|
|
92
|
+
const day = encounter.encounterDatetime ? toDateKey(new Date(encounter.encounterDatetime)) : undefined;
|
|
93
|
+
if (!day) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (filters.fromDate && day < filters.fromDate) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (filters.toDate && day > filters.toDate) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Checkbox,
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableContainer,
|
|
9
|
+
TableHead,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableRow,
|
|
12
|
+
Tag,
|
|
13
|
+
} from '@carbon/react';
|
|
14
|
+
import { type AuditObs, type ObsChangeStatus, type ObsTreeNode } from '../types';
|
|
15
|
+
import { formatUserAndDate } from './audit-format';
|
|
16
|
+
import { flattenObsTree, formatObsValue, getConceptDescription, isVoidedStatus } from './obs-audit';
|
|
17
|
+
import styles from './audit.scss';
|
|
18
|
+
|
|
19
|
+
interface ObsAuditTableProps {
|
|
20
|
+
obsTree: Array<ObsTreeNode>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One indent step per level of obs group nesting. */
|
|
24
|
+
const indentPerLevel = 1.5;
|
|
25
|
+
|
|
26
|
+
function collectObs(nodes: Array<ObsTreeNode>, into: Map<string, AuditObs>): Map<string, AuditObs> {
|
|
27
|
+
for (const node of nodes) {
|
|
28
|
+
into.set(node.obs.uuid, node.obs);
|
|
29
|
+
collectObs(node.children, into);
|
|
30
|
+
}
|
|
31
|
+
return into;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The audit trail for one encounter's observations: what was entered, what was changed, what was
|
|
36
|
+
* deleted, and by whom. Deleted obs are struck through, as they were on the legacy admin page, and
|
|
37
|
+
* every obs carries the change status derived by `getObsChangeStatus`.
|
|
38
|
+
*/
|
|
39
|
+
export default function ObsAuditTable({ obsTree }: ObsAuditTableProps) {
|
|
40
|
+
const { t, i18n } = useTranslation();
|
|
41
|
+
const [showDeleted, setShowDeleted] = useState(true);
|
|
42
|
+
const [showDescriptions, setShowDescriptions] = useState(false);
|
|
43
|
+
|
|
44
|
+
/** The tag and the legend entry for each status that is worth calling out. */
|
|
45
|
+
const statusMeta = useMemo(
|
|
46
|
+
() => ({
|
|
47
|
+
edited: {
|
|
48
|
+
label: t('edited', 'Edited'),
|
|
49
|
+
type: 'blue' as const,
|
|
50
|
+
legend: t('editedLegend', 'the value was changed after it was first recorded'),
|
|
51
|
+
},
|
|
52
|
+
addedAfterEncounter: {
|
|
53
|
+
label: t('addedLater', 'Added later'),
|
|
54
|
+
type: 'teal' as const,
|
|
55
|
+
legend: t('addedLaterLegend', 'recorded after the encounter itself was created'),
|
|
56
|
+
},
|
|
57
|
+
deleted: {
|
|
58
|
+
label: t('deleted', 'Deleted'),
|
|
59
|
+
type: 'red' as const,
|
|
60
|
+
legend: t('deletedLegend', 'removed from the encounter'),
|
|
61
|
+
},
|
|
62
|
+
supersededValue: {
|
|
63
|
+
label: t('previousValue', 'Previous value'),
|
|
64
|
+
type: 'gray' as const,
|
|
65
|
+
legend: t('previousValueLegend', 'the value that an edit replaced'),
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
[t],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const obsByUuid = useMemo(() => collectObs(obsTree, new Map<string, AuditObs>()), [obsTree]);
|
|
72
|
+
const rows = useMemo(() => flattenObsTree(obsTree, showDeleted), [obsTree, showDeleted]);
|
|
73
|
+
const hasDeletedObs = useMemo(() => Array.from(obsByUuid.values()).some((obs) => obs.voided), [obsByUuid]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<h3 className={styles.sectionHeading}>{t('observations', 'Observations')}</h3>
|
|
78
|
+
<div className={styles.toolbar}>
|
|
79
|
+
<Checkbox
|
|
80
|
+
checked={showDeleted}
|
|
81
|
+
disabled={!hasDeletedObs}
|
|
82
|
+
id="show-deleted-obs"
|
|
83
|
+
labelText={t('showDeletedObs', 'Show deleted observations')}
|
|
84
|
+
onChange={(_event, { checked }) => setShowDeleted(checked)}
|
|
85
|
+
/>
|
|
86
|
+
<Checkbox
|
|
87
|
+
checked={showDescriptions}
|
|
88
|
+
id="show-obs-descriptions"
|
|
89
|
+
labelText={t('showConceptDescriptions', 'Show concept descriptions')}
|
|
90
|
+
onChange={(_event, { checked }) => setShowDescriptions(checked)}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className={styles.legend}>
|
|
94
|
+
{Object.entries(statusMeta).map(([status, meta]) => (
|
|
95
|
+
<span className={styles.legendItem} key={status}>
|
|
96
|
+
<Tag type={meta.type}>{meta.label}</Tag>
|
|
97
|
+
{meta.legend}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{rows.length === 0 ? (
|
|
103
|
+
<p className={styles.emptyState}>{t('noObservations', 'This encounter has no observations.')}</p>
|
|
104
|
+
) : (
|
|
105
|
+
<TableContainer className={styles.tableContainer}>
|
|
106
|
+
<Table size="sm">
|
|
107
|
+
<TableHead>
|
|
108
|
+
<TableRow>
|
|
109
|
+
<TableHeader>{t('questionConcept', 'Question concept')}</TableHeader>
|
|
110
|
+
<TableHeader>{t('value', 'Value')}</TableHeader>
|
|
111
|
+
<TableHeader>{t('change', 'Change')}</TableHeader>
|
|
112
|
+
<TableHeader>{t('recordedBy', 'Recorded by')}</TableHeader>
|
|
113
|
+
<TableHeader>{t('deletedBy', 'Deleted by')}</TableHeader>
|
|
114
|
+
</TableRow>
|
|
115
|
+
</TableHead>
|
|
116
|
+
<TableBody>
|
|
117
|
+
{rows.map(({ obs, status, depth, children }) => {
|
|
118
|
+
const meta = status === 'unchanged' ? undefined : statusMeta[status];
|
|
119
|
+
const previous = obs.previousVersion ? obsByUuid.get(obs.previousVersion.uuid) : undefined;
|
|
120
|
+
const description = showDescriptions ? getConceptDescription(obs, i18n?.language) : undefined;
|
|
121
|
+
const isGroup = children.length > 0;
|
|
122
|
+
const indent = { paddingInlineStart: `${depth * indentPerLevel}rem` };
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<React.Fragment key={obs.uuid}>
|
|
126
|
+
<TableRow className={isVoidedStatus(status) ? styles.voidedRow : undefined}>
|
|
127
|
+
<TableCell>
|
|
128
|
+
<span className={styles.obsConcept} style={indent}>
|
|
129
|
+
{isGroup ? <strong>{obs.concept?.display}</strong> : obs.concept?.display}
|
|
130
|
+
</span>
|
|
131
|
+
</TableCell>
|
|
132
|
+
<TableCell>
|
|
133
|
+
{isGroup ? null : formatObsValue(obs.value)}
|
|
134
|
+
{previous ? (
|
|
135
|
+
<div className={styles.obsDescription}>
|
|
136
|
+
{t('wasPreviously', 'was {{value}}', { value: formatObsValue(previous.value) })}
|
|
137
|
+
</div>
|
|
138
|
+
) : null}
|
|
139
|
+
{obs.comment ? <div className={styles.obsDescription}>{obs.comment}</div> : null}
|
|
140
|
+
</TableCell>
|
|
141
|
+
<TableCell>{meta ? <Tag type={meta.type}>{meta.label}</Tag> : null}</TableCell>
|
|
142
|
+
<TableCell>
|
|
143
|
+
<div>{formatUserAndDate(obs.auditInfo?.creator, obs.auditInfo?.dateCreated)}</div>
|
|
144
|
+
{obs.auditInfo?.changedBy ? (
|
|
145
|
+
<div className={styles.obsDescription}>
|
|
146
|
+
{t('changedByUser', 'Changed by {{user}}', {
|
|
147
|
+
user: formatUserAndDate(obs.auditInfo.changedBy, obs.auditInfo.dateChanged),
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
) : null}
|
|
151
|
+
</TableCell>
|
|
152
|
+
<TableCell>
|
|
153
|
+
{obs.voided ? (
|
|
154
|
+
<>
|
|
155
|
+
<div>{formatUserAndDate(obs.auditInfo?.voidedBy, obs.auditInfo?.dateVoided)}</div>
|
|
156
|
+
{obs.auditInfo?.voidReason ? (
|
|
157
|
+
<div className={styles.obsDescription}>{obs.auditInfo.voidReason}</div>
|
|
158
|
+
) : null}
|
|
159
|
+
</>
|
|
160
|
+
) : null}
|
|
161
|
+
</TableCell>
|
|
162
|
+
</TableRow>
|
|
163
|
+
{description ? (
|
|
164
|
+
<TableRow>
|
|
165
|
+
<TableCell colSpan={5}>
|
|
166
|
+
<div className={styles.obsDescription} style={indent}>
|
|
167
|
+
{description}
|
|
168
|
+
</div>
|
|
169
|
+
</TableCell>
|
|
170
|
+
</TableRow>
|
|
171
|
+
) : null}
|
|
172
|
+
</React.Fragment>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</TableBody>
|
|
176
|
+
</Table>
|
|
177
|
+
</TableContainer>
|
|
178
|
+
)}
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type AuditEncounter, type AuditObs } from '../types';
|
|
2
|
+
import {
|
|
3
|
+
buildObsTree,
|
|
4
|
+
flattenObsTree,
|
|
5
|
+
formatObsValue,
|
|
6
|
+
getConceptDescription,
|
|
7
|
+
getObsChangeStatus,
|
|
8
|
+
isVoidedStatus,
|
|
9
|
+
} from './obs-audit';
|
|
10
|
+
|
|
11
|
+
const encounterCreated = '2026-09-01T11:09:00.000+0000';
|
|
12
|
+
const laterThanEncounter = '2026-09-02T08:30:00.000+0000';
|
|
13
|
+
|
|
14
|
+
const encounter: AuditEncounter = {
|
|
15
|
+
uuid: 'enc-1',
|
|
16
|
+
auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function obs(overrides: Partial<AuditObs> & { uuid: string }): AuditObs {
|
|
20
|
+
return {
|
|
21
|
+
concept: { uuid: `concept-${overrides.uuid}`, display: 'Pulse' },
|
|
22
|
+
auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated },
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('getObsChangeStatus', () => {
|
|
28
|
+
it('reports an obs created with its encounter as unchanged', () => {
|
|
29
|
+
expect(getObsChangeStatus(obs({ uuid: 'a' }), encounter, new Set())).toBe('unchanged');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports an obs that replaced an earlier one as edited', () => {
|
|
33
|
+
const edited = obs({ uuid: 'a', previousVersion: { uuid: 'b' }, auditInfo: { dateCreated: laterThanEncounter } });
|
|
34
|
+
expect(getObsChangeStatus(edited, encounter, new Set())).toBe('edited');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('reports an obs created after its encounter as added later', () => {
|
|
38
|
+
const added = obs({ uuid: 'a', auditInfo: { dateCreated: laterThanEncounter } });
|
|
39
|
+
expect(getObsChangeStatus(added, encounter, new Set())).toBe('addedAfterEncounter');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('reports a voided obs as deleted', () => {
|
|
43
|
+
expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set())).toBe('deleted');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports a voided obs that a later obs replaced as a superseded value', () => {
|
|
47
|
+
expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set(['a']))).toBe('supersededValue');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not guess when either creation date is missing', () => {
|
|
51
|
+
expect(getObsChangeStatus(obs({ uuid: 'a', auditInfo: {} }), encounter, new Set())).toBe('unchanged');
|
|
52
|
+
expect(getObsChangeStatus(obs({ uuid: 'a' }), undefined, new Set())).toBe('unchanged');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('knows which statuses mean the obs row is voided', () => {
|
|
56
|
+
expect(isVoidedStatus('deleted')).toBe(true);
|
|
57
|
+
expect(isVoidedStatus('supersededValue')).toBe(true);
|
|
58
|
+
expect(isVoidedStatus('edited')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('buildObsTree', () => {
|
|
63
|
+
it('nests obs group members under their group and tags each obs with its status', () => {
|
|
64
|
+
const tree = buildObsTree(
|
|
65
|
+
[
|
|
66
|
+
obs({ uuid: 'member', obsGroup: { uuid: 'group' }, concept: { uuid: 'c-2', display: 'Sign/Symptom name' } }),
|
|
67
|
+
obs({ uuid: 'group', concept: { uuid: 'c-1', display: 'Sign/Symptom construct' } }),
|
|
68
|
+
],
|
|
69
|
+
encounter,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(tree).toHaveLength(1);
|
|
73
|
+
expect(tree[0].obs.uuid).toBe('group');
|
|
74
|
+
expect(tree[0].depth).toBe(0);
|
|
75
|
+
expect(tree[0].status).toBe('unchanged');
|
|
76
|
+
expect(tree[0].children.map((child) => child.obs.uuid)).toEqual(['member']);
|
|
77
|
+
expect(tree[0].children[0].depth).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps an obs whose group is missing from the list at the top level', () => {
|
|
81
|
+
const tree = buildObsTree([obs({ uuid: 'orphan', obsGroup: { uuid: 'not-loaded' } })], encounter);
|
|
82
|
+
|
|
83
|
+
expect(tree.map((node) => node.obs.uuid)).toEqual(['orphan']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('puts the versions of one value together, newest first', () => {
|
|
87
|
+
const tree = buildObsTree(
|
|
88
|
+
[
|
|
89
|
+
obs({
|
|
90
|
+
uuid: 'pulse-old',
|
|
91
|
+
value: 67,
|
|
92
|
+
voided: true,
|
|
93
|
+
formFieldPath: 'covid.pulse',
|
|
94
|
+
auditInfo: { dateCreated: encounterCreated },
|
|
95
|
+
}),
|
|
96
|
+
obs({ uuid: 'temperature', value: 38, formFieldPath: 'covid.temperature' }),
|
|
97
|
+
obs({
|
|
98
|
+
uuid: 'pulse-new',
|
|
99
|
+
value: 68,
|
|
100
|
+
previousVersion: { uuid: 'pulse-old' },
|
|
101
|
+
formFieldPath: 'covid.pulse',
|
|
102
|
+
auditInfo: { dateCreated: laterThanEncounter },
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
encounter,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(tree.map((node) => [node.obs.uuid, node.status])).toEqual([
|
|
109
|
+
['pulse-new', 'edited'],
|
|
110
|
+
['pulse-old', 'supersededValue'],
|
|
111
|
+
['temperature', 'unchanged'],
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('flattenObsTree', () => {
|
|
117
|
+
const tree = buildObsTree(
|
|
118
|
+
[
|
|
119
|
+
obs({ uuid: 'group', voided: true, concept: { uuid: 'c-1', display: 'Group' } }),
|
|
120
|
+
obs({ uuid: 'member', voided: true, obsGroup: { uuid: 'group' } }),
|
|
121
|
+
obs({ uuid: 'kept' }),
|
|
122
|
+
],
|
|
123
|
+
encounter,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
it('returns every obs when deleted obs are shown', () => {
|
|
127
|
+
expect(flattenObsTree(tree, true).map((node) => node.obs.uuid)).toEqual(['group', 'member', 'kept']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('drops deleted obs, and anything nested inside them, when they are hidden', () => {
|
|
131
|
+
expect(flattenObsTree(tree, false).map((node) => node.obs.uuid)).toEqual(['kept']);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('formatObsValue', () => {
|
|
136
|
+
it('displays coded values by their name and scalar values as they are', () => {
|
|
137
|
+
expect(formatObsValue({ uuid: 'concept-yes', display: 'Yes' })).toBe('Yes');
|
|
138
|
+
expect(formatObsValue(68)).toBe('68');
|
|
139
|
+
expect(formatObsValue('advil 100')).toBe('advil 100');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('displays nothing for an obs with no value, such as an obs group', () => {
|
|
143
|
+
expect(formatObsValue(null)).toBe('');
|
|
144
|
+
expect(formatObsValue(undefined)).toBe('');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('getConceptDescription', () => {
|
|
149
|
+
const described = obs({
|
|
150
|
+
uuid: 'a',
|
|
151
|
+
concept: {
|
|
152
|
+
uuid: 'c-1',
|
|
153
|
+
display: 'Pulse',
|
|
154
|
+
descriptions: [
|
|
155
|
+
{ description: 'Battements par minute', locale: 'fr' },
|
|
156
|
+
{ description: 'Beats per minute', locale: 'en' },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('prefers the description matching the given locale', () => {
|
|
162
|
+
expect(getConceptDescription(described, 'en_GB')).toBe('Beats per minute');
|
|
163
|
+
expect(getConceptDescription(described, 'fr')).toBe('Battements par minute');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('falls back to the first description when nothing matches', () => {
|
|
167
|
+
expect(getConceptDescription(described, 'es')).toBe('Battements par minute');
|
|
168
|
+
expect(getConceptDescription(obs({ uuid: 'a' }), 'en')).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type AuditEncounter, type AuditObs, type ObsChangeStatus, type ObsTreeNode, type ObsValue } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Statuses that mean the obs row is voided in the database. */
|
|
4
|
+
const voidedStatuses: Array<ObsChangeStatus> = ['deleted', 'supersededValue'];
|
|
5
|
+
|
|
6
|
+
export function isVoidedStatus(status: ObsChangeStatus): boolean {
|
|
7
|
+
return voidedStatuses.includes(status);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toTime(date: string | undefined): number | null {
|
|
11
|
+
if (!date) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const time = new Date(date).getTime();
|
|
15
|
+
return Number.isNaN(time) ? null : time;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Works out how an obs came to look the way it does. This reproduces what the legacy
|
|
20
|
+
* `admin/encounters/encounter.form` page showed, using the data the REST API exposes:
|
|
21
|
+
*
|
|
22
|
+
* - an obs that points at a `previousVersion` replaced an earlier obs, so its value was edited;
|
|
23
|
+
* - an obs created later than its encounter was added after the encounter was first saved.
|
|
24
|
+
* OpenMRS stamps the same `dateCreated` on an encounter and on every obs saved with it, so an
|
|
25
|
+
* obs whose `dateCreated` differs from the encounter's was saved in a later transaction;
|
|
26
|
+
* - a voided obs was deleted, unless a surviving obs points back at it as its `previousVersion`,
|
|
27
|
+
* in which case it is the value that was edited away.
|
|
28
|
+
*
|
|
29
|
+
* @param obs the obs to classify
|
|
30
|
+
* @param encounter the encounter the obs belongs to, for its creation date
|
|
31
|
+
* @param supersededUuids uuids that some other obs in the encounter names as its `previousVersion`
|
|
32
|
+
*/
|
|
33
|
+
export function getObsChangeStatus(
|
|
34
|
+
obs: AuditObs,
|
|
35
|
+
encounter: AuditEncounter | undefined,
|
|
36
|
+
supersededUuids: Set<string>,
|
|
37
|
+
): ObsChangeStatus {
|
|
38
|
+
if (obs.voided) {
|
|
39
|
+
return supersededUuids.has(obs.uuid) ? 'supersededValue' : 'deleted';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (obs.previousVersion) {
|
|
43
|
+
return 'edited';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const obsCreated = toTime(obs.auditInfo?.dateCreated);
|
|
47
|
+
const encounterCreated = toTime(encounter?.auditInfo?.dateCreated);
|
|
48
|
+
if (obsCreated !== null && encounterCreated !== null && obsCreated !== encounterCreated) {
|
|
49
|
+
return 'addedAfterEncounter';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 'unchanged';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sorts obs so that every version of a value sits together, newest first. Obs recorded through a
|
|
57
|
+
* form share a `formFieldPath`, which also approximates the order of the fields on that form; obs
|
|
58
|
+
* recorded some other way fall back to the concept name.
|
|
59
|
+
*/
|
|
60
|
+
function compareObs(a: AuditObs, b: AuditObs): number {
|
|
61
|
+
const groupA = a.formFieldPath ?? a.concept?.display ?? '';
|
|
62
|
+
const groupB = b.formFieldPath ?? b.concept?.display ?? '';
|
|
63
|
+
if (groupA !== groupB) {
|
|
64
|
+
return groupA.localeCompare(groupB);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const createdA = toTime(a.auditInfo?.dateCreated) ?? 0;
|
|
68
|
+
const createdB = toTime(b.auditInfo?.dateCreated) ?? 0;
|
|
69
|
+
if (createdA !== createdB) {
|
|
70
|
+
return createdB - createdA;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return a.uuid.localeCompare(b.uuid);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Turns the flat list of obs the REST API returns for an encounter — which includes obs group
|
|
78
|
+
* members and voided obs — into the group hierarchy, tagging each obs with its change status.
|
|
79
|
+
*
|
|
80
|
+
* An obs whose group is not in the list (which the API should not return, but which would
|
|
81
|
+
* otherwise silently drop the obs) is treated as a root so that it stays visible.
|
|
82
|
+
*/
|
|
83
|
+
export function buildObsTree(obsList: Array<AuditObs>, encounter?: AuditEncounter): Array<ObsTreeNode> {
|
|
84
|
+
const supersededUuids = new Set(
|
|
85
|
+
obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const nodes = new Map<string, ObsTreeNode>(
|
|
89
|
+
obsList.map((obs) => [
|
|
90
|
+
obs.uuid,
|
|
91
|
+
{ obs, status: getObsChangeStatus(obs, encounter, supersededUuids), depth: 0, children: [] },
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const roots: Array<ObsTreeNode> = [];
|
|
96
|
+
for (const node of nodes.values()) {
|
|
97
|
+
const parent = node.obs.obsGroup?.uuid ? nodes.get(node.obs.obsGroup.uuid) : undefined;
|
|
98
|
+
if (parent) {
|
|
99
|
+
parent.children.push(node);
|
|
100
|
+
} else {
|
|
101
|
+
roots.push(node);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sortAndSetDepth = (siblings: Array<ObsTreeNode>, depth: number) => {
|
|
106
|
+
siblings.sort((a, b) => compareObs(a.obs, b.obs));
|
|
107
|
+
for (const sibling of siblings) {
|
|
108
|
+
sibling.depth = depth;
|
|
109
|
+
sortAndSetDepth(sibling.children, depth + 1);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
sortAndSetDepth(roots, 0);
|
|
113
|
+
|
|
114
|
+
return roots;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flattens the obs tree into the rows to render, dropping voided obs (along with anything nested
|
|
119
|
+
* inside them) unless deleted obs are being shown.
|
|
120
|
+
*/
|
|
121
|
+
export function flattenObsTree(nodes: Array<ObsTreeNode>, showDeleted: boolean): Array<ObsTreeNode> {
|
|
122
|
+
return nodes.flatMap((node) => {
|
|
123
|
+
if (!showDeleted && isVoidedStatus(node.status)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return [node, ...flattenObsTree(node.children, showDeleted)];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Renders an obs value for display. Coded obs (and the drug and location values OpenMRS stores
|
|
132
|
+
* alongside them) arrive as references rather than as scalars.
|
|
133
|
+
*/
|
|
134
|
+
export function formatObsValue(value: ObsValue | undefined): string {
|
|
135
|
+
if (value === null || value === undefined) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === 'object') {
|
|
139
|
+
return value.display ?? '';
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === 'boolean') {
|
|
142
|
+
return String(value);
|
|
143
|
+
}
|
|
144
|
+
return String(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Picks the concept description matching the given locale, falling back to whatever is there. */
|
|
148
|
+
export function getConceptDescription(obs: AuditObs, locale: string | undefined): string | undefined {
|
|
149
|
+
const descriptions = obs.concept?.descriptions;
|
|
150
|
+
if (!descriptions?.length) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const language = locale?.split(/[-_]/)[0];
|
|
154
|
+
const match = descriptions.find((description) => description.locale?.split(/[-_]/)[0] === language);
|
|
155
|
+
return (match ?? descriptions[0]).description;
|
|
156
|
+
}
|