@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.
Files changed (84) hide show
  1. package/.turbo/turbo-lint.log +14 -0
  2. package/.turbo/turbo-test.log +12735 -0
  3. package/.turbo/turbo-typescript.log +1 -0
  4. package/README.md +116 -0
  5. package/dist/115.js +1 -0
  6. package/dist/115.js.map +1 -0
  7. package/dist/117.js +1 -0
  8. package/dist/117.js.map +1 -0
  9. package/dist/240.js +1 -0
  10. package/dist/240.js.map +1 -0
  11. package/dist/349.js +1 -0
  12. package/dist/349.js.map +1 -0
  13. package/dist/446.js +1 -0
  14. package/dist/446.js.map +1 -0
  15. package/dist/455.js +1 -0
  16. package/dist/455.js.map +1 -0
  17. package/dist/466.js +1 -0
  18. package/dist/466.js.map +1 -0
  19. package/dist/508.js +1 -0
  20. package/dist/508.js.map +1 -0
  21. package/dist/61.js +1 -0
  22. package/dist/61.js.map +1 -0
  23. package/dist/689.js +1 -0
  24. package/dist/689.js.map +1 -0
  25. package/dist/711.js +1 -0
  26. package/dist/711.js.map +1 -0
  27. package/dist/712.js +1 -0
  28. package/dist/712.js.map +1 -0
  29. package/dist/771.js +1 -0
  30. package/dist/771.js.map +1 -0
  31. package/dist/859.js +1 -0
  32. package/dist/859.js.map +1 -0
  33. package/dist/912.js +1 -0
  34. package/dist/912.js.map +1 -0
  35. package/dist/913.js +1 -0
  36. package/dist/913.js.map +1 -0
  37. package/dist/94.js +49 -0
  38. package/dist/94.js.map +1 -0
  39. package/dist/989.js +1 -0
  40. package/dist/989.js.map +1 -0
  41. package/dist/main.js +3 -0
  42. package/dist/main.js.map +1 -0
  43. package/dist/pih-esm-audit-app.js +3 -0
  44. package/dist/pih-esm-audit-app.js.buildmanifest.json +628 -0
  45. package/dist/pih-esm-audit-app.js.map +1 -0
  46. package/dist/routes.json +1 -0
  47. package/jest.config.js +3 -0
  48. package/package.json +53 -0
  49. package/rspack.config.js +1 -0
  50. package/src/audit/audit-format.ts +24 -0
  51. package/src/audit/audit.component.tsx +69 -0
  52. package/src/audit/audit.resource.test.tsx +174 -0
  53. package/src/audit/audit.resource.ts +409 -0
  54. package/src/audit/audit.scss +171 -0
  55. package/src/audit/encounter-audit.component.test.tsx +242 -0
  56. package/src/audit/encounter-audit.component.tsx +204 -0
  57. package/src/audit/encounter-filters.component.tsx +75 -0
  58. package/src/audit/encounter-filters.test.ts +119 -0
  59. package/src/audit/encounter-filters.ts +105 -0
  60. package/src/audit/obs-audit-table.component.tsx +181 -0
  61. package/src/audit/obs-audit.test.ts +170 -0
  62. package/src/audit/obs-audit.ts +156 -0
  63. package/src/audit/patient-activity.component.test.tsx +191 -0
  64. package/src/audit/patient-activity.component.tsx +203 -0
  65. package/src/audit/patient-activity.test.ts +155 -0
  66. package/src/audit/patient-activity.ts +156 -0
  67. package/src/audit/patient-encounters.component.tsx +134 -0
  68. package/src/audit/patient-record.component.test.tsx +193 -0
  69. package/src/audit/patient-record.component.tsx +115 -0
  70. package/src/audit/patient-search.component.test.tsx +73 -0
  71. package/src/audit/patient-search.component.tsx +120 -0
  72. package/src/config-schema.ts +36 -0
  73. package/src/dashboard-link.component.tsx +38 -0
  74. package/src/dashboard.meta.ts +11 -0
  75. package/src/declarations.d.tsx +3 -0
  76. package/src/index.ts +33 -0
  77. package/src/root.component.test.tsx +58 -0
  78. package/src/root.component.tsx +16 -0
  79. package/src/routes.json +20 -0
  80. package/src/types.ts +122 -0
  81. package/translations/en.json +89 -0
  82. package/translations/es.json +89 -0
  83. package/translations/fr.json +89 -0
  84. 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
+ }