diff --git a/packages/esm-audit-app/README.md b/packages/esm-audit-app/README.md
new file mode 100644
index 0000000..3f97c6d
--- /dev/null
+++ b/packages/esm-audit-app/README.md
@@ -0,0 +1,116 @@
+# @pih/esm-audit-app
+
+An audit trail for patient encounters and observations in the PIH EMR.
+
+This is the OpenMRS 3 replacement for the legacy `admin/encounters` pages, which let an auditor
+search for a patient and then read, for any one of their encounters, everything that had been
+entered, changed or deleted on it and by whom. It reads the same data through the OpenMRS REST API.
+
+## The audit trail
+
+The dashboard is a three step drill-down. Each step keeps its position in the page's query string,
+so a particular encounter's audit trail can be linked to and the browser's back button walks back
+up the drill-down.
+
+1. **Patient search** — find the patient by name or identifier
+ (`GET /ws/rest/v1/patient?q=…`).
+2. **Encounter list** — every encounter recorded for that patient, most recent first
+ (`GET /ws/rest/v1/encounter?patient=…&order=desc`, paged by the server through
+ `useOpenmrsPagination`), narrowable by encounter type and by the date the encounter happened. The type dropdown offers only the types this patient's own encounters
+ use, read from a scan of their encounters with a rep that carries nothing but the type, so it
+ never offers a type that would return nothing. Deleted encounters are hidden by default; see the
+ caveat below.
+3. **Encounter audit** — how the encounter itself was recorded (created by, changed by, deleted by,
+ with the reason), its providers, and the full history of its observations
+ (`GET /ws/rest/v1/encounter/{uuid}` and `GET /ws/rest/v1/obs?encounter={uuid}&includeAll=true`).
+
+The patient step has a second tab, **Record activity**, which answers "who touched this record":
+one row per user with what they created, edited and deleted, and the individual events behind those
+counts, each linking to the encounter it happened on. Clicking a user's name narrows the activity
+log to that user, and clicking it again — or dismissing the tag beside the log's heading — widens
+it back out. The tab reads the same filters as the encounter list, so narrowing by type or date
+narrows the activity too.
+
+### How each observation is classified
+
+OpenMRS never overwrites an obs: editing one voids the old row and saves a new row that points back
+at it through `previousVersion`. The obs search with `includeAll=true` returns every obs row for the
+encounter — group members and voided rows included — which is enough to reconstruct what happened.
+`src/audit/obs-audit.ts` derives one status per obs, and the table tags them accordingly:
+
+| Status | What it means |
+| ----------------- | --------------------------------------------------------------------------------------------------------------- |
+| _(no tag)_ | Entered when the encounter was first saved, and untouched since. |
+| **Edited** | The obs points at a `previousVersion`, so its value was changed after it was first recorded. |
+| **Added later** | The obs's `dateCreated` differs from the encounter's, so it was saved in a later transaction than the encounter. |
+| **Deleted** | The obs is voided and nothing replaced it — the value was removed from the encounter. |
+| **Previous value**| The obs is voided and a surviving obs names it as its `previousVersion` — this is the value an edit replaced. |
+
+Deleted and superseded obs are struck through, as they were on the legacy page, and each version of
+a value is listed directly beneath the version that replaced it. Obs group members are indented
+under their group.
+
+### Caveat: deleted encounters
+
+The REST API's encounter-by-patient search always excludes voided encounters and does not honour
+`includeAll`. Listing deleted encounters therefore falls back to the free-text encounter search,
+which does honour it, using the patient's preferred identifier as the search phrase; because that
+search matches any patient whose name or identifier contains the phrase, the results are narrowed
+back down to the patient in hand and paginated on the client — the one list that has to be read in
+full rather than a page at a time. A patient with no identifier at all cannot have their deleted
+encounters listed, and the page says so.
+
+That search also takes no filter parameters, so the encounter type and date range are applied by
+the server on the normal path (`encounterType`, `fromdate` and `todate`, which the API compares
+against `encounterDatetime` inclusively) and on the client on the deleted-encounter path.
+`src/audit/encounter-filters.ts` holds both, so the two paths cannot drift apart. Dates are sent to
+the server without a time zone, so it reads them as the facility's own day rather than as UTC
+instants.
+
+Because that search is the only way to see deleted encounters, it is also where the encounter types
+come from once deleted encounters are shown — otherwise a type used only by a deleted encounter
+would be missing from the dropdown. Both hooks build the same url, so SWR serves the second one
+from cache rather than reading it twice.
+
+### Caveat: what the activity view costs
+
+Encounters carry their own `auditInfo`, so who created, changed or deleted each one comes free with
+the encounter list.
+
+Observations do not. Each encounter's observations need their own request, because the
+obs-by-encounter search is the only one that honours `includeAll` and so the only one that can see
+deleted observations. The view reads every encounter the filters match, which makes it complete but
+means **one request per encounter** — five in flight at a time, with the running count shown while
+it works. On a long record over a slow link that is a real wait, and filtering by encounter type or
+date is the way to keep it short.
+
+This is a deliberate choice to favour completeness over speed while the view is new; a cap is the
+obvious lever if it proves too slow in the field.
+
+## Enabling the app
+
+Because `audit-dashboard-link` is declared against `homepage-dashboard-slot` in
+`routes.json`, it is attached automatically for every implementation that loads
+this module — no `add` entry is needed. The shared `base-config.json` in the
+[pihemr](https://github.com/PIH/pihemr) repo only pins where the link sits in
+the home nav, via that slot's `order` list.
+
+A site that does *not* want the dashboard adds `audit-dashboard-link` to the
+slot's `remove` list in its own `config.json`. Note that `remove` wins over
+`add` when an id appears in both, and that config arrays are replaced rather
+than merged, so such an override must repeat the whole `remove` list from
+`base-config.json`.
+
+Reading the audit trail needs the `View Encounters` and `View Observations` privileges, the same
+ones the legacy pages required.
+
+## Configuration
+
+See the [openmrs-esm-module-config docs](https://wiki.openmrs.org/display/projects/openmrs-esm-module-config)
+for information about how to provide configuration files.
+
+| Key | Default | Description |
+| ----------------------- | -------------------------------------------------- | -------------------------------------------------- |
+| `patientSearchPageSize` | `10` | Patients per page of search results. |
+| `encountersPageSize` | `10` | Encounters per page of a patient's encounter list. |
+| `patientChartUrl` | `${openmrsSpaBase}/patient/${patientUuid}/chart` | Where the patient's name in the encounter audit links to. |
diff --git a/packages/esm-audit-app/jest.config.js b/packages/esm-audit-app/jest.config.js
new file mode 100644
index 0000000..0352f62
--- /dev/null
+++ b/packages/esm-audit-app/jest.config.js
@@ -0,0 +1,3 @@
+const rootConfig = require('../../jest.config.js');
+
+module.exports = rootConfig;
diff --git a/packages/esm-audit-app/package.json b/packages/esm-audit-app/package.json
new file mode 100644
index 0000000..5e44b08
--- /dev/null
+++ b/packages/esm-audit-app/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@pih/esm-audit-app",
+ "version": "2.1.0",
+ "description": "An audit trail for patient encounters and observations in the PIH EMR",
+ "browser": "dist/pih-esm-audit-app.js",
+ "main": "src/index.ts",
+ "source": true,
+ "license": "MPL-2.0",
+ "homepage": "https://github.com/PIH/openmrs-esm-pihemr#readme",
+ "scripts": {
+ "start": "openmrs develop",
+ "serve": "rspack serve --mode=development",
+ "debug": "npm run serve",
+ "build": "rspack --mode production",
+ "analyze": "rspack --mode=production --env analyze=true",
+ "lint": "cross-env eslint src --ext ts,tsx",
+ "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color",
+ "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color",
+ "coverage": "yarn test --coverage",
+ "typescript": "tsc",
+ "extract-translations": "i18next 'src/**/*.component.tsx'"
+ },
+ "browserslist": [
+ "extends browserslist-config-openmrs"
+ ],
+ "keywords": [
+ "openmrs"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/PIH/openmrs-esm-pihemr.git"
+ },
+ "bugs": {
+ "url": "https://github.com/PIH/openmrs-esm-pihemr/issues"
+ },
+ "dependencies": {
+ "@babel/runtime": "^7.24.0"
+ },
+ "peerDependencies": {
+ "@openmrs/esm-framework": "8.x",
+ "i18next": "^19.0.0",
+ "react": "18.x",
+ "react-i18next": "16.x",
+ "react-router-dom": "^6.x",
+ "swr": "2.x"
+ },
+ "devDependencies": {
+ "openmrs": "^10.0.1-pre.5263"
+ }
+}
diff --git a/packages/esm-audit-app/rspack.config.js b/packages/esm-audit-app/rspack.config.js
new file mode 100644
index 0000000..b2bb3b9
--- /dev/null
+++ b/packages/esm-audit-app/rspack.config.js
@@ -0,0 +1 @@
+module.exports = require('openmrs/default-rspack-config');
diff --git a/packages/esm-audit-app/src/audit/audit-format.ts b/packages/esm-audit-app/src/audit/audit-format.ts
new file mode 100644
index 0000000..6706c0a
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/audit-format.ts
@@ -0,0 +1,24 @@
+import { formatDate, formatDatetime, parseDate } from '@openmrs/esm-framework';
+import { type OpenmrsResourceRef } from '../types';
+
+/** Formats a REST date, without the "Today" shorthand — an audit trail wants literal dates. */
+export function formatAuditDate(date: string | undefined): string {
+ return date ? formatDate(parseDate(date), { mode: 'wide', time: false, noToday: true }) : '';
+}
+
+export function formatAuditDatetime(date: string | undefined): string {
+ return date ? formatDatetime(parseDate(date), { mode: 'wide', noToday: true }) : '';
+}
+
+/**
+ * Renders the "who and when" of an audit entry, e.g. `Cos John — 01 Sep 2026, 11:09 AM`. The user
+ * reference the API returns for `creator`, `changedBy` and `voidedBy` displays as the person's
+ * name, or as the username where the account has no person name.
+ */
+export function formatUserAndDate(user: OpenmrsResourceRef | undefined, date: string | undefined): string {
+ const formattedDate = formatAuditDatetime(date);
+ if (user?.display && formattedDate) {
+ return `${user.display} — ${formattedDate}`;
+ }
+ return user?.display ?? formattedDate;
+}
diff --git a/packages/esm-audit-app/src/audit/audit.component.tsx b/packages/esm-audit-app/src/audit/audit.component.tsx
new file mode 100644
index 0000000..e72231e
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/audit.component.tsx
@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { PageHeader, PageHeaderContent, PatientSearchPictogram } from '@openmrs/esm-framework';
+import EncounterAudit from './encounter-audit.component';
+import PatientRecord, { type PatientRecordView, patientRecordViews } from './patient-record.component';
+import PatientSearch from './patient-search.component';
+import styles from './audit.scss';
+
+/**
+ * The audit trail is a three step drill-down — find a patient, pick one of their encounters, then
+ * read what was entered, changed and deleted on it. The current step is held in the query string
+ * so that a particular encounter's audit trail can be linked to and so that the browser's back
+ * button walks back up the drill-down.
+ */
+export default function Audit() {
+ const { t } = useTranslation();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const patientUuid = searchParams.get('patient');
+ const encounterUuid = searchParams.get('encounter');
+ const view = searchParams.get('view');
+ const patientRecordView: PatientRecordView = patientRecordViews.includes(view as PatientRecordView)
+ ? (view as PatientRecordView)
+ : 'encounters';
+
+ const goTo = useCallback(
+ (next: { patient?: string; encounter?: string; view?: PatientRecordView }) => {
+ const params = new URLSearchParams(searchParams);
+ params.delete('patient');
+ params.delete('encounter');
+ params.delete('view');
+ if (next.patient) {
+ params.set('patient', next.patient);
+ }
+ if (next.encounter) {
+ params.set('encounter', next.encounter);
+ }
+ if (next.view && next.view !== 'encounters') {
+ params.set('view', next.view);
+ }
+ setSearchParams(params);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ return (
+
+
+ } />
+
+ {encounterUuid ? (
+
goTo({ patient: uuid ?? patientUuid, view: patientRecordView })}
+ />
+ ) : patientUuid ? (
+ goTo({})}
+ onSelectEncounter={(uuid) => goTo({ patient: patientUuid, encounter: uuid, view: patientRecordView })}
+ onSelectView={(nextView) => goTo({ patient: patientUuid, view: nextView })}
+ patientUuid={patientUuid}
+ view={patientRecordView}
+ />
+ ) : (
+ goTo({ patient: uuid })} />
+ )}
+
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/audit.resource.test.tsx b/packages/esm-audit-app/src/audit/audit.resource.test.tsx
new file mode 100644
index 0000000..d73b1bb
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/audit.resource.test.tsx
@@ -0,0 +1,174 @@
+import React, { type PropsWithChildren } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { SWRConfig } from 'swr';
+import { openmrsFetch } from '@openmrs/esm-framework';
+import { type AuditEncounter, type AuditPatient } from '../types';
+import { usePatientEncounters, usePatientEncounterTypes } from './audit.resource';
+import { type EncounterFilters } from './encounter-filters';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+
+const patient: AuditPatient = {
+ uuid: 'patient-1',
+ display: 'Y2AHXV - Dave TestPatient',
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
+};
+
+const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' };
+const checkinType = { uuid: 'type-checkin', display: 'Inscription' };
+
+const april: AuditEncounter = {
+ uuid: 'enc-april',
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
+ patient: { uuid: 'patient-1', display: 'Dave TestPatient' },
+ encounterType: consultationType,
+};
+
+const may: AuditEncounter = {
+ uuid: 'enc-may',
+ encounterDatetime: '2026-05-02T09:00:00.000+0000',
+ voided: true,
+ patient: { uuid: 'patient-1', display: 'Dave TestPatient' },
+ encounterType: checkinType,
+};
+
+const wrapper = ({ children }: PropsWithChildren) => (
+ new Map() }}>{children}
+);
+
+function renderUsePatientEncounters(includeDeleted: boolean, filters: EncounterFilters) {
+ return renderHook(() => usePatientEncounters(patient, includeDeleted, filters, 10), { wrapper });
+}
+
+describe('usePatientEncounters', () => {
+ beforeEach(() => {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/encounter?q=')) {
+ return Promise.resolve({ data: { results: [april, may] } }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: [april], totalCount: 1 } }) as ReturnType;
+ });
+ });
+
+ it('reuses one read of the deleted-encounter search across both hooks', async () => {
+ const { result } = renderHook(
+ () => ({
+ encounters: usePatientEncounters(patient, true, {}, 10),
+ types: usePatientEncounterTypes(patient, true),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.types.encounterTypes).toHaveLength(2));
+ expect(result.current.encounters.encounters).toHaveLength(2);
+ expect(mockOpenmrsFetch.mock.calls.filter(([url]) => (url as string).includes('/encounter?q=')).length).toBe(1);
+ });
+
+ /** `useOpenmrsPagination` rebuilds the url through `URL`, so its params come back encoded. */
+ function lastRequestedUrl() {
+ return decodeURIComponent(mockOpenmrsFetch.mock.calls.at(-1)?.[0] as string);
+ }
+
+ it('has the server apply the filters, and page, when deleted encounters are excluded', async () => {
+ const { result } = renderUsePatientEncounters(false, {
+ encounterType: consultationType,
+ fromDate: '2026-04-01',
+ toDate: '2026-04-30',
+ });
+
+ await waitFor(() => expect(result.current.encounters).toHaveLength(1));
+ expect(lastRequestedUrl()).toContain(
+ '&encounterType=type-consultation&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59',
+ );
+ expect(lastRequestedUrl()).toContain('&limit=10&startIndex=0&totalCount=true');
+ });
+
+ it('asks the server for the next page rather than reading the whole record', async () => {
+ mockOpenmrsFetch.mockImplementation(
+ () => Promise.resolve({ data: { results: [april], totalCount: 30 } }) as ReturnType,
+ );
+ const { result } = renderUsePatientEncounters(false, {});
+
+ await waitFor(() => expect(result.current.totalCount).toBe(30));
+ expect(result.current.currentPage).toBe(1);
+
+ act(() => result.current.goTo(3));
+
+ await waitFor(() => expect(result.current.currentPage).toBe(3));
+ expect(lastRequestedUrl()).toContain('&limit=10&startIndex=20&totalCount=true');
+ });
+
+ it('applies the filters on the client when deleted encounters are included', async () => {
+ const { result } = renderUsePatientEncounters(true, { fromDate: '2026-05-01' });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.encounters.map((encounter) => encounter.uuid)).toEqual(['enc-may']);
+ expect(result.current.totalCount).toBe(1);
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/encounter?q=Y2AHXV&includeAll=true'));
+ });
+
+ it('returns both encounters when nothing is filtered', async () => {
+ const { result } = renderUsePatientEncounters(true, {});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.encounters.map((encounter) => encounter.uuid)).toEqual(['enc-may', 'enc-april']);
+ });
+
+ it('cannot list deleted encounters for a patient with no identifier', async () => {
+ const { result } = renderHook(
+ () => usePatientEncounters({ uuid: 'patient-2', display: 'No Identifier' }, true, {}, 10),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.cannotIncludeDeleted).toBe(true));
+ expect(result.current.encounters).toEqual([]);
+ });
+});
+
+describe('usePatientEncounterTypes', () => {
+ it("offers only the types the patient's own encounters use", async () => {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('totalCount=true')) {
+ return Promise.resolve({ data: { results: [], totalCount: 0 } }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: [april, { ...april, uuid: 'enc-again' }] } }) as ReturnType<
+ typeof openmrsFetch
+ >;
+ });
+
+ const { result } = renderHook(() => usePatientEncounterTypes(patient, false), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.encounterTypes).toEqual([consultationType]);
+ });
+
+ it('includes types that only deleted encounters use, once those are shown', async () => {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/encounter?q=')) {
+ return Promise.resolve({ data: { results: [april, may] } }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: [april] } }) as ReturnType;
+ });
+
+ const { result } = renderHook(() => usePatientEncounterTypes(patient, true), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.encounterTypes).toEqual([checkinType, consultationType]);
+ });
+
+ it("leaves out other patients' types when reading the deleted-encounter search", async () => {
+ mockOpenmrsFetch.mockImplementation(
+ () =>
+ Promise.resolve({
+ data: {
+ results: [april, { ...may, patient: { uuid: 'patient-2', display: 'Someone Else' } }],
+ },
+ }) as ReturnType,
+ );
+
+ const { result } = renderHook(() => usePatientEncounterTypes(patient, true), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.encounterTypes).toEqual([consultationType]);
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/audit.resource.ts b/packages/esm-audit-app/src/audit/audit.resource.ts
new file mode 100644
index 0000000..2858355
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/audit.resource.ts
@@ -0,0 +1,409 @@
+import { useEffect, useMemo, useState } from 'react';
+import useSWR from 'swr';
+import {
+ type FetchResponse,
+ openmrsFetch,
+ restBaseUrl,
+ useOpenmrsFetchAll,
+ useOpenmrsPagination,
+} from '@openmrs/esm-framework';
+import { type AuditEncounter, type AuditObs, type AuditPatient, type ObsTreeNode, type PagedResponse } from '../types';
+import {
+ buildEncounterFilterQuery,
+ distinctEncounterTypes,
+ type EncounterFilters,
+ matchesEncounterFilters,
+} from './encounter-filters';
+import { buildObsTree } from './obs-audit';
+import { buildAuditEvents, type ObsByEncounter, summariseByUser } from './patient-activity';
+
+const patientRep =
+ 'custom:(uuid,display,identifiers:(uuid,identifier,preferred,identifierType:(uuid,display))' +
+ ',person:(display,gender,age,birthdate,dead))';
+
+/**
+ * `patient` is needed to narrow the free-text search used to list deleted encounters, and
+ * `auditInfo` is what the activity view reads to say who created, changed or deleted each one.
+ */
+const encounterListRep =
+ 'custom:(uuid,display,encounterDatetime,voided,patient:(uuid),encounterType:(uuid,display)' +
+ ',form:(uuid,display),location:(uuid,display)' +
+ ',encounterProviders:(uuid,voided,provider:(uuid,display),encounterRole:(uuid,display)),auditInfo)';
+
+const encounterDetailRep =
+ 'custom:(uuid,display,encounterDatetime,voided,patient:(uuid,display,identifiers:(uuid,identifier,preferred))' +
+ ',location:(uuid,display),form:(uuid,display,version),encounterType:(uuid,display)' +
+ ',visit:(uuid,display,startDatetime,visitType:(uuid,display))' +
+ ',encounterProviders:(uuid,voided,provider:(uuid,display,identifier),encounterRole:(uuid,display)),auditInfo)';
+
+/**
+ * `value:ref` keeps coded values down to a `{uuid, display}` reference while leaving numeric, text
+ * and datetime values as the scalars they are. `previousVersion` and `auditInfo` are what make the
+ * audit trail possible: the former links an obs to the value it replaced, the latter carries the
+ * creator, the editor and the voiding user with their timestamps.
+ */
+const obsRep =
+ 'custom:(uuid,display,obsDatetime,voided,comment,formFieldPath,value:ref' +
+ ',concept:(uuid,display,descriptions:(description,locale)),obsGroup:(uuid)' +
+ ',previousVersion:(uuid,display),auditInfo)';
+
+/** A stable identity, so the unfiltered reads do not rebuild their memos on every render. */
+const noFilters: EncounterFilters = {};
+
+/** Just enough of an obs to say who touched it and when. */
+const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)';
+
+/**
+ * The framework's paging hooks default to their own binding of `openmrsFetch`; naming ours keeps
+ * every request this module makes going through the one function, which is also what lets tests
+ * stand in for it.
+ */
+const restFetchOptions = { fetcher: openmrsFetch };
+
+/**
+ * The page size asked for on bulk reads. Without it the server falls back to
+ * `webservices.rest.maxResultsDefault`, which is 50 by default and means more round trips.
+ */
+const bulkPageSize = 100;
+const maxBulkPages = 25;
+
+/**
+ * Reads every page of a paginated REST endpoint imperatively.
+ *
+ * `useOpenmrsFetchAll` covers this wherever the url is known at render time. The activity scan is
+ * the exception: it reads the observations of many encounters, and a hook cannot be called once
+ * per encounter, so that one loop needs a plain function.
+ */
+async function fetchAllPages(url: string): Promise> {
+ const results: Array = [];
+ const separator = url.includes('?') ? '&' : '?';
+
+ for (let page = 0; page < maxBulkPages; page++) {
+ const response = await openmrsFetch>(
+ `${url}${separator}startIndex=${page * bulkPageSize}&limit=${bulkPageSize}`,
+ );
+ const batch = response?.data?.results ?? [];
+ results.push(...batch);
+ if (batch.length < bulkPageSize) {
+ break;
+ }
+ }
+
+ return results;
+}
+
+/** How many encounters' observations to read at once, so a scan does not flood the server. */
+const scanConcurrency = 5;
+
+/** Runs `worker` over every item, keeping at most `limit` requests in flight. */
+async function mapWithConcurrency(items: Array, limit: number, worker: (item: T) => Promise) {
+ const results = new Array(items.length);
+ let next = 0;
+
+ async function run() {
+ while (next < items.length) {
+ const index = next++;
+ results[index] = await worker(items[index]);
+ }
+ }
+
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
+ return results;
+}
+
+/** The identifier to show for a patient, and the one to search encounters by. */
+export function getPreferredIdentifier(patient: AuditPatient | undefined): string | undefined {
+ const identifiers = patient?.identifiers;
+ if (!identifiers?.length) {
+ return undefined;
+ }
+ return (identifiers.find((identifier) => identifier.preferred) ?? identifiers[0]).identifier;
+}
+
+/**
+ * Searches patients by name or identifier, one page at a time. The audit trail starts here
+ * because a patient is what an auditor has to hand — a name, or an EMR ID off a paper form.
+ */
+export function usePatientSearch(query: string, page: number, pageSize: number) {
+ const startIndex = (page - 1) * pageSize;
+ const url = query
+ ? `${restBaseUrl}/patient?q=${encodeURIComponent(query)}&v=${patientRep}` +
+ `&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
+ : null;
+
+ const { data, error, isLoading } = useSWR>>(url, openmrsFetch);
+
+ return {
+ patients: data?.data?.results ?? [],
+ totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
+ error,
+ isLoading,
+ };
+}
+
+export function useAuditPatient(patientUuid: string | null) {
+ const url = patientUuid ? `${restBaseUrl}/patient/${patientUuid}?v=${patientRep}` : null;
+ const { data, error, isLoading } = useSWR>(url, openmrsFetch);
+
+ return { patient: data?.data, error, isLoading };
+}
+
+/**
+ * The search phrase used to reach a patient's deleted encounters, and the url that reads them.
+ * Both `usePatientEncounters` and `usePatientEncounterTypes` need that list, so they build the
+ * same url and SWR serves it to whichever of them asks second.
+ */
+function encounterSearchPhrase(patient: AuditPatient | undefined): string | undefined {
+ return getPreferredIdentifier(patient) ?? patient?.person?.display;
+}
+
+/**
+ * Whether a patient's deleted encounters can be reached at all. The free-text search that finds
+ * them needs a phrase to search on, so a patient with neither an identifier nor a name has none.
+ */
+export function canListDeletedEncounters(patient: AuditPatient | undefined): boolean {
+ return Boolean(encounterSearchPhrase(patient));
+}
+
+function deletedEncountersUrl(patient: AuditPatient | undefined): string | null {
+ const searchPhrase = encounterSearchPhrase(patient);
+ return patient && searchPhrase
+ ? `${restBaseUrl}/encounter?q=${encodeURIComponent(searchPhrase)}&includeAll=true&v=${encounterListRep}` +
+ `&limit=${bulkPageSize}`
+ : null;
+}
+
+function encountersForPatient(encounters: Array | undefined, patient: AuditPatient | undefined) {
+ return (encounters ?? []).filter((encounter) => encounter.patient?.uuid === patient?.uuid);
+}
+
+/**
+ * Every encounter a patient has, in one go rather than a page at a time — what the encounter type
+ * options and the activity view both need. The two paths mirror `usePatientEncounters`: the
+ * by-patient search for live encounters, and the free-text search when deleted ones are wanted.
+ * Both use `encounterListRep`, so a read made for one caller serves the others from cache.
+ */
+export function useAllPatientEncounters(
+ patient: AuditPatient | undefined,
+ includeDeleted: boolean,
+ filters: EncounterFilters,
+) {
+ const scanUrl =
+ patient && !includeDeleted
+ ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` +
+ `${buildEncounterFilterQuery(filters)}&order=desc&limit=${bulkPageSize}`
+ : null;
+ // `useOpenmrsFetchAll` takes a null url to mean "do not fetch", though its type does not say so.
+ const scanResult = useOpenmrsFetchAll(scanUrl as string, restFetchOptions);
+ const bulkResult = useOpenmrsFetchAll(
+ (includeDeleted ? deletedEncountersUrl(patient) : null) as string,
+ restFetchOptions,
+ );
+
+ const encounters = useMemo(() => {
+ if (!includeDeleted) {
+ return scanResult.data ?? [];
+ }
+ return encountersForPatient(bulkResult.data, patient)
+ .filter((encounter) => matchesEncounterFilters(encounter, filters))
+ .sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? ''));
+ }, [bulkResult.data, filters, includeDeleted, patient, scanResult.data]);
+
+ return {
+ encounters,
+ error: includeDeleted ? bulkResult.error : scanResult.error,
+ isLoading: includeDeleted ? bulkResult.isLoading : scanResult.isLoading,
+ };
+}
+
+/**
+ * The encounter types to offer as filters: the ones this patient's own encounters use, so the
+ * dropdown never offers a type that would return nothing. Read unfiltered, so choosing a type
+ * does not narrow the choices left.
+ */
+export function usePatientEncounterTypes(patient: AuditPatient | undefined, includeDeleted: boolean) {
+ const { encounters, error, isLoading } = useAllPatientEncounters(patient, includeDeleted, noFilters);
+ const encounterTypes = useMemo(() => distinctEncounterTypes(encounters), [encounters]);
+
+ return { encounterTypes, error, isLoading };
+}
+
+/**
+ * One page of a patient's encounters, optionally narrowed by encounter type and date range, with
+ * the page to show and the callback to change it — whichever of the two paths below is in use.
+ */
+interface PatientEncountersResult {
+ encounters: Array;
+ totalCount: number;
+ currentPage: number;
+ goTo(page: number): void;
+ error: unknown;
+ isLoading: boolean;
+ /** True when the patient has no identifier to run the deleted-encounter search with. */
+ cannotIncludeDeleted: boolean;
+}
+
+/**
+ * Lists a patient's encounters, a page at a time.
+ *
+ * Live encounters are paged by the server: `useOpenmrsPagination` owns the page number and asks
+ * for one page at a time, so the size of a patient's record costs nothing to display.
+ *
+ * Deleted encounters cannot be paged that way. The encounter-by-patient search always excludes
+ * voided encounters, so showing them means falling back to the free-text encounter search, which
+ * does honour `includeAll` but matches any patient whose name or identifier contains the phrase
+ * and takes no filter parameters. Its results therefore have to be read in full, narrowed to the
+ * patient in hand, filtered, and paged here — the one case where the whole list is fetched.
+ */
+export function usePatientEncounters(
+ patient: AuditPatient | undefined,
+ includeDeleted: boolean,
+ filters: EncounterFilters,
+ pageSize: number,
+): PatientEncountersResult {
+ const pagedUrl =
+ patient && !includeDeleted
+ ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` +
+ `${buildEncounterFilterQuery(filters)}&order=desc`
+ : null;
+ // `useOpenmrsPagination` appends limit, startIndex and totalCount itself, so the url omits them.
+ const pagedResult = useOpenmrsPagination(pagedUrl as string, pageSize, restFetchOptions);
+
+ const bulkResult = useOpenmrsFetchAll(
+ (includeDeleted ? deletedEncountersUrl(patient) : null) as string,
+ restFetchOptions,
+ );
+ const [clientPage, setClientPage] = useState(1);
+
+ const deletedEncounters = useMemo(() => {
+ if (!includeDeleted) {
+ return [];
+ }
+ return encountersForPatient(bulkResult.data, patient)
+ .filter((encounter) => matchesEncounterFilters(encounter, filters))
+ .sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? ''));
+ }, [bulkResult.data, filters, includeDeleted, patient]);
+
+ /**
+ * A filter, the deleted toggle or a different patient changes which encounters exist, so the
+ * paging starts over. `goTo` refuses a page it considers out of bounds, so it is only called
+ * when there is somewhere to go back from.
+ */
+ useEffect(() => {
+ setClientPage(1);
+ if (pagedResult.currentPage !== 1) {
+ pagedResult.goTo(1);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filters, includeDeleted, patient?.uuid]);
+
+ if (includeDeleted) {
+ const totalPages = Math.max(1, Math.ceil(deletedEncounters.length / pageSize));
+ const currentPage = Math.min(clientPage, totalPages);
+ return {
+ encounters: deletedEncounters.slice((currentPage - 1) * pageSize, currentPage * pageSize),
+ totalCount: deletedEncounters.length,
+ currentPage,
+ goTo: setClientPage,
+ error: bulkResult.error,
+ isLoading: bulkResult.isLoading,
+ cannotIncludeDeleted: Boolean(patient) && !canListDeletedEncounters(patient),
+ };
+ }
+
+ return {
+ encounters: pagedResult.data ?? [],
+ // The hook reports NaN until the first page has been read.
+ totalCount: Number.isNaN(pagedResult.totalCount) ? 0 : pagedResult.totalCount,
+ currentPage: pagedResult.currentPage,
+ goTo: pagedResult.goTo,
+ error: pagedResult.error,
+ isLoading: pagedResult.isLoading,
+ cannotIncludeDeleted: false,
+ };
+}
+
+/**
+ * Loads one encounter together with every obs ever recorded against it — group members and voided
+ * obs included, which is what `includeAll` buys — and assembles the audit trail.
+ */
+export function useEncounterAudit(encounterUuid: string | null): {
+ encounter: AuditEncounter | undefined;
+ obsTree: Array;
+ error: Error | undefined;
+ isLoading: boolean;
+} {
+ const encounterUrl = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterDetailRep}` : null;
+ const encounterResult = useSWR>(encounterUrl, openmrsFetch);
+
+ const obsUrl = encounterUuid
+ ? `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${obsRep}&limit=${bulkPageSize}`
+ : null;
+ const obsResult = useOpenmrsFetchAll(obsUrl as string, restFetchOptions);
+
+ const encounter = encounterResult.data?.data;
+ const obsTree = useMemo(() => buildObsTree(obsResult.data ?? [], encounter), [obsResult.data, encounter]);
+
+ return {
+ encounter,
+ obsTree,
+ error: encounterResult.error ?? obsResult.error,
+ isLoading: encounterResult.isLoading || obsResult.isLoading,
+ };
+}
+
+/**
+ * Who touched this patient's record: every creation, edit and deletion across their encounters,
+ * summarised per user and listed in order.
+ *
+ * Encounter rows carry their own `auditInfo`, and every matching encounter has already been read,
+ * so who created, changed and deleted encounters comes free.
+ *
+ * Observations do not: each encounter's observations need their own request, because the
+ * obs-by-encounter search is the only one that honours `includeAll` and so the only one that can
+ * see deleted observations. Every matching encounter is read all the same, so the view is complete
+ * — which means a patient with a long record costs one request per encounter, a few at a time.
+ * `scanProgress` is reported so the caller can show how far it has got.
+ */
+export function usePatientActivity(
+ patient: AuditPatient | undefined,
+ includeDeleted: boolean,
+ filters: EncounterFilters,
+) {
+ const {
+ encounters,
+ error: encountersError,
+ isLoading: isLoadingEncounters,
+ } = useAllPatientEncounters(patient, includeDeleted, filters);
+
+ const [encountersRead, setEncountersRead] = useState(0);
+ const encounterUuids = useMemo(() => encounters.map((encounter) => encounter.uuid), [encounters]);
+
+ const obsResult = useSWR(
+ encounterUuids.length ? ['audit-activity-obs', encounterUuids] : null,
+ async () => {
+ setEncountersRead(0);
+ let read = 0;
+ const perEncounter = await mapWithConcurrency(encounterUuids, scanConcurrency, async (encounterUuid) => {
+ const obs = await fetchAllPages(
+ `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${activityObsRep}`,
+ );
+ setEncountersRead((read += 1));
+ return obs;
+ });
+ return Object.fromEntries(encounterUuids.map((encounterUuid, index) => [encounterUuid, perEncounter[index]]));
+ },
+ );
+
+ const events = useMemo(() => buildAuditEvents(encounters, obsResult.data ?? {}), [encounters, obsResult.data]);
+ const userActivity = useMemo(() => summariseByUser(events), [events]);
+
+ return {
+ events,
+ userActivity,
+ /** How far the observation reads have got, for a view that has to wait on a long record. */
+ scanProgress: { read: encountersRead, total: encounterUuids.length },
+ error: encountersError ?? obsResult.error,
+ isLoading: isLoadingEncounters || obsResult.isLoading,
+ };
+}
diff --git a/packages/esm-audit-app/src/audit/audit.scss b/packages/esm-audit-app/src/audit/audit.scss
new file mode 100644
index 0000000..69fd340
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/audit.scss
@@ -0,0 +1,171 @@
+@use '@carbon/layout';
+@use '@carbon/type';
+@use '@openmrs/esm-styleguide/src/vars' as *;
+
+.container {
+ background-color: $ui-background;
+ min-height: 100%;
+ padding-bottom: layout.$spacing-07;
+}
+
+.pageHeader {
+ margin-bottom: layout.$spacing-05;
+}
+
+.section {
+ padding: 0 layout.$spacing-05;
+}
+
+.sectionHeading {
+ @include type.type-style('productive-heading-02');
+ color: $text-02;
+ margin: layout.$spacing-05 0 layout.$spacing-03;
+}
+
+.contextHeader {
+ display: flex;
+ flex-direction: column;
+ gap: layout.$spacing-02;
+ margin-bottom: layout.$spacing-05;
+}
+
+.contextTitle {
+ @include type.type-style('productive-heading-03');
+ color: $color-gray-100;
+}
+
+.contextSubtitle {
+ @include type.type-style('body-long-01');
+ color: $text-02;
+}
+
+.backButton {
+ margin-left: -#{layout.$spacing-05};
+}
+
+.searchInput {
+ max-width: 30rem;
+ margin-bottom: layout.$spacing-05;
+}
+
+.toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: layout.$spacing-05;
+ margin-bottom: layout.$spacing-03;
+}
+
+.tableContainer {
+ margin-bottom: layout.$spacing-03;
+}
+
+/* The whole row is the target for opening a patient or an encounter. */
+.clickableRow {
+ cursor: pointer;
+}
+
+.summaryList {
+ background-color: $ui-01;
+ margin-bottom: layout.$spacing-05;
+}
+
+.summaryLabel {
+ @include type.type-style('productive-heading-01');
+ color: $text-02;
+ width: 14rem;
+}
+
+.summaryValue {
+ @include type.type-style('body-short-01');
+ color: $color-gray-100;
+}
+
+/* Voided rows keep the legacy admin page's struck-through, muted look. */
+.voidedRow td {
+ color: $text-02;
+ text-decoration: line-through;
+}
+
+.obsConcept {
+ @include type.type-style('body-short-01');
+}
+
+.obsDescription {
+ @include type.type-style('label-01');
+ color: $text-02;
+ padding: 0 0 layout.$spacing-03;
+ text-decoration: none;
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: layout.$spacing-03 layout.$spacing-05;
+ margin-bottom: layout.$spacing-05;
+}
+
+.legendItem {
+ @include type.type-style('label-01');
+ color: $text-02;
+ display: flex;
+ align-items: center;
+ gap: layout.$spacing-02;
+}
+
+.emptyState {
+ @include type.type-style('body-long-01');
+ color: $text-02;
+ padding: layout.$spacing-05 0;
+}
+
+.inlineNotification {
+ max-width: none;
+ margin-bottom: layout.$spacing-05;
+}
+
+/* A table cell that opens a drill-down: looks like a link, behaves like a button. */
+.linkButton {
+ @include type.type-style('body-short-01');
+ background: none;
+ border: none;
+ color: $interactive-01;
+ cursor: pointer;
+ padding: 0;
+ text-align: left;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: layout.$spacing-05;
+ margin-bottom: layout.$spacing-05;
+}
+
+.filterControl {
+ min-width: 16rem;
+}
+
+/* Sits on the same baseline as the filter inputs, which carry their own labels. */
+.clearFilters {
+ margin-bottom: layout.$spacing-01;
+}
+
+.logHeader {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: layout.$spacing-03;
+}
+
+/* Marks the user whose events the activity log is showing. */
+.selectedRow td {
+ background-color: $color-blue-10;
+}
diff --git a/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx b/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx
new file mode 100644
index 0000000..966b30d
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx
@@ -0,0 +1,242 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SWRConfig } from 'swr';
+import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
+import { type AuditEncounter, type AuditObs } from '../types';
+import EncounterAudit from './encounter-audit.component';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+const mockUseConfig = jest.mocked(useConfig);
+
+const encounterCreated = '2026-09-01T11:09:00.000+0000';
+const dayAfterEncounter = '2026-09-02T08:30:00.000+0000';
+const creator = { uuid: 'user-1', display: 'Cos John' };
+
+const mockEncounter: AuditEncounter = {
+ uuid: 'enc-1',
+ display: 'COVID-19 Admission',
+ encounterDatetime: encounterCreated,
+ voided: false,
+ patient: {
+ uuid: 'patient-1',
+ display: 'Y2AHXV - Dave TestPatient',
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
+ },
+ location: { uuid: 'loc-1', display: 'CDI Klinik Ekstèn Jeneral' },
+ form: { uuid: 'form-1', display: 'COVID19 Admission', version: '1.0' },
+ encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
+ visit: { uuid: 'visit-1', display: 'Clinic or Hospital Visit' },
+ encounterProviders: [
+ {
+ uuid: 'ep-1',
+ voided: false,
+ provider: { uuid: 'prov-1', display: 'Louidor Jean paul', identifier: 'MCPRPG' },
+ encounterRole: { uuid: 'role-1', display: 'Consulting Clinician' },
+ },
+ ],
+ auditInfo: { creator, dateCreated: encounterCreated },
+};
+
+const mockObs: Array = [
+ {
+ uuid: 'obs-worker',
+ concept: { uuid: 'concept-worker', display: 'Health care worker' },
+ formFieldPath: 'covid.1-worker',
+ value: { uuid: 'concept-yes', display: 'Yes' },
+ auditInfo: { creator, dateCreated: encounterCreated },
+ },
+ {
+ uuid: 'obs-pulse-new',
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
+ formFieldPath: 'covid.2-pulse',
+ value: 68,
+ previousVersion: { uuid: 'obs-pulse-old', display: 'Pulse: 67' },
+ auditInfo: { creator, dateCreated: dayAfterEncounter },
+ },
+ {
+ uuid: 'obs-pulse-old',
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
+ formFieldPath: 'covid.2-pulse',
+ value: 67,
+ voided: true,
+ auditInfo: {
+ creator,
+ dateCreated: encounterCreated,
+ voidedBy: creator,
+ dateVoided: dayAfterEncounter,
+ voidReason: 'Corrected the reading',
+ },
+ },
+ {
+ uuid: 'obs-systolic',
+ concept: { uuid: 'concept-systolic', display: 'Systolic' },
+ formFieldPath: 'covid.3-systolic',
+ value: 123,
+ auditInfo: { creator, dateCreated: dayAfterEncounter },
+ },
+ {
+ uuid: 'obs-symptom-group',
+ concept: { uuid: 'concept-symptom-group', display: 'Sign/Symptom construct' },
+ formFieldPath: 'covid.4-symptom',
+ auditInfo: { creator, dateCreated: encounterCreated },
+ },
+ {
+ uuid: 'obs-symptom',
+ concept: { uuid: 'concept-symptom', display: 'Sign/Symptom name' },
+ obsGroup: { uuid: 'obs-symptom-group' },
+ value: { uuid: 'concept-fever', display: 'Fever' },
+ auditInfo: { creator, dateCreated: encounterCreated },
+ },
+];
+
+function mockRestApi({ encounter = mockEncounter, obs = mockObs } = {}) {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/obs?')) {
+ return Promise.resolve({ data: { results: obs } }) as ReturnType;
+ }
+ return Promise.resolve({ data: encounter }) as ReturnType;
+ });
+}
+
+function renderEncounterAudit() {
+ const onBackToEncounters = jest.fn();
+ render(
+ new Map() }}>
+
+ ,
+ );
+ return onBackToEncounters;
+}
+
+function obsRowsFor(conceptName: string): Array {
+ return screen.getAllByRole('row').filter((row) => row.textContent?.startsWith(conceptName));
+}
+
+describe(' ', () => {
+ beforeEach(() => {
+ mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' });
+ mockRestApi();
+ });
+
+ it('summarises how the encounter was recorded', async () => {
+ renderEncounterAudit();
+
+ expect(await screen.findByText('CDI Klinik Ekstèn Jeneral')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Y2AHXV - Dave TestPatient' })).toHaveAttribute(
+ 'href',
+ '/openmrs/spa/patient/patient-1/chart',
+ );
+ expect(screen.getByText('Y2AHXV')).toBeInTheDocument();
+ expect(screen.getByText('COVID19 Admission v1.0')).toBeInTheDocument();
+ expect(screen.getByText('Clinic or Hospital Visit')).toBeInTheDocument();
+ const createdByRow = screen.getAllByRole('row').find((row) => row.textContent?.startsWith('Created by'));
+ expect(createdByRow).toHaveTextContent('Cos John');
+ });
+
+ it('lists the providers on the encounter', async () => {
+ renderEncounterAudit();
+
+ const providerRow = (await screen.findAllByRole('row')).find((row) =>
+ row.textContent?.includes('Louidor Jean paul'),
+ );
+ expect(providerRow).toHaveTextContent('Consulting Clinician');
+ expect(providerRow).toHaveTextContent('MCPRPG');
+ });
+
+ it('shows an edited obs above the value it replaced', async () => {
+ renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+
+ const pulseRows = obsRowsFor('Pulse');
+ expect(pulseRows).toHaveLength(2);
+ expect(pulseRows[0]).toHaveTextContent('68');
+ expect(pulseRows[0]).toHaveTextContent('Edited');
+ expect(pulseRows[0]).toHaveTextContent('was 67');
+ expect(pulseRows[1]).toHaveTextContent('67');
+ expect(pulseRows[1]).toHaveTextContent('Previous value');
+ expect(pulseRows[1]).toHaveTextContent('Corrected the reading');
+ });
+
+ it('flags an obs recorded after the encounter was created, and leaves the rest unflagged', async () => {
+ renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+
+ expect(obsRowsFor('Systolic')[0]).toHaveTextContent('Added later');
+
+ const workerRow = obsRowsFor('Health care worker')[0];
+ expect(workerRow).toHaveTextContent('Yes');
+ expect(workerRow).not.toHaveTextContent('Added later');
+ expect(workerRow).not.toHaveTextContent('Edited');
+ });
+
+ it('nests obs group members under their group', async () => {
+ renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+
+ expect(obsRowsFor('Sign/Symptom construct')).toHaveLength(1);
+ expect(obsRowsFor('Sign/Symptom name')[0]).toHaveTextContent('Fever');
+ });
+
+ it('hides deleted observations on request', async () => {
+ renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+ expect(obsRowsFor('Pulse')).toHaveLength(2);
+
+ await userEvent.click(screen.getByRole('checkbox', { name: /show deleted observations/i }));
+
+ expect(obsRowsFor('Pulse')).toHaveLength(1);
+ expect(obsRowsFor('Pulse')[0]).toHaveTextContent('68');
+ });
+
+ it('shows concept descriptions on request', async () => {
+ mockRestApi({
+ obs: [
+ {
+ ...mockObs[0],
+ concept: {
+ uuid: 'concept-worker',
+ display: 'Health care worker',
+ descriptions: [{ description: 'Whether the patient works in health care', locale: 'en' }],
+ },
+ },
+ ],
+ });
+ renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+
+ expect(screen.queryByText(/whether the patient works in health care/i)).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('checkbox', { name: /show concept descriptions/i }));
+
+ expect(screen.getByText(/whether the patient works in health care/i)).toBeInTheDocument();
+ });
+
+ it('warns that a deleted encounter is being audited', async () => {
+ mockRestApi({
+ encounter: {
+ ...mockEncounter,
+ voided: true,
+ auditInfo: {
+ ...mockEncounter.auditInfo,
+ voidedBy: creator,
+ dateVoided: dayAfterEncounter,
+ voidReason: 'Entered on the wrong patient',
+ },
+ },
+ });
+ renderEncounterAudit();
+
+ expect(await screen.findByText(/this encounter has been deleted/i)).toBeInTheDocument();
+ expect(screen.getByText('Entered on the wrong patient')).toBeInTheDocument();
+ });
+
+ it('walks back up to the patient the encounter belongs to', async () => {
+ const onBackToEncounters = renderEncounterAudit();
+ await screen.findByText('CDI Klinik Ekstèn Jeneral');
+
+ await userEvent.click(screen.getByRole('button', { name: /back to encounters/i }));
+
+ expect(onBackToEncounters).toHaveBeenCalledWith('patient-1');
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/encounter-audit.component.tsx b/packages/esm-audit-app/src/audit/encounter-audit.component.tsx
new file mode 100644
index 0000000..a705606
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/encounter-audit.component.tsx
@@ -0,0 +1,204 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Button,
+ InlineNotification,
+ StructuredListBody,
+ StructuredListCell,
+ StructuredListRow,
+ StructuredListWrapper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+ SkeletonText,
+ Tag,
+} from '@carbon/react';
+import { ArrowLeftIcon, ConfigurableLink, ErrorState, useConfig } from '@openmrs/esm-framework';
+import { type Config } from '../config-schema';
+import { type AuditEncounter } from '../types';
+import { formatAuditDatetime, formatUserAndDate } from './audit-format';
+import { getPreferredIdentifier, useEncounterAudit } from './audit.resource';
+import ObsAuditTable from './obs-audit-table.component';
+import styles from './audit.scss';
+
+interface EncounterAuditProps {
+ encounterUuid: string;
+ /** Called with the encounter's patient, so that a deep link can navigate back up the trail. */
+ onBackToEncounters(patientUuid: string | undefined): void;
+}
+
+interface SummaryRow {
+ label: string;
+ value: React.ReactNode;
+}
+
+/**
+ * Step three of the audit trail: everything known about how one encounter came to be — who created
+ * it, who last changed it, whether it has been deleted, and the full history of its observations.
+ */
+export default function EncounterAudit({ encounterUuid, onBackToEncounters }: EncounterAuditProps) {
+ const { t } = useTranslation();
+ const config = useConfig();
+ const { encounter, obsTree, error, isLoading } = useEncounterAudit(encounterUuid);
+
+ if (error) {
+ return (
+
+ onBackToEncounters(undefined)} />
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ onBackToEncounters(undefined)} />
+
+
+ );
+ }
+
+ if (!encounter) {
+ return (
+
+
onBackToEncounters(undefined)} />
+ {t('encounterNotFound', 'That encounter could not be found.')}
+
+ );
+ }
+
+ const patientUuid = encounter.patient?.uuid;
+ const summaryRows: Array = [
+ {
+ label: t('patient', 'Patient'),
+ value: patientUuid ? (
+
+ {encounter.patient?.display}
+
+ ) : (
+ encounter.patient?.display
+ ),
+ },
+ { label: t('identifier', 'Identifier'), value: getPreferredIdentifier(encounter.patient) },
+ { label: t('encounterDate', 'Encounter date'), value: formatAuditDatetime(encounter.encounterDatetime) },
+ { label: t('location', 'Location'), value: encounter.location?.display },
+ { label: t('visit', 'Visit'), value: encounter.visit?.display },
+ { label: t('encounterType', 'Encounter type'), value: encounter.encounterType?.display },
+ { label: t('form', 'Form'), value: formatForm(encounter) },
+ {
+ label: t('createdBy', 'Created by'),
+ value: formatUserAndDate(encounter.auditInfo?.creator, encounter.auditInfo?.dateCreated),
+ },
+ ];
+
+ if (encounter.auditInfo?.changedBy) {
+ summaryRows.push({
+ label: t('changedBy', 'Changed by'),
+ value: formatUserAndDate(encounter.auditInfo.changedBy, encounter.auditInfo.dateChanged),
+ });
+ }
+
+ if (encounter.voided) {
+ summaryRows.push({
+ label: t('deletedBy', 'Deleted by'),
+ value: formatUserAndDate(encounter.auditInfo?.voidedBy, encounter.auditInfo?.dateVoided),
+ });
+ if (encounter.auditInfo?.voidReason) {
+ summaryRows.push({ label: t('deletionReason', 'Reason for deletion'), value: encounter.auditInfo.voidReason });
+ }
+ }
+
+ const providers = (encounter.encounterProviders ?? []).filter((encounterProvider) => !encounterProvider.voided);
+
+ return (
+
+
onBackToEncounters(patientUuid)} />
+
+
+ {encounter.encounterType?.display ?? t('encounter', 'Encounter')}
+ {encounter.voided ? {t('deleted', 'Deleted')} : null}
+
+
+ {[encounter.patient?.display, formatAuditDatetime(encounter.encounterDatetime)].filter(Boolean).join(' · ')}
+
+
+
+ {encounter.voided ? (
+
+ ) : null}
+
+ {t('encounterSummary', 'Encounter summary')}
+
+
+ {summaryRows.map((row) => (
+
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+
+
+ {t('providers', 'Providers')}
+ {providers.length === 0 ? (
+ {t('noProviders', 'No providers are recorded for this encounter.')}
+ ) : (
+
+
+
+
+ {t('role', 'Role')}
+ {t('providerName', 'Provider name')}
+ {t('identifier', 'Identifier')}
+
+
+
+ {providers.map((encounterProvider) => (
+
+ {encounterProvider.encounterRole?.display}
+ {encounterProvider.provider?.display}
+ {encounterProvider.provider?.identifier}
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+function BackButton({ onClick }: { onClick(): void }) {
+ const { t } = useTranslation();
+ return (
+
+ {t('backToEncounters', 'Back to encounters')}
+
+ );
+}
+
+function formatForm(encounter: AuditEncounter): string | undefined {
+ if (!encounter.form?.display) {
+ return undefined;
+ }
+ return encounter.form.version ? `${encounter.form.display} v${encounter.form.version}` : encounter.form.display;
+}
diff --git a/packages/esm-audit-app/src/audit/encounter-filters.component.tsx b/packages/esm-audit-app/src/audit/encounter-filters.component.tsx
new file mode 100644
index 0000000..d10893a
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/encounter-filters.component.tsx
@@ -0,0 +1,75 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, ComboBox } from '@carbon/react';
+import { OpenmrsDateRangePicker } from '@openmrs/esm-framework';
+import { type AuditPatient, type OpenmrsResourceRef } from '../types';
+import { usePatientEncounterTypes } from './audit.resource';
+import { type EncounterFilters, fromDateKey, hasActiveFilters, toDateKey } from './encounter-filters';
+import styles from './audit.scss';
+
+interface EncounterFiltersBarProps {
+ patient: AuditPatient | undefined;
+ includeDeleted: boolean;
+ filters: EncounterFilters;
+ onChange(filters: EncounterFilters): void;
+}
+
+/**
+ * Narrows a patient's encounter list by encounter type and by the date the encounter happened.
+ * The types on offer are the ones this patient's encounters actually use.
+ */
+export default function EncounterFiltersBar({ patient, includeDeleted, filters, onChange }: EncounterFiltersBarProps) {
+ const { t } = useTranslation();
+ const { encounterTypes, isLoading } = usePatientEncounterTypes(patient, includeDeleted);
+
+ const today = useMemo(() => new Date(), []);
+
+ /**
+ * Hiding deleted encounters again can take the chosen type out of the list. Keeping it on the
+ * list means the filter that is in force is still the one shown, and can still be changed.
+ */
+ const items = useMemo(() => {
+ const selected = filters.encounterType;
+ if (selected && !encounterTypes.some((encounterType) => encounterType.uuid === selected.uuid)) {
+ return [...encounterTypes, selected];
+ }
+ return encounterTypes;
+ }, [encounterTypes, filters.encounterType]);
+
+ const dateRange = useMemo<[Date | null, Date | null]>(
+ () => [fromDateKey(filters.fromDate), fromDateKey(filters.toDate)],
+ [filters.fromDate, filters.toDate],
+ );
+
+ return (
+
+ encounterType?.display ?? ''}
+ onChange={({ selectedItem }: { selectedItem: OpenmrsResourceRef | null }) =>
+ onChange({ ...filters, encounterType: selectedItem ?? undefined })
+ }
+ placeholder={t('allEncounterTypes', 'All encounter types')}
+ selectedItem={filters.encounterType ?? null}
+ size="sm"
+ titleText={t('encounterType', 'Encounter type')}
+ />
+ onChange({ ...filters, fromDate: toDateKey(from), toDate: toDateKey(to) })}
+ size="sm"
+ value={dateRange}
+ />
+ {hasActiveFilters(filters) ? (
+ onChange({})} size="sm">
+ {t('clearFilters', 'Clear filters')}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/encounter-filters.test.ts b/packages/esm-audit-app/src/audit/encounter-filters.test.ts
new file mode 100644
index 0000000..c3a22cc
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/encounter-filters.test.ts
@@ -0,0 +1,119 @@
+import { type AuditEncounter } from '../types';
+import {
+ buildEncounterFilterQuery,
+ distinctEncounterTypes,
+ fromDateKey,
+ hasActiveFilters,
+ matchesEncounterFilters,
+ toDateKey,
+} from './encounter-filters';
+
+const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' };
+const checkinType = { uuid: 'type-checkin', display: 'Inscription' };
+
+const consultation: AuditEncounter = {
+ uuid: 'enc-1',
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
+ encounterType: consultationType,
+};
+
+describe('date keys', () => {
+ it('reads the calendar date a moment falls on', () => {
+ expect(toDateKey(new Date(2026, 3, 18, 9, 0))).toBe('2026-04-18');
+ expect(toDateKey(new Date(2026, 0, 5))).toBe('2026-01-05');
+ });
+
+ it('has nothing to say about a missing or unparseable date', () => {
+ expect(toDateKey(null)).toBeUndefined();
+ expect(toDateKey(undefined)).toBeUndefined();
+ expect(toDateKey(new Date('nonsense'))).toBeUndefined();
+ });
+
+ it('round-trips back to local midnight, not to UTC midnight', () => {
+ const date = fromDateKey('2026-04-18');
+
+ expect(date?.getFullYear()).toBe(2026);
+ expect(date?.getMonth()).toBe(3);
+ expect(date?.getDate()).toBe(18);
+ expect(date?.getHours()).toBe(0);
+ expect(toDateKey(date)).toBe('2026-04-18');
+ });
+
+ it('has nothing to round-trip when no date is set', () => {
+ expect(fromDateKey(undefined)).toBeNull();
+ expect(fromDateKey('')).toBeNull();
+ });
+});
+
+describe('hasActiveFilters', () => {
+ it('knows when a filter is set', () => {
+ expect(hasActiveFilters({})).toBe(false);
+ expect(hasActiveFilters({ encounterType: consultationType })).toBe(true);
+ expect(hasActiveFilters({ fromDate: '2026-04-01' })).toBe(true);
+ expect(hasActiveFilters({ toDate: '2026-04-30' })).toBe(true);
+ });
+});
+
+describe('buildEncounterFilterQuery', () => {
+ it('sends nothing when nothing is filtered', () => {
+ expect(buildEncounterFilterQuery({})).toBe('');
+ });
+
+ it('asks the server for one encounter type', () => {
+ expect(buildEncounterFilterQuery({ encounterType: consultationType })).toBe('&encounterType=type-consultation');
+ });
+
+ it('covers whole days at both ends of the range, in the server’s own time zone', () => {
+ expect(buildEncounterFilterQuery({ fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe(
+ '&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59',
+ );
+ });
+});
+
+describe('matchesEncounterFilters', () => {
+ it('keeps everything when nothing is filtered', () => {
+ expect(matchesEncounterFilters(consultation, {})).toBe(true);
+ });
+
+ it('keeps only the chosen encounter type', () => {
+ expect(matchesEncounterFilters(consultation, { encounterType: consultationType })).toBe(true);
+ expect(matchesEncounterFilters(consultation, { encounterType: checkinType })).toBe(false);
+ });
+
+ it('treats both ends of the date range as inclusive', () => {
+ const day = toDateKey(new Date(consultation.encounterDatetime));
+
+ expect(matchesEncounterFilters(consultation, { fromDate: day, toDate: day })).toBe(true);
+ expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe(true);
+ expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-19' })).toBe(false);
+ expect(matchesEncounterFilters(consultation, { toDate: '2026-04-17' })).toBe(false);
+ });
+
+ it('requires every filter to match', () => {
+ expect(matchesEncounterFilters(consultation, { encounterType: consultationType, fromDate: '2026-04-19' })).toBe(
+ false,
+ );
+ });
+
+ it('excludes an encounter with no date once a date range is set', () => {
+ expect(matchesEncounterFilters({ uuid: 'enc-2' }, { fromDate: '2026-04-01' })).toBe(false);
+ expect(matchesEncounterFilters({ uuid: 'enc-2' }, {})).toBe(true);
+ });
+});
+
+describe('distinctEncounterTypes', () => {
+ it('collects each type a patient has once, sorted by name', () => {
+ expect(
+ distinctEncounterTypes([
+ { uuid: 'enc-1', encounterType: consultationType },
+ { uuid: 'enc-2', encounterType: checkinType },
+ { uuid: 'enc-3', encounterType: { ...consultationType } },
+ ]),
+ ).toEqual([checkinType, consultationType]);
+ });
+
+ it('skips encounters with no type, and copes with none at all', () => {
+ expect(distinctEncounterTypes([{ uuid: 'enc-1' }])).toEqual([]);
+ expect(distinctEncounterTypes([])).toEqual([]);
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/encounter-filters.ts b/packages/esm-audit-app/src/audit/encounter-filters.ts
new file mode 100644
index 0000000..f04657a
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/encounter-filters.ts
@@ -0,0 +1,105 @@
+import { type AuditEncounter, type OpenmrsResourceRef } from '../types';
+
+/**
+ * The filters an auditor can narrow a patient's encounter list by. Dates are held as calendar
+ * dates (`YYYY-MM-DD`) rather than instants, because that is what someone picking a date means,
+ * and both bounds are inclusive.
+ */
+export interface EncounterFilters {
+ /**
+ * The whole reference rather than just the uuid, so that the chosen type stays displayable even
+ * if it drops out of the available options — which happens when deleted encounters are hidden
+ * again and the type only occurred on those.
+ */
+ encounterType?: OpenmrsResourceRef;
+ fromDate?: string;
+ toDate?: string;
+}
+
+/**
+ * The encounter types a patient actually has, which is what their encounter list is worth
+ * filtering by — a dropdown of every type in the system would mostly offer types that return
+ * nothing for this patient.
+ */
+export function distinctEncounterTypes(encounters: Array): Array {
+ const byUuid = new Map();
+ for (const encounter of encounters) {
+ if (encounter.encounterType?.uuid) {
+ byUuid.set(encounter.encounterType.uuid, encounter.encounterType);
+ }
+ }
+ return Array.from(byUuid.values()).sort((a, b) => (a.display ?? '').localeCompare(b.display ?? ''));
+}
+
+function pad(value: number): string {
+ return String(value).padStart(2, '0');
+}
+
+/** The calendar date a `Date` falls on, in the browser's time zone. */
+export function toDateKey(date: Date | null | undefined): string | undefined {
+ if (!date || Number.isNaN(date.getTime())) {
+ return undefined;
+ }
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+/** Local midnight on a calendar date, for handing a stored filter back to the date picker. */
+export function fromDateKey(key: string | undefined): Date | null {
+ if (!key) {
+ return null;
+ }
+ const [year, month, day] = key.split('-').map(Number);
+ if (!year || !month || !day) {
+ return null;
+ }
+ return new Date(year, month - 1, day);
+}
+
+export function hasActiveFilters(filters: EncounterFilters): boolean {
+ return Boolean(filters.encounterType || filters.fromDate || filters.toDate);
+}
+
+/**
+ * The query parameters the encounter-by-patient search understands. The dates are sent without a
+ * time zone so the server reads them as its own wall clock — the facility's day, which is what the
+ * auditor picking a date means — and `todate` covers the whole of its day, since the API compares
+ * it against `encounterDatetime` with `<=`.
+ */
+export function buildEncounterFilterQuery(filters: EncounterFilters): string {
+ let query = '';
+ if (filters.encounterType) {
+ query += `&encounterType=${filters.encounterType.uuid}`;
+ }
+ if (filters.fromDate) {
+ query += `&fromdate=${filters.fromDate}T00:00:00`;
+ }
+ if (filters.toDate) {
+ query += `&todate=${filters.toDate}T23:59:59`;
+ }
+ return query;
+}
+
+/**
+ * The same filters applied on the client, for the free-text search used to list deleted
+ * encounters, which the REST API cannot filter server-side.
+ */
+export function matchesEncounterFilters(encounter: AuditEncounter, filters: EncounterFilters): boolean {
+ if (filters.encounterType && encounter.encounterType?.uuid !== filters.encounterType.uuid) {
+ return false;
+ }
+
+ if (filters.fromDate || filters.toDate) {
+ const day = encounter.encounterDatetime ? toDateKey(new Date(encounter.encounterDatetime)) : undefined;
+ if (!day) {
+ return false;
+ }
+ if (filters.fromDate && day < filters.fromDate) {
+ return false;
+ }
+ if (filters.toDate && day > filters.toDate) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx b/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx
new file mode 100644
index 0000000..850e02e
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx
@@ -0,0 +1,181 @@
+import React, { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Checkbox,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tag,
+} from '@carbon/react';
+import { type AuditObs, type ObsChangeStatus, type ObsTreeNode } from '../types';
+import { formatUserAndDate } from './audit-format';
+import { flattenObsTree, formatObsValue, getConceptDescription, isVoidedStatus } from './obs-audit';
+import styles from './audit.scss';
+
+interface ObsAuditTableProps {
+ obsTree: Array;
+}
+
+/** One indent step per level of obs group nesting. */
+const indentPerLevel = 1.5;
+
+function collectObs(nodes: Array, into: Map): Map {
+ for (const node of nodes) {
+ into.set(node.obs.uuid, node.obs);
+ collectObs(node.children, into);
+ }
+ return into;
+}
+
+/**
+ * The audit trail for one encounter's observations: what was entered, what was changed, what was
+ * deleted, and by whom. Deleted obs are struck through, as they were on the legacy admin page, and
+ * every obs carries the change status derived by `getObsChangeStatus`.
+ */
+export default function ObsAuditTable({ obsTree }: ObsAuditTableProps) {
+ const { t, i18n } = useTranslation();
+ const [showDeleted, setShowDeleted] = useState(true);
+ const [showDescriptions, setShowDescriptions] = useState(false);
+
+ /** The tag and the legend entry for each status that is worth calling out. */
+ const statusMeta = useMemo(
+ () => ({
+ edited: {
+ label: t('edited', 'Edited'),
+ type: 'blue' as const,
+ legend: t('editedLegend', 'the value was changed after it was first recorded'),
+ },
+ addedAfterEncounter: {
+ label: t('addedLater', 'Added later'),
+ type: 'teal' as const,
+ legend: t('addedLaterLegend', 'recorded after the encounter itself was created'),
+ },
+ deleted: {
+ label: t('deleted', 'Deleted'),
+ type: 'red' as const,
+ legend: t('deletedLegend', 'removed from the encounter'),
+ },
+ supersededValue: {
+ label: t('previousValue', 'Previous value'),
+ type: 'gray' as const,
+ legend: t('previousValueLegend', 'the value that an edit replaced'),
+ },
+ }),
+ [t],
+ );
+
+ const obsByUuid = useMemo(() => collectObs(obsTree, new Map()), [obsTree]);
+ const rows = useMemo(() => flattenObsTree(obsTree, showDeleted), [obsTree, showDeleted]);
+ const hasDeletedObs = useMemo(() => Array.from(obsByUuid.values()).some((obs) => obs.voided), [obsByUuid]);
+
+ return (
+ <>
+ {t('observations', 'Observations')}
+
+ setShowDeleted(checked)}
+ />
+ setShowDescriptions(checked)}
+ />
+
+
+ {Object.entries(statusMeta).map(([status, meta]) => (
+
+ {meta.label}
+ {meta.legend}
+
+ ))}
+
+
+ {rows.length === 0 ? (
+ {t('noObservations', 'This encounter has no observations.')}
+ ) : (
+
+
+
+
+ {t('questionConcept', 'Question concept')}
+ {t('value', 'Value')}
+ {t('change', 'Change')}
+ {t('recordedBy', 'Recorded by')}
+ {t('deletedBy', 'Deleted by')}
+
+
+
+ {rows.map(({ obs, status, depth, children }) => {
+ const meta = status === 'unchanged' ? undefined : statusMeta[status];
+ const previous = obs.previousVersion ? obsByUuid.get(obs.previousVersion.uuid) : undefined;
+ const description = showDescriptions ? getConceptDescription(obs, i18n?.language) : undefined;
+ const isGroup = children.length > 0;
+ const indent = { paddingInlineStart: `${depth * indentPerLevel}rem` };
+
+ return (
+
+
+
+
+ {isGroup ? {obs.concept?.display} : obs.concept?.display}
+
+
+
+ {isGroup ? null : formatObsValue(obs.value)}
+ {previous ? (
+
+ {t('wasPreviously', 'was {{value}}', { value: formatObsValue(previous.value) })}
+
+ ) : null}
+ {obs.comment ? {obs.comment}
: null}
+
+ {meta ? {meta.label} : null}
+
+ {formatUserAndDate(obs.auditInfo?.creator, obs.auditInfo?.dateCreated)}
+ {obs.auditInfo?.changedBy ? (
+
+ {t('changedByUser', 'Changed by {{user}}', {
+ user: formatUserAndDate(obs.auditInfo.changedBy, obs.auditInfo.dateChanged),
+ })}
+
+ ) : null}
+
+
+ {obs.voided ? (
+ <>
+ {formatUserAndDate(obs.auditInfo?.voidedBy, obs.auditInfo?.dateVoided)}
+ {obs.auditInfo?.voidReason ? (
+ {obs.auditInfo.voidReason}
+ ) : null}
+ >
+ ) : null}
+
+
+ {description ? (
+
+
+
+ {description}
+
+
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/obs-audit.test.ts b/packages/esm-audit-app/src/audit/obs-audit.test.ts
new file mode 100644
index 0000000..6315e65
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/obs-audit.test.ts
@@ -0,0 +1,170 @@
+import { type AuditEncounter, type AuditObs } from '../types';
+import {
+ buildObsTree,
+ flattenObsTree,
+ formatObsValue,
+ getConceptDescription,
+ getObsChangeStatus,
+ isVoidedStatus,
+} from './obs-audit';
+
+const encounterCreated = '2026-09-01T11:09:00.000+0000';
+const laterThanEncounter = '2026-09-02T08:30:00.000+0000';
+
+const encounter: AuditEncounter = {
+ uuid: 'enc-1',
+ auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated },
+};
+
+function obs(overrides: Partial & { uuid: string }): AuditObs {
+ return {
+ concept: { uuid: `concept-${overrides.uuid}`, display: 'Pulse' },
+ auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated },
+ ...overrides,
+ };
+}
+
+describe('getObsChangeStatus', () => {
+ it('reports an obs created with its encounter as unchanged', () => {
+ expect(getObsChangeStatus(obs({ uuid: 'a' }), encounter, new Set())).toBe('unchanged');
+ });
+
+ it('reports an obs that replaced an earlier one as edited', () => {
+ const edited = obs({ uuid: 'a', previousVersion: { uuid: 'b' }, auditInfo: { dateCreated: laterThanEncounter } });
+ expect(getObsChangeStatus(edited, encounter, new Set())).toBe('edited');
+ });
+
+ it('reports an obs created after its encounter as added later', () => {
+ const added = obs({ uuid: 'a', auditInfo: { dateCreated: laterThanEncounter } });
+ expect(getObsChangeStatus(added, encounter, new Set())).toBe('addedAfterEncounter');
+ });
+
+ it('reports a voided obs as deleted', () => {
+ expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set())).toBe('deleted');
+ });
+
+ it('reports a voided obs that a later obs replaced as a superseded value', () => {
+ expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set(['a']))).toBe('supersededValue');
+ });
+
+ it('does not guess when either creation date is missing', () => {
+ expect(getObsChangeStatus(obs({ uuid: 'a', auditInfo: {} }), encounter, new Set())).toBe('unchanged');
+ expect(getObsChangeStatus(obs({ uuid: 'a' }), undefined, new Set())).toBe('unchanged');
+ });
+
+ it('knows which statuses mean the obs row is voided', () => {
+ expect(isVoidedStatus('deleted')).toBe(true);
+ expect(isVoidedStatus('supersededValue')).toBe(true);
+ expect(isVoidedStatus('edited')).toBe(false);
+ });
+});
+
+describe('buildObsTree', () => {
+ it('nests obs group members under their group and tags each obs with its status', () => {
+ const tree = buildObsTree(
+ [
+ obs({ uuid: 'member', obsGroup: { uuid: 'group' }, concept: { uuid: 'c-2', display: 'Sign/Symptom name' } }),
+ obs({ uuid: 'group', concept: { uuid: 'c-1', display: 'Sign/Symptom construct' } }),
+ ],
+ encounter,
+ );
+
+ expect(tree).toHaveLength(1);
+ expect(tree[0].obs.uuid).toBe('group');
+ expect(tree[0].depth).toBe(0);
+ expect(tree[0].status).toBe('unchanged');
+ expect(tree[0].children.map((child) => child.obs.uuid)).toEqual(['member']);
+ expect(tree[0].children[0].depth).toBe(1);
+ });
+
+ it('keeps an obs whose group is missing from the list at the top level', () => {
+ const tree = buildObsTree([obs({ uuid: 'orphan', obsGroup: { uuid: 'not-loaded' } })], encounter);
+
+ expect(tree.map((node) => node.obs.uuid)).toEqual(['orphan']);
+ });
+
+ it('puts the versions of one value together, newest first', () => {
+ const tree = buildObsTree(
+ [
+ obs({
+ uuid: 'pulse-old',
+ value: 67,
+ voided: true,
+ formFieldPath: 'covid.pulse',
+ auditInfo: { dateCreated: encounterCreated },
+ }),
+ obs({ uuid: 'temperature', value: 38, formFieldPath: 'covid.temperature' }),
+ obs({
+ uuid: 'pulse-new',
+ value: 68,
+ previousVersion: { uuid: 'pulse-old' },
+ formFieldPath: 'covid.pulse',
+ auditInfo: { dateCreated: laterThanEncounter },
+ }),
+ ],
+ encounter,
+ );
+
+ expect(tree.map((node) => [node.obs.uuid, node.status])).toEqual([
+ ['pulse-new', 'edited'],
+ ['pulse-old', 'supersededValue'],
+ ['temperature', 'unchanged'],
+ ]);
+ });
+});
+
+describe('flattenObsTree', () => {
+ const tree = buildObsTree(
+ [
+ obs({ uuid: 'group', voided: true, concept: { uuid: 'c-1', display: 'Group' } }),
+ obs({ uuid: 'member', voided: true, obsGroup: { uuid: 'group' } }),
+ obs({ uuid: 'kept' }),
+ ],
+ encounter,
+ );
+
+ it('returns every obs when deleted obs are shown', () => {
+ expect(flattenObsTree(tree, true).map((node) => node.obs.uuid)).toEqual(['group', 'member', 'kept']);
+ });
+
+ it('drops deleted obs, and anything nested inside them, when they are hidden', () => {
+ expect(flattenObsTree(tree, false).map((node) => node.obs.uuid)).toEqual(['kept']);
+ });
+});
+
+describe('formatObsValue', () => {
+ it('displays coded values by their name and scalar values as they are', () => {
+ expect(formatObsValue({ uuid: 'concept-yes', display: 'Yes' })).toBe('Yes');
+ expect(formatObsValue(68)).toBe('68');
+ expect(formatObsValue('advil 100')).toBe('advil 100');
+ });
+
+ it('displays nothing for an obs with no value, such as an obs group', () => {
+ expect(formatObsValue(null)).toBe('');
+ expect(formatObsValue(undefined)).toBe('');
+ });
+});
+
+describe('getConceptDescription', () => {
+ const described = obs({
+ uuid: 'a',
+ concept: {
+ uuid: 'c-1',
+ display: 'Pulse',
+ descriptions: [
+ { description: 'Battements par minute', locale: 'fr' },
+ { description: 'Beats per minute', locale: 'en' },
+ ],
+ },
+ });
+
+ it('prefers the description matching the given locale', () => {
+ expect(getConceptDescription(described, 'en_GB')).toBe('Beats per minute');
+ expect(getConceptDescription(described, 'fr')).toBe('Battements par minute');
+ });
+
+ it('falls back to the first description when nothing matches', () => {
+ expect(getConceptDescription(described, 'es')).toBe('Battements par minute');
+ expect(getConceptDescription(obs({ uuid: 'a' }), 'en')).toBeUndefined();
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/obs-audit.ts b/packages/esm-audit-app/src/audit/obs-audit.ts
new file mode 100644
index 0000000..2447f94
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/obs-audit.ts
@@ -0,0 +1,156 @@
+import { type AuditEncounter, type AuditObs, type ObsChangeStatus, type ObsTreeNode, type ObsValue } from '../types';
+
+/** Statuses that mean the obs row is voided in the database. */
+const voidedStatuses: Array = ['deleted', 'supersededValue'];
+
+export function isVoidedStatus(status: ObsChangeStatus): boolean {
+ return voidedStatuses.includes(status);
+}
+
+function toTime(date: string | undefined): number | null {
+ if (!date) {
+ return null;
+ }
+ const time = new Date(date).getTime();
+ return Number.isNaN(time) ? null : time;
+}
+
+/**
+ * Works out how an obs came to look the way it does. This reproduces what the legacy
+ * `admin/encounters/encounter.form` page showed, using the data the REST API exposes:
+ *
+ * - an obs that points at a `previousVersion` replaced an earlier obs, so its value was edited;
+ * - an obs created later than its encounter was added after the encounter was first saved.
+ * OpenMRS stamps the same `dateCreated` on an encounter and on every obs saved with it, so an
+ * obs whose `dateCreated` differs from the encounter's was saved in a later transaction;
+ * - a voided obs was deleted, unless a surviving obs points back at it as its `previousVersion`,
+ * in which case it is the value that was edited away.
+ *
+ * @param obs the obs to classify
+ * @param encounter the encounter the obs belongs to, for its creation date
+ * @param supersededUuids uuids that some other obs in the encounter names as its `previousVersion`
+ */
+export function getObsChangeStatus(
+ obs: AuditObs,
+ encounter: AuditEncounter | undefined,
+ supersededUuids: Set,
+): ObsChangeStatus {
+ if (obs.voided) {
+ return supersededUuids.has(obs.uuid) ? 'supersededValue' : 'deleted';
+ }
+
+ if (obs.previousVersion) {
+ return 'edited';
+ }
+
+ const obsCreated = toTime(obs.auditInfo?.dateCreated);
+ const encounterCreated = toTime(encounter?.auditInfo?.dateCreated);
+ if (obsCreated !== null && encounterCreated !== null && obsCreated !== encounterCreated) {
+ return 'addedAfterEncounter';
+ }
+
+ return 'unchanged';
+}
+
+/**
+ * Sorts obs so that every version of a value sits together, newest first. Obs recorded through a
+ * form share a `formFieldPath`, which also approximates the order of the fields on that form; obs
+ * recorded some other way fall back to the concept name.
+ */
+function compareObs(a: AuditObs, b: AuditObs): number {
+ const groupA = a.formFieldPath ?? a.concept?.display ?? '';
+ const groupB = b.formFieldPath ?? b.concept?.display ?? '';
+ if (groupA !== groupB) {
+ return groupA.localeCompare(groupB);
+ }
+
+ const createdA = toTime(a.auditInfo?.dateCreated) ?? 0;
+ const createdB = toTime(b.auditInfo?.dateCreated) ?? 0;
+ if (createdA !== createdB) {
+ return createdB - createdA;
+ }
+
+ return a.uuid.localeCompare(b.uuid);
+}
+
+/**
+ * Turns the flat list of obs the REST API returns for an encounter — which includes obs group
+ * members and voided obs — into the group hierarchy, tagging each obs with its change status.
+ *
+ * An obs whose group is not in the list (which the API should not return, but which would
+ * otherwise silently drop the obs) is treated as a root so that it stays visible.
+ */
+export function buildObsTree(obsList: Array, encounter?: AuditEncounter): Array {
+ const supersededUuids = new Set(
+ obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)),
+ );
+
+ const nodes = new Map(
+ obsList.map((obs) => [
+ obs.uuid,
+ { obs, status: getObsChangeStatus(obs, encounter, supersededUuids), depth: 0, children: [] },
+ ]),
+ );
+
+ const roots: Array = [];
+ for (const node of nodes.values()) {
+ const parent = node.obs.obsGroup?.uuid ? nodes.get(node.obs.obsGroup.uuid) : undefined;
+ if (parent) {
+ parent.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ const sortAndSetDepth = (siblings: Array, depth: number) => {
+ siblings.sort((a, b) => compareObs(a.obs, b.obs));
+ for (const sibling of siblings) {
+ sibling.depth = depth;
+ sortAndSetDepth(sibling.children, depth + 1);
+ }
+ };
+ sortAndSetDepth(roots, 0);
+
+ return roots;
+}
+
+/**
+ * Flattens the obs tree into the rows to render, dropping voided obs (along with anything nested
+ * inside them) unless deleted obs are being shown.
+ */
+export function flattenObsTree(nodes: Array, showDeleted: boolean): Array {
+ return nodes.flatMap((node) => {
+ if (!showDeleted && isVoidedStatus(node.status)) {
+ return [];
+ }
+ return [node, ...flattenObsTree(node.children, showDeleted)];
+ });
+}
+
+/**
+ * Renders an obs value for display. Coded obs (and the drug and location values OpenMRS stores
+ * alongside them) arrive as references rather than as scalars.
+ */
+export function formatObsValue(value: ObsValue | undefined): string {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ if (typeof value === 'object') {
+ return value.display ?? '';
+ }
+ if (typeof value === 'boolean') {
+ return String(value);
+ }
+ return String(value);
+}
+
+/** Picks the concept description matching the given locale, falling back to whatever is there. */
+export function getConceptDescription(obs: AuditObs, locale: string | undefined): string | undefined {
+ const descriptions = obs.concept?.descriptions;
+ if (!descriptions?.length) {
+ return undefined;
+ }
+ const language = locale?.split(/[-_]/)[0];
+ const match = descriptions.find((description) => description.locale?.split(/[-_]/)[0] === language);
+ return (match ?? descriptions[0]).description;
+}
diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx
new file mode 100644
index 0000000..9d235d3
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx
@@ -0,0 +1,191 @@
+import React from 'react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SWRConfig } from 'swr';
+import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
+import { type AuditEncounter, type AuditObs, type AuditPatient } from '../types';
+import PatientActivity from './patient-activity.component';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+const mockUseConfig = jest.mocked(useConfig);
+
+const clerk = { uuid: 'user-clerk', display: 'Cos John' };
+const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' };
+const created = '2026-09-01T11:09:00.000+0000';
+const dayAfter = '2026-09-02T08:30:00.000+0000';
+
+const patient: AuditPatient = {
+ uuid: 'patient-1',
+ display: 'Y2AHXV - Dave TestPatient',
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
+};
+
+const encounters: Array = [
+ {
+ uuid: 'enc-1',
+ encounterDatetime: created,
+ encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
+ auditInfo: { creator: clerk, dateCreated: created },
+ },
+];
+
+const obs: Array = [
+ {
+ uuid: 'obs-pulse-new',
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
+ previousVersion: { uuid: 'obs-pulse-old' },
+ auditInfo: { creator: nurse, dateCreated: dayAfter },
+ },
+ {
+ uuid: 'obs-pulse-old',
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
+ voided: true,
+ auditInfo: { creator: clerk, dateCreated: created, voidedBy: nurse, dateVoided: dayAfter },
+ },
+];
+
+function mockRestApi({ encounterList = encounters }: { encounterList?: Array } = {}) {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/obs?encounter=')) {
+ return Promise.resolve({ data: { results: obs } }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: encounterList } }) as ReturnType;
+ });
+}
+
+function renderPatientActivity() {
+ const onSelectEncounter = jest.fn();
+ render(
+ new Map() }}>
+
+ ,
+ );
+ return onSelectEncounter;
+}
+
+function rowFor(name: string) {
+ return screen.getAllByRole('row').find((row) => row.textContent?.includes(name));
+}
+
+/** The activity log's rows, which are the ones naming an action. */
+function logRows() {
+ return screen
+ .getAllByRole('row')
+ .filter((row) =>
+ /Created encounter|Edited observation|Recorded observation|Deleted observation/.test(row.textContent ?? ''),
+ );
+}
+
+describe(' ', () => {
+ beforeEach(() => {
+ mockRestApi();
+ });
+
+ it('says who touched the record and what each of them did', async () => {
+ renderPatientActivity();
+
+ expect(await screen.findByText('Who modified this patient record')).toBeInTheDocument();
+
+ const clerkRow = rowFor('Cos John');
+ expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('1');
+ expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('1');
+
+ const nurseRow = rowFor('Louidor Jean paul');
+ expect(within(nurseRow).getAllByRole('cell')[3]).toHaveTextContent('1');
+ });
+
+ it('lists the individual events, most recent first', async () => {
+ renderPatientActivity();
+ await screen.findByText('Activity log');
+
+ const rows = logRows();
+ expect(rows[0]).toHaveTextContent('Edited observation');
+ expect(rows[0]).toHaveTextContent('Louidor Jean paul');
+ expect(rows[0]).toHaveTextContent('Pulse');
+ expect(rows[0]).toHaveTextContent('COVID-19 Admission');
+ });
+
+ it('narrows the activity log to one user, and back again', async () => {
+ renderPatientActivity();
+ await screen.findByText('Activity log');
+ expect(logRows()).toHaveLength(3);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Louidor Jean paul' }));
+
+ const nurseOnly = logRows();
+ expect(nurseOnly).toHaveLength(1);
+ expect(nurseOnly[0]).toHaveTextContent('Edited observation');
+ expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'true');
+
+ await userEvent.click(screen.getByRole('button', { name: /show everyone/i }));
+
+ expect(logRows()).toHaveLength(3);
+ expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('clicking the selected user again shows everyone', async () => {
+ renderPatientActivity();
+ await screen.findByText('Activity log');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cos John' }));
+ expect(logRows()).toHaveLength(2);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cos John' }));
+ expect(logRows()).toHaveLength(3);
+ });
+
+ it('drills into the encounter behind an event', async () => {
+ const onSelectEncounter = renderPatientActivity();
+ await screen.findByText('Activity log');
+
+ await userEvent.click(screen.getAllByRole('button', { name: /COVID-19 Admission/ })[0]);
+
+ expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
+ });
+
+ it("reads every encounter's observations, however many there are", async () => {
+ mockRestApi({
+ encounterList: [
+ encounters[0],
+ { ...encounters[0], uuid: 'enc-2', auditInfo: { creator: clerk, dateCreated: created } },
+ ],
+ });
+ renderPatientActivity();
+
+ await screen.findByText('Activity log');
+
+ // Both encounters are created by the clerk and both contribute their observations.
+ const clerkRow = rowFor('Cos John');
+ expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('2');
+ expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('2');
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-1'));
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-2'));
+ });
+
+ it('says how far the observation reads have got while they run', async () => {
+ let releaseObs: (value: unknown) => void = () => {};
+ const obsGate = new Promise((resolve) => {
+ releaseObs = resolve;
+ });
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/obs?encounter=')) {
+ return obsGate.then(() => ({ data: { results: obs } })) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: encounters } }) as ReturnType;
+ });
+ renderPatientActivity();
+
+ expect(await screen.findByText(/reading observations from 0 of 1 encounters/i)).toBeInTheDocument();
+
+ releaseObs(null);
+
+ expect(await screen.findByText('Activity log')).toBeInTheDocument();
+ });
+
+ it('says when there is nothing to show', async () => {
+ mockRestApi({ encounterList: [] });
+ renderPatientActivity();
+
+ expect(await screen.findByText(/no recorded activity/i)).toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.tsx
new file mode 100644
index 0000000..b3a731b
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-activity.component.tsx
@@ -0,0 +1,203 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ DismissibleTag,
+ InlineLoading,
+ Pagination,
+ SkeletonText,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tag,
+} from '@carbon/react';
+import { ErrorState } from '@openmrs/esm-framework';
+import { type AuditPatient } from '../types';
+import { formatAuditDatetime } from './audit-format';
+import { usePatientActivity } from './audit.resource';
+import { type EncounterFilters } from './encounter-filters';
+import { type AuditAction, eventUserUuid } from './patient-activity';
+import styles from './audit.scss';
+
+interface PatientActivityProps {
+ patient: AuditPatient | undefined;
+ includeDeleted: boolean;
+ filters: EncounterFilters;
+ onSelectEncounter(encounterUuid: string): void;
+}
+
+const eventsPerPage = 20;
+
+/**
+ * Who touched this patient's record: one row per user with what they did, and the individual
+ * events behind those counts.
+ */
+export default function PatientActivity({ patient, includeDeleted, filters, onSelectEncounter }: PatientActivityProps) {
+ const { t } = useTranslation();
+ const [selectedUserUuid, setSelectedUserUuid] = useState(null);
+ const [page, setPage] = useState(1);
+ const { events, userActivity, scanProgress, error, isLoading } = usePatientActivity(patient, includeDeleted, filters);
+
+ const selectedUser = useMemo(
+ () => userActivity.find((activity) => activity.userUuid === selectedUserUuid),
+ [selectedUserUuid, userActivity],
+ );
+
+ const shownEvents = useMemo(
+ () => (selectedUserUuid ? events.filter((auditEvent) => eventUserUuid(auditEvent) === selectedUserUuid) : events),
+ [events, selectedUserUuid],
+ );
+
+ useEffect(() => {
+ setPage(1);
+ }, [selectedUserUuid]);
+
+ /** Clamped rather than reset, so a revalidation that shortens the log cannot blank the page. */
+ const totalPages = Math.max(1, Math.ceil(shownEvents.length / eventsPerPage));
+ const currentPage = Math.min(page, totalPages);
+ const pagedEvents = shownEvents.slice((currentPage - 1) * eventsPerPage, currentPage * eventsPerPage);
+
+ const actionLabels = useMemo>(
+ () => ({
+ encounterCreated: { label: t('encounterCreatedAction', 'Created encounter'), type: 'teal' },
+ encounterChanged: { label: t('encounterChangedAction', 'Changed encounter'), type: 'blue' },
+ encounterDeleted: { label: t('encounterDeletedAction', 'Deleted encounter'), type: 'red' },
+ obsRecorded: { label: t('obsRecordedAction', 'Recorded observation'), type: 'gray' },
+ obsEdited: { label: t('obsEditedAction', 'Edited observation'), type: 'blue' },
+ obsDeleted: { label: t('obsDeletedAction', 'Deleted observation'), type: 'red' },
+ }),
+ [t],
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (isLoading) {
+ return (
+ <>
+ {scanProgress.total > 0 ? (
+
+ ) : null}
+
+ >
+ );
+ }
+
+ if (events.length === 0) {
+ return {t('noActivity', 'There is no recorded activity to show.')}
;
+ }
+
+ return (
+ <>
+ {t('whoTouchedThisRecord', 'Who modified this patient record')}
+
+
+
+
+ {t('user', 'User')}
+ {t('encountersCreated', 'Encounters created')}
+ {t('obsRecordedCount', 'Observations recorded')}
+ {t('obsEditedCount', 'Observations edited')}
+ {t('obsDeletedCount', 'Observations deleted')}
+ {t('firstActivity', 'First activity')}
+ {t('lastActivity', 'Last activity')}
+
+
+
+ {userActivity.map((activity) => {
+ const isSelected = activity.userUuid === selectedUserUuid;
+ return (
+
+
+ setSelectedUserUuid(isSelected ? null : activity.userUuid)}
+ title={t('showOnlyThisUser', "Show only this user's activity")}
+ type="button">
+ {activity.userDisplay || t('unknownUser', 'Unknown user')}
+
+
+ {activity.counts.encounterCreated}
+ {activity.counts.obsRecorded}
+ {activity.counts.obsEdited}
+ {activity.counts.obsDeleted}
+ {formatAuditDatetime(activity.firstActivity)}
+ {formatAuditDatetime(activity.lastActivity)}
+
+ );
+ })}
+
+
+
+
+
+
{t('activityLog', 'Activity log')}
+ {selectedUserUuid ? (
+ setSelectedUserUuid(null)}
+ text={selectedUser?.userDisplay || t('unknownUser', 'Unknown user')}
+ title={t('showEveryone', 'Show everyone')}
+ type="blue"
+ />
+ ) : null}
+
+
+
+
+
+ {t('when', 'When')}
+ {t('user', 'User')}
+ {t('action', 'Action')}
+ {t('detail', 'Detail')}
+ {t('encounter', 'Encounter')}
+
+
+
+ {pagedEvents.map((auditEvent) => (
+
+ {formatAuditDatetime(auditEvent.timestamp)}
+ {auditEvent.user?.display || t('unknownUser', 'Unknown user')}
+
+ {actionLabels[auditEvent.action].label}
+
+ {auditEvent.concept}
+
+ onSelectEncounter(auditEvent.encounter.uuid)}
+ type="button">
+ {[
+ auditEvent.encounter.encounterType?.display,
+ formatAuditDatetime(auditEvent.encounter.encounterDatetime),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+ ))}
+
+
+
+ setPage(nextPage)}
+ page={currentPage}
+ pageSize={eventsPerPage}
+ pageSizes={[eventsPerPage]}
+ size="sm"
+ totalItems={shownEvents.length}
+ />
+ >
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/patient-activity.test.ts b/packages/esm-audit-app/src/audit/patient-activity.test.ts
new file mode 100644
index 0000000..0f0eee5
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-activity.test.ts
@@ -0,0 +1,155 @@
+import { type AuditEncounter, type AuditObs } from '../types';
+import { buildAuditEvents, eventUserUuid, summariseByUser, unknownUserUuid } from './patient-activity';
+
+const clerk = { uuid: 'user-clerk', display: 'Cos John' };
+const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' };
+
+const encounterCreated = '2026-09-01T11:09:00.000+0000';
+const dayAfter = '2026-09-02T08:30:00.000+0000';
+
+const encounter: AuditEncounter = {
+ uuid: 'enc-1',
+ encounterDatetime: encounterCreated,
+ encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' },
+ auditInfo: { creator: clerk, dateCreated: encounterCreated },
+};
+
+function obs(overrides: Partial & { uuid: string }): AuditObs {
+ return {
+ concept: { uuid: 'concept-pulse', display: 'Pulse' },
+ auditInfo: { creator: clerk, dateCreated: encounterCreated },
+ ...overrides,
+ };
+}
+
+describe('buildAuditEvents', () => {
+ it('records who created an encounter', () => {
+ expect(buildAuditEvents([encounter], {})).toEqual([
+ expect.objectContaining({ action: 'encounterCreated', user: clerk, timestamp: encounterCreated }),
+ ]);
+ });
+
+ it('records an encounter that was changed and one that was deleted', () => {
+ const changed: AuditEncounter = {
+ ...encounter,
+ voided: true,
+ auditInfo: {
+ creator: clerk,
+ dateCreated: encounterCreated,
+ changedBy: nurse,
+ dateChanged: dayAfter,
+ voidedBy: nurse,
+ dateVoided: dayAfter,
+ },
+ };
+
+ expect(buildAuditEvents([changed], {}).map((auditEvent) => auditEvent.action)).toEqual([
+ 'encounterChanged',
+ 'encounterDeleted',
+ 'encounterCreated',
+ ]);
+ });
+
+ it('counts an edit once, as an edit, rather than as a deletion and a recording', () => {
+ const events = buildAuditEvents([encounter], {
+ 'enc-1': [
+ obs({
+ uuid: 'obs-new',
+ previousVersion: { uuid: 'obs-old' },
+ auditInfo: { creator: nurse, dateCreated: dayAfter },
+ }),
+ obs({
+ uuid: 'obs-old',
+ voided: true,
+ auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter },
+ }),
+ ],
+ });
+
+ expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([
+ ['obsEdited', 'user-nurse'],
+ ['encounterCreated', 'user-clerk'],
+ ['obsRecorded', 'user-clerk'],
+ ]);
+ });
+
+ it('records an outright deletion alongside the original recording', () => {
+ const events = buildAuditEvents([encounter], {
+ 'enc-1': [
+ obs({
+ uuid: 'obs-gone',
+ voided: true,
+ auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter },
+ }),
+ ],
+ });
+
+ expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([
+ ['obsDeleted', 'user-nurse'],
+ ['encounterCreated', 'user-clerk'],
+ ['obsRecorded', 'user-clerk'],
+ ]);
+ expect(events[0].concept).toBe('Pulse');
+ });
+
+ it('leaves out events the api gave no timestamp for', () => {
+ expect(buildAuditEvents([{ uuid: 'enc-2' }], {})).toEqual([]);
+ });
+
+ it('puts the most recent event first', () => {
+ const events = buildAuditEvents([encounter], {
+ 'enc-1': [obs({ uuid: 'obs-later', auditInfo: { creator: nurse, dateCreated: dayAfter } })],
+ });
+
+ expect(events[0].timestamp).toBe(dayAfter);
+ });
+});
+
+describe('eventUserUuid', () => {
+ it('keys an event by its user, or by the unknown user when the api named none', () => {
+ const [withUser] = buildAuditEvents([encounter], {});
+ const [withoutUser] = buildAuditEvents([{ uuid: 'enc-2', auditInfo: { dateCreated: dayAfter } }], {});
+
+ expect(eventUserUuid(withUser)).toBe(clerk.uuid);
+ expect(eventUserUuid(withoutUser)).toBe(unknownUserUuid);
+ });
+});
+
+describe('summariseByUser', () => {
+ const events = buildAuditEvents([encounter], {
+ 'enc-1': [
+ obs({ uuid: 'obs-1' }),
+ obs({ uuid: 'obs-2' }),
+ obs({
+ uuid: 'obs-new',
+ previousVersion: { uuid: 'obs-2' },
+ auditInfo: { creator: nurse, dateCreated: dayAfter },
+ }),
+ ],
+ });
+
+ it('counts what each user did, busiest first', () => {
+ const summary = summariseByUser(events);
+
+ expect(summary.map((activity) => activity.userDisplay)).toEqual(['Cos John', 'Louidor Jean paul']);
+ expect(summary[0].counts).toMatchObject({ encounterCreated: 1, obsRecorded: 2, obsEdited: 0 });
+ expect(summary[0].totalEvents).toBe(3);
+ expect(summary[1].counts).toMatchObject({ obsEdited: 1 });
+ });
+
+ it('brackets each user’s activity with when they first and last touched the record', () => {
+ const summary = summariseByUser(events);
+
+ expect(summary[0].firstActivity).toBe(encounterCreated);
+ expect(summary[0].lastActivity).toBe(encounterCreated);
+ expect(summary[1].firstActivity).toBe(dayAfter);
+ });
+
+ it('gathers events with no named user rather than dropping them', () => {
+ const summary = summariseByUser(buildAuditEvents([{ uuid: 'enc-3', auditInfo: { dateCreated: dayAfter } }], {}));
+
+ expect(summary).toHaveLength(1);
+ expect(summary[0].userUuid).toBe(unknownUserUuid);
+ expect(summary[0].userDisplay).toBe('');
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/patient-activity.ts b/packages/esm-audit-app/src/audit/patient-activity.ts
new file mode 100644
index 0000000..6642fda
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-activity.ts
@@ -0,0 +1,156 @@
+import { type AuditEncounter, type AuditObs, type OpenmrsResourceRef } from '../types';
+
+/** What someone did to a patient's record. */
+export type AuditAction =
+ | 'encounterCreated'
+ | 'encounterChanged'
+ | 'encounterDeleted'
+ | 'obsRecorded'
+ | 'obsEdited'
+ | 'obsDeleted';
+
+export interface AuditEvent {
+ /** Stable across renders: the row it came from plus what happened to it. */
+ key: string;
+ action: AuditAction;
+ /** When it happened, as the REST API reported it. */
+ timestamp: string;
+ user: OpenmrsResourceRef | undefined;
+ encounter: AuditEncounter;
+ /** The concept whose value was touched, for the observation actions. */
+ concept?: string;
+}
+
+/** One user's footprint on the part of the record that was read. */
+export interface UserActivity {
+ userUuid: string;
+ userDisplay: string;
+ counts: Record;
+ firstActivity: string;
+ lastActivity: string;
+ totalEvents: number;
+}
+
+/** The obs of one encounter, keyed by the encounter's uuid. */
+export type ObsByEncounter = Record>;
+
+/** Stands in for the user on events whose actor the API did not report. */
+export const unknownUserUuid = 'unknown';
+
+/**
+ * The key an event is grouped and filtered by. `summariseByUser` and the activity log both use it,
+ * so a row's counts and the events behind it can never disagree about who did what.
+ */
+export function eventUserUuid(auditEvent: AuditEvent): string {
+ return auditEvent.user?.uuid ?? unknownUserUuid;
+}
+
+function event(
+ action: AuditAction,
+ timestamp: string | undefined,
+ user: OpenmrsResourceRef | undefined,
+ encounter: AuditEncounter,
+ keySuffix: string,
+ concept?: string,
+): AuditEvent | null {
+ return timestamp ? { key: `${keySuffix}:${action}`, action, timestamp, user, encounter, concept } : null;
+}
+
+/**
+ * Turns encounters and their observations into the list of things people did to the record.
+ *
+ * Every obs row was created by someone at some point, so each yields one creation event — an edit
+ * if it replaced an earlier obs, a plain recording otherwise. A voided obs yields a deletion event
+ * too, unless a later obs replaced it: that voiding is the other half of the successor's edit, and
+ * counting it again would make one correction look like two acts.
+ */
+export function buildAuditEvents(encounters: Array, obsByEncounter: ObsByEncounter): Array {
+ const events: Array = [];
+
+ for (const encounter of encounters) {
+ const audit = encounter.auditInfo;
+ events.push(
+ event('encounterCreated', audit?.dateCreated, audit?.creator, encounter, encounter.uuid),
+ event('encounterChanged', audit?.dateChanged, audit?.changedBy, encounter, encounter.uuid),
+ encounter.voided
+ ? event('encounterDeleted', audit?.dateVoided, audit?.voidedBy, encounter, encounter.uuid)
+ : null,
+ );
+
+ const obsList = obsByEncounter[encounter.uuid] ?? [];
+ const supersededUuids = new Set(
+ obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)),
+ );
+
+ for (const obs of obsList) {
+ const obsAudit = obs.auditInfo;
+ const concept = obs.concept?.display;
+ events.push(
+ event(
+ obs.previousVersion ? 'obsEdited' : 'obsRecorded',
+ obsAudit?.dateCreated,
+ obsAudit?.creator,
+ encounter,
+ obs.uuid,
+ concept,
+ ),
+ obs.voided && !supersededUuids.has(obs.uuid)
+ ? event('obsDeleted', obsAudit?.dateVoided, obsAudit?.voidedBy, encounter, obs.uuid, concept)
+ : null,
+ );
+ }
+ }
+
+ return events
+ .filter((auditEvent): auditEvent is AuditEvent => auditEvent !== null)
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+}
+
+function emptyCounts(): Record {
+ return {
+ encounterCreated: 0,
+ encounterChanged: 0,
+ encounterDeleted: 0,
+ obsRecorded: 0,
+ obsEdited: 0,
+ obsDeleted: 0,
+ };
+}
+
+/**
+ * Who touched the record, and how much: one row per user, busiest first. Events whose user the
+ * API did not report are gathered under a single unknown user rather than dropped, so the counts
+ * still add up to what was read.
+ */
+export function summariseByUser(events: Array): Array {
+ const byUser = new Map();
+
+ for (const auditEvent of events) {
+ const userUuid = eventUserUuid(auditEvent);
+ let activity = byUser.get(userUuid);
+ if (!activity) {
+ activity = {
+ userUuid,
+ userDisplay: auditEvent.user?.display ?? '',
+ counts: emptyCounts(),
+ firstActivity: auditEvent.timestamp,
+ lastActivity: auditEvent.timestamp,
+ totalEvents: 0,
+ };
+ byUser.set(userUuid, activity);
+ }
+
+ activity.counts[auditEvent.action] += 1;
+ activity.totalEvents += 1;
+ if (auditEvent.timestamp < activity.firstActivity) {
+ activity.firstActivity = auditEvent.timestamp;
+ }
+ if (auditEvent.timestamp > activity.lastActivity) {
+ activity.lastActivity = auditEvent.timestamp;
+ }
+ }
+
+ return Array.from(byUser.values()).sort(
+ (a, b) => b.totalEvents - a.totalEvents || a.userDisplay.localeCompare(b.userDisplay),
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/patient-encounters.component.tsx b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx
new file mode 100644
index 0000000..f5f0389
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx
@@ -0,0 +1,134 @@
+import React, { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ DataTableSkeleton,
+ Pagination,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tag,
+} from '@carbon/react';
+import { ErrorState, useConfig } from '@openmrs/esm-framework';
+import { type Config } from '../config-schema';
+import { type AuditEncounter, type AuditPatient } from '../types';
+import { formatAuditDatetime } from './audit-format';
+import { usePatientEncounters } from './audit.resource';
+import { type EncounterFilters, hasActiveFilters } from './encounter-filters';
+import styles from './audit.scss';
+
+interface PatientEncountersProps {
+ patient: AuditPatient | undefined;
+ includeDeleted: boolean;
+ filters: EncounterFilters;
+ isLoadingPatient: boolean;
+ onSelectEncounter(encounterUuid: string): void;
+}
+
+function providerNames(encounter: AuditEncounter): string {
+ return (encounter.encounterProviders ?? [])
+ .filter((encounterProvider) => !encounterProvider.voided)
+ .map((encounterProvider) => encounterProvider.provider?.display)
+ .filter(Boolean)
+ .join(', ');
+}
+
+/**
+ * Every encounter recorded for the patient, most recent first, as the way into one encounter's
+ * audit trail. Deleted encounters are hidden by default, as they were on the legacy admin page.
+ */
+export default function PatientEncounters({
+ patient,
+ includeDeleted,
+ filters,
+ isLoadingPatient,
+ onSelectEncounter,
+}: PatientEncountersProps) {
+ const { t } = useTranslation();
+ const config = useConfig();
+ const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10);
+ const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
+
+ const { encounters, totalCount, currentPage, goTo, error, isLoading } = usePatientEncounters(
+ patient,
+ includeDeleted,
+ filters,
+ pageSize,
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (isLoadingPatient || isLoading) {
+ return ;
+ }
+
+ if (encounters.length === 0) {
+ return (
+
+ {hasActiveFilters(filters)
+ ? t('noEncountersMatchFilters', 'No encounters match these filters.')
+ : t('noEncountersFound', 'This patient has no encounters to audit.')}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {t('encounterDate', 'Encounter date')}
+ {t('encounterType', 'Encounter type')}
+ {t('form', 'Form')}
+ {t('provider', 'Provider')}
+ {t('location', 'Location')}
+ {t('status', 'Status')}
+
+
+
+ {encounters.map((encounter) => (
+ onSelectEncounter(encounter.uuid)}>
+
+ {
+ event.stopPropagation();
+ onSelectEncounter(encounter.uuid);
+ }}
+ type="button">
+ {formatAuditDatetime(encounter.encounterDatetime)}
+
+
+ {encounter.encounterType?.display}
+ {encounter.form?.display}
+ {providerNames(encounter)}
+ {encounter.location?.display}
+ {encounter.voided ? {t('deleted', 'Deleted')} : null}
+
+ ))}
+
+
+
+ {
+ setPageSize(nextPageSize);
+ goTo(nextPage);
+ }}
+ page={currentPage}
+ pageSize={pageSize}
+ pageSizes={pageSizes}
+ size="sm"
+ totalItems={totalCount}
+ />
+ >
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/patient-record.component.test.tsx b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx
new file mode 100644
index 0000000..7f52df9
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SWRConfig } from 'swr';
+import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
+import { type AuditEncounter, type AuditPatient } from '../types';
+import PatientRecord from './patient-record.component';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+const mockUseConfig = jest.mocked(useConfig);
+
+const mockPatient: AuditPatient = {
+ uuid: 'patient-1',
+ display: 'Y2AHXV - Dave TestPatient',
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
+ person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' },
+};
+
+const consultation: AuditEncounter = {
+ uuid: 'enc-1',
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
+ voided: false,
+ encounterType: { uuid: 'type-1', display: 'Oncology Consultation' },
+ form: { uuid: 'form-1', display: 'Oncology Consult Note' },
+ location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
+ encounterProviders: [{ uuid: 'ep-1', provider: { uuid: 'prov-1', display: 'Louidor Jean paul' } }],
+};
+
+const deletedCheckin: AuditEncounter = {
+ uuid: 'enc-2',
+ encounterDatetime: '2026-04-11T08:00:00.000+0000',
+ voided: true,
+ encounterType: { uuid: 'type-2', display: 'Inscription' },
+ form: { uuid: 'form-2', display: 'LiveCheckin' },
+ location: { uuid: 'loc-2', display: 'CDI Klinik Ekstèn Jeneral' },
+ patient: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient' },
+};
+
+/** The free-text encounter search matches on identifier, so it can return other patients' rows. */
+const otherPatientsEncounter: AuditEncounter = {
+ uuid: 'enc-3',
+ encounterDatetime: '2026-05-01T08:00:00.000+0000',
+ voided: false,
+ encounterType: { uuid: 'type-3', display: 'Consultation' },
+ patient: { uuid: 'patient-2', display: 'Y2AHXV2 - Someone Else' },
+};
+
+/**
+ * The encounter list is the only read that asks for a total count; the unfiltered scan behind the
+ * encounter-type dropdown and the activity view reads the same endpoint without one.
+ */
+function mockRestApi({ pagedEncounters = [consultation] }: { pagedEncounters?: Array } = {}) {
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/patient/patient-1')) {
+ return Promise.resolve({ data: mockPatient }) as ReturnType;
+ }
+ if (url.includes('/obs?encounter=')) {
+ return Promise.resolve({ data: { results: [] } }) as ReturnType;
+ }
+ if (url.includes('/encounter?q=')) {
+ return Promise.resolve({
+ data: {
+ results: [{ ...consultation, patient: deletedCheckin.patient }, deletedCheckin, otherPatientsEncounter],
+ },
+ }) as ReturnType;
+ }
+ if (url.includes('totalCount=true')) {
+ return Promise.resolve({
+ data: { results: pagedEncounters, totalCount: pagedEncounters.length },
+ }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: [consultation, deletedCheckin] } }) as ReturnType;
+ });
+}
+
+function renderPatientRecord() {
+ const onSelectEncounter = jest.fn();
+ const onBackToSearch = jest.fn();
+ const onSelectView = jest.fn();
+ render(
+ new Map() }}>
+
+ ,
+ );
+ return { onSelectEncounter, onBackToSearch, onSelectView };
+}
+
+describe(' ', () => {
+ beforeEach(() => {
+ mockUseConfig.mockReturnValue({
+ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart',
+ });
+ mockRestApi();
+ });
+
+ it("lists the patient's encounters, without the deleted ones", async () => {
+ renderPatientRecord();
+
+ expect(await screen.findByText('Dave TestPatient')).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'Oncology Consultation' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'Louidor Jean paul' })).toBeInTheDocument();
+ expect(screen.queryByText('LiveCheckin')).not.toBeInTheDocument();
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/encounter?patient=patient-1'));
+ });
+
+ it('includes deleted encounters on request, and only this patient’s', async () => {
+ renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+
+ await userEvent.click(screen.getByRole('checkbox', { name: /include deleted encounters/i }));
+
+ expect(await screen.findByRole('cell', { name: 'LiveCheckin' })).toBeInTheDocument();
+ expect(screen.queryByRole('cell', { name: 'Consultation' })).not.toBeInTheDocument();
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
+ expect.stringContaining('/ws/rest/v1/encounter?q=Y2AHXV&includeAll=true'),
+ );
+
+ const deletedRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('LiveCheckin'));
+ expect(deletedRow).toHaveTextContent('Deleted');
+ });
+
+ it('drills down into the encounter that is clicked', async () => {
+ const { onSelectEncounter } = renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+
+ await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
+
+ expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
+ });
+
+ it("offers only the encounter types this patient's encounters use", async () => {
+ renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
+
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
+ expect(options.map((option) => option.textContent)).toEqual(['Inscription', 'Oncology Consultation']);
+ });
+
+ it('asks the server for one encounter type when one is chosen', async () => {
+ renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
+
+ await waitFor(() =>
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('&encounterType=type-2')),
+ );
+ });
+
+ it('offers to clear the filters once one is set, and says when nothing matches', async () => {
+ renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
+
+ mockRestApi({ pagedEncounters: [] });
+
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
+
+ expect(await screen.findByText(/no encounters match these filters/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: /clear filters/i }));
+
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
+ });
+
+ it('goes back to the patient search', async () => {
+ const { onBackToSearch } = renderPatientRecord();
+
+ await userEvent.click(screen.getByRole('button', { name: /back to patient search/i }));
+
+ expect(onBackToSearch).toHaveBeenCalled();
+ });
+
+ it('switches to the record activity view', async () => {
+ const { onSelectView } = renderPatientRecord();
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
+
+ await userEvent.click(screen.getByRole('tab', { name: /patient activity/i }));
+
+ expect(onSelectView).toHaveBeenCalledWith('activity');
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/patient-record.component.tsx b/packages/esm-audit-app/src/audit/patient-record.component.tsx
new file mode 100644
index 0000000..0dd3f7b
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-record.component.tsx
@@ -0,0 +1,115 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Checkbox, InlineNotification, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
+import { ArrowLeftIcon, ErrorState } from '@openmrs/esm-framework';
+import { canListDeletedEncounters, getPreferredIdentifier, useAuditPatient } from './audit.resource';
+import { type EncounterFilters } from './encounter-filters';
+import EncounterFiltersBar from './encounter-filters.component';
+import PatientActivity from './patient-activity.component';
+import PatientEncounters from './patient-encounters.component';
+import styles from './audit.scss';
+
+export type PatientRecordView = 'encounters' | 'activity';
+
+export const patientRecordViews: Array = ['encounters', 'activity'];
+
+interface PatientRecordProps {
+ patientUuid: string;
+ view: PatientRecordView;
+ onSelectView(view: PatientRecordView): void;
+ onSelectEncounter(encounterUuid: string): void;
+ onBackToSearch(): void;
+}
+
+/**
+ * Step two of the audit trail. The patient's record can be read two ways — as the list of
+ * encounters to drill into, or as the record of who has touched it — and both are narrowed by the
+ * same filters, which is why they share this component's state rather than holding their own.
+ */
+export default function PatientRecord({
+ patientUuid,
+ view,
+ onSelectView,
+ onSelectEncounter,
+ onBackToSearch,
+}: PatientRecordProps) {
+ const { t } = useTranslation();
+ const [includeDeleted, setIncludeDeleted] = useState(false);
+ const [filters, setFilters] = useState({});
+
+ const { patient, error: patientError, isLoading: isLoadingPatient } = useAuditPatient(patientUuid);
+ const cannotIncludeDeleted = includeDeleted && Boolean(patient) && !canListDeletedEncounters(patient);
+
+ return (
+
+
+ {t('backToPatientSearch', 'Back to patient search')}
+
+
+ {patient?.person?.display ?? patient?.display ?? ''}
+
+ {[getPreferredIdentifier(patient), patient?.person?.gender, patient?.person?.age]
+ .filter((part) => part !== undefined && part !== '')
+ .join(' · ')}
+
+
+
+
+
+
+ setIncludeDeleted(checked)}
+ />
+
+
+ {cannotIncludeDeleted ? (
+
+ ) : null}
+
+ {patientError ? (
+
+ ) : (
+
onSelectView(patientRecordViews[selectedIndex])}
+ selectedIndex={Math.max(0, patientRecordViews.indexOf(view))}>
+
+ {t('encounters', 'Encounters')}
+ {t('recordActivity', 'Patient activity')}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/esm-audit-app/src/audit/patient-search.component.test.tsx b/packages/esm-audit-app/src/audit/patient-search.component.test.tsx
new file mode 100644
index 0000000..c0c570f
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-search.component.test.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SWRConfig } from 'swr';
+import { openmrsFetch } from '@openmrs/esm-framework';
+import PatientSearch from './patient-search.component';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+
+const mockPatients = [
+ {
+ uuid: 'patient-1',
+ display: 'Y2AHXV - Dave TestPatient',
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
+ person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' },
+ },
+];
+
+function renderPatientSearch() {
+ const onSelectPatient = jest.fn();
+ render(
+ new Map() }}>
+
+ ,
+ );
+ return onSelectPatient;
+}
+
+describe(' ', () => {
+ beforeEach(() => {
+ mockOpenmrsFetch.mockResolvedValue({
+ data: { results: mockPatients, totalCount: 1 },
+ } as unknown as ReturnType);
+ });
+
+ it('asks for a search term before searching', () => {
+ renderPatientSearch();
+
+ expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument();
+ expect(mockOpenmrsFetch).not.toHaveBeenCalled();
+ });
+
+ it('searches patients by name or identifier and lists what it finds', async () => {
+ renderPatientSearch();
+
+ await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV');
+
+ expect(await screen.findByRole('button', { name: 'Dave TestPatient' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'Y2AHXV' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: '42' })).toBeInTheDocument();
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/patient?q=Y2AHXV'));
+ });
+
+ it('drills down into the patient that is clicked', async () => {
+ const onSelectPatient = renderPatientSearch();
+
+ await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV');
+ await userEvent.click(await screen.findByRole('button', { name: 'Dave TestPatient' }));
+
+ expect(onSelectPatient).toHaveBeenCalledWith('patient-1');
+ });
+
+ it('says so when nothing matches', async () => {
+ mockOpenmrsFetch.mockResolvedValue({ data: { results: [], totalCount: 0 } } as unknown as ReturnType<
+ typeof openmrsFetch
+ >);
+ renderPatientSearch();
+
+ await userEvent.type(screen.getByRole('searchbox'), 'nobody');
+
+ expect(await screen.findByText(/no patients match "nobody"/i)).toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-audit-app/src/audit/patient-search.component.tsx b/packages/esm-audit-app/src/audit/patient-search.component.tsx
new file mode 100644
index 0000000..24c218f
--- /dev/null
+++ b/packages/esm-audit-app/src/audit/patient-search.component.tsx
@@ -0,0 +1,120 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ DataTableSkeleton,
+ Pagination,
+ Search,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@carbon/react';
+import { ErrorState, useConfig, useDebounce } from '@openmrs/esm-framework';
+import { type Config } from '../config-schema';
+import { formatAuditDate } from './audit-format';
+import { getPreferredIdentifier, usePatientSearch } from './audit.resource';
+import styles from './audit.scss';
+
+interface PatientSearchProps {
+ onSelectPatient(patientUuid: string): void;
+}
+
+/**
+ * Step one of the audit trail: find the patient whose records are being audited, by name or by
+ * identifier. This mirrors the legacy admin page, which searched encounters by patient name or
+ * identifier, except that the patient is chosen explicitly before their encounters are listed.
+ */
+export default function PatientSearch({ onSelectPatient }: PatientSearchProps) {
+ const { t } = useTranslation();
+ const config = useConfig();
+ const [searchTerm, setSearchTerm] = useState('');
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(config.patientSearchPageSize ?? 10);
+ const debouncedSearchTerm = useDebounce(searchTerm.trim(), 300);
+ const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
+ const { patients, totalCount, error, isLoading } = usePatientSearch(debouncedSearchTerm, page, pageSize);
+
+ useEffect(() => {
+ setPage(1);
+ }, [debouncedSearchTerm]);
+
+ return (
+
+
setSearchTerm(event.target.value)}
+ size="lg"
+ value={searchTerm}
+ />
+ {!debouncedSearchTerm ? (
+
+ {t('searchToBegin', 'Enter a patient name or identifier to begin auditing their records.')}
+
+ ) : error ? (
+
+ ) : isLoading ? (
+
+ ) : patients.length === 0 ? (
+
+ {t('noPatientsFound', 'No patients match "{{searchTerm}}".', { searchTerm: debouncedSearchTerm })}
+
+ ) : (
+ <>
+
+
+
+
+ {t('patientName', 'Patient name')}
+ {t('identifier', 'Identifier')}
+ {t('gender', 'Gender')}
+ {t('age', 'Age')}
+ {t('birthdate', 'Date of birth')}
+
+
+
+ {patients.map((patient) => (
+ onSelectPatient(patient.uuid)}>
+
+ {
+ event.stopPropagation();
+ onSelectPatient(patient.uuid);
+ }}
+ type="button">
+ {patient.person?.display ?? patient.display}
+
+
+ {getPreferredIdentifier(patient)}
+ {patient.person?.gender}
+ {patient.person?.age}
+ {formatAuditDate(patient.person?.birthdate)}
+
+ ))}
+
+
+
+ {
+ setPage(nextPage);
+ setPageSize(nextPageSize);
+ }}
+ page={page}
+ pageSize={pageSize}
+ pageSizes={pageSizes}
+ size="sm"
+ totalItems={totalCount}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/packages/esm-audit-app/src/config-schema.ts b/packages/esm-audit-app/src/config-schema.ts
new file mode 100644
index 0000000..dcacddd
--- /dev/null
+++ b/packages/esm-audit-app/src/config-schema.ts
@@ -0,0 +1,36 @@
+/**
+ * This is the config schema. In OpenMRS Microfrontends, all config parameters
+ * are optional, so every element must have a reasonable default. See the
+ * configuration system documentation:
+ * https://openmrs.github.io/openmrs-esm-core/#/main/config
+ */
+import { Type, validators } from '@openmrs/esm-framework';
+
+export const configSchema = {
+ patientSearchPageSize: {
+ _type: Type.Number,
+ _description: 'How many patients to show per page of patient search results.',
+ _default: 10,
+ _validators: [validators.inRange(1, 100)],
+ },
+ encountersPageSize: {
+ _type: Type.Number,
+ _description: "How many encounters to show per page of a patient's encounter list.",
+ _default: 10,
+ _validators: [validators.inRange(1, 100)],
+ },
+ patientChartUrl: {
+ _type: Type.String,
+ _description:
+ 'The URL of the patient chart to link to from the audit trail. May contain the ' +
+ 'template variables ${openmrsSpaBase}, ${openmrsBase} and ${patientUuid}.',
+ _default: '${openmrsSpaBase}/patient/${patientUuid}/chart',
+ _validators: [validators.isUrlWithTemplateParameters(['patientUuid'])],
+ },
+};
+
+export interface Config {
+ patientSearchPageSize: number;
+ encountersPageSize: number;
+ patientChartUrl: string;
+}
diff --git a/packages/esm-audit-app/src/dashboard-link.component.tsx b/packages/esm-audit-app/src/dashboard-link.component.tsx
new file mode 100644
index 0000000..cd132c4
--- /dev/null
+++ b/packages/esm-audit-app/src/dashboard-link.component.tsx
@@ -0,0 +1,38 @@
+import React, { useMemo } from 'react';
+import { BrowserRouter, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { ConfigurableLink } from '@openmrs/esm-framework';
+import { dashboardMeta } from './dashboard.meta';
+
+function AuditLink() {
+ const { t } = useTranslation();
+ const { pathname } = useLocation();
+ const isActive = useMemo(
+ () =>
+ pathname
+ .split('/')
+ .map((segment) => decodeURIComponent(segment))
+ .includes(dashboardMeta.name),
+ [pathname],
+ );
+
+ return (
+
+ {t('audit', dashboardMeta.title)}
+
+ );
+}
+
+/**
+ * Extensions are mounted as their own React root, so they do not inherit the
+ * home app's router context and need their own to read the current location.
+ */
+export default function DashboardLink() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/esm-audit-app/src/dashboard.meta.ts b/packages/esm-audit-app/src/dashboard.meta.ts
new file mode 100644
index 0000000..8c00e16
--- /dev/null
+++ b/packages/esm-audit-app/src/dashboard.meta.ts
@@ -0,0 +1,11 @@
+/**
+ * The home app renders one dashboard per extension assigned to the
+ * `homepage-dashboard-slot`, keyed off the `name` in the extension's meta.
+ * That name is also the last segment of the dashboard's URL, so this dashboard
+ * lives at `/openmrs/spa/home/audit`.
+ */
+export const dashboardMeta = {
+ name: 'audit',
+ slot: 'audit-dashboard-slot',
+ title: 'Audit',
+};
diff --git a/packages/esm-audit-app/src/declarations.d.tsx b/packages/esm-audit-app/src/declarations.d.tsx
new file mode 100644
index 0000000..450e9d9
--- /dev/null
+++ b/packages/esm-audit-app/src/declarations.d.tsx
@@ -0,0 +1,3 @@
+declare module '@carbon/react';
+declare module '*.css';
+declare module '*.scss';
diff --git a/packages/esm-audit-app/src/index.ts b/packages/esm-audit-app/src/index.ts
new file mode 100644
index 0000000..7efeffe
--- /dev/null
+++ b/packages/esm-audit-app/src/index.ts
@@ -0,0 +1,33 @@
+/**
+ * This is the entrypoint file of the application. It communicates the
+ * important features of this microfrontend to the app shell. It
+ * connects the app shell to the React application(s) that make up this
+ * microfrontend.
+ */
+
+import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle, registerBreadcrumbs } from '@openmrs/esm-framework';
+import { configSchema } from './config-schema';
+import { dashboardMeta } from './dashboard.meta';
+import DashboardLink from './dashboard-link.component';
+
+const moduleName = '@pih/esm-audit-app';
+const options = {
+ featureName: 'audit',
+ moduleName,
+};
+
+export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
+
+export const root = getAsyncLifecycle(() => import('./root.component'), options);
+
+export const auditDashboardLink = getSyncLifecycle(DashboardLink, options);
+
+export function startupApp() {
+ defineConfigSchema(moduleName, configSchema);
+ registerBreadcrumbs([
+ {
+ path: `${window.spaBase}/home/${dashboardMeta.name}`,
+ title: dashboardMeta.title,
+ },
+ ]);
+}
diff --git a/packages/esm-audit-app/src/root.component.test.tsx b/packages/esm-audit-app/src/root.component.test.tsx
new file mode 100644
index 0000000..8f5f216
--- /dev/null
+++ b/packages/esm-audit-app/src/root.component.test.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { SWRConfig } from 'swr';
+import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
+import Root from './root.component';
+
+const mockOpenmrsFetch = jest.mocked(openmrsFetch);
+const mockUseConfig = jest.mocked(useConfig);
+
+function renderRoot(url = '/') {
+ window.history.pushState({}, '', url);
+ render(
+ new Map() }}>
+
+ ,
+ );
+}
+
+describe(' ', () => {
+ beforeEach(() => {
+ mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' });
+ mockOpenmrsFetch.mockImplementation((url: string) => {
+ if (url.includes('/patient/patient-1')) {
+ return Promise.resolve({
+ data: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient', person: { display: 'Dave TestPatient' } },
+ }) as ReturnType;
+ }
+ if (url.includes('/encounter/enc-1')) {
+ return Promise.resolve({
+ data: { uuid: 'enc-1', encounterType: { uuid: 'type-1', display: 'Oncology Consultation' } },
+ }) as ReturnType;
+ }
+ return Promise.resolve({ data: { results: [] } }) as ReturnType;
+ });
+ });
+
+ it('starts at the patient search', () => {
+ renderRoot();
+
+ expect(screen.getByText('Audit trail')).toBeInTheDocument();
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument();
+ });
+
+ it("shows a patient's encounters when the url names a patient", async () => {
+ renderRoot('/?patient=patient-1');
+
+ expect(await screen.findByText('Dave TestPatient')).toBeInTheDocument();
+ expect(screen.getByRole('checkbox', { name: /include deleted encounters/i })).toBeInTheDocument();
+ });
+
+ it('shows the encounter audit trail when the url names an encounter', async () => {
+ renderRoot('/?patient=patient-1&encounter=enc-1');
+
+ expect(await screen.findByText('Encounter summary')).toBeInTheDocument();
+ expect(screen.getByText('Observations')).toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-audit-app/src/root.component.tsx b/packages/esm-audit-app/src/root.component.tsx
new file mode 100644
index 0000000..f8fedf0
--- /dev/null
+++ b/packages/esm-audit-app/src/root.component.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { BrowserRouter } from 'react-router-dom';
+import Audit from './audit/audit.component';
+
+/**
+ * Extensions are mounted as their own React root, so this dashboard does not inherit the home
+ * app's router context and needs its own in order to keep the audit trail's position — the
+ * selected patient and encounter — in the URL's query string.
+ */
+export default function Root() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/esm-audit-app/src/routes.json b/packages/esm-audit-app/src/routes.json
new file mode 100644
index 0000000..956d73f
--- /dev/null
+++ b/packages/esm-audit-app/src/routes.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json.openmrs.org/routes.schema.json",
+ "extensions": [
+ {
+ "name": "audit-dashboard-link",
+ "slot": "homepage-dashboard-slot",
+ "component": "auditDashboardLink",
+ "meta": {
+ "name": "audit",
+ "slot": "audit-dashboard-slot",
+ "title": "Audit"
+ }
+ },
+ {
+ "name": "audit-dashboard",
+ "slot": "audit-dashboard-slot",
+ "component": "root"
+ }
+ ]
+}
diff --git a/packages/esm-audit-app/src/types.ts b/packages/esm-audit-app/src/types.ts
new file mode 100644
index 0000000..ee27924
--- /dev/null
+++ b/packages/esm-audit-app/src/types.ts
@@ -0,0 +1,122 @@
+/**
+ * Types for the encounter audit trail. These mirror the shapes returned by the
+ * OpenMRS REST API for the custom representations requested in
+ * `audit/audit.resource.ts` — keep the two in sync.
+ */
+
+/** The `{uuid, display}` shape the REST API uses for a reference to another resource. */
+export interface OpenmrsResourceRef {
+ uuid: string;
+ display?: string;
+}
+
+/**
+ * The REST API's `auditInfo` property. `changedBy`/`dateChanged` are only set once a
+ * row has been edited in place, and the `voided*` fields are only present on voided rows.
+ */
+export interface AuditInfo {
+ creator?: OpenmrsResourceRef;
+ dateCreated?: string;
+ changedBy?: OpenmrsResourceRef;
+ dateChanged?: string;
+ voidedBy?: OpenmrsResourceRef;
+ dateVoided?: string;
+ voidReason?: string;
+}
+
+export interface PagedResponse {
+ results: Array;
+ links?: Array<{ rel: 'prev' | 'next'; uri: string }>;
+ totalCount?: number;
+}
+
+export interface PatientIdentifier {
+ identifier: string;
+ preferred?: boolean;
+ identifierType?: OpenmrsResourceRef;
+}
+
+export interface AuditPatient {
+ uuid: string;
+ display: string;
+ identifiers?: Array;
+ person?: {
+ display?: string;
+ gender?: string;
+ age?: number;
+ birthdate?: string;
+ dead?: boolean;
+ };
+}
+
+export interface EncounterProvider {
+ uuid: string;
+ voided?: boolean;
+ provider?: OpenmrsResourceRef & { identifier?: string };
+ encounterRole?: OpenmrsResourceRef;
+}
+
+export interface AuditEncounter {
+ uuid: string;
+ display?: string;
+ encounterDatetime?: string;
+ voided?: boolean;
+ patient?: AuditPatient;
+ location?: OpenmrsResourceRef;
+ form?: OpenmrsResourceRef & { version?: string };
+ encounterType?: OpenmrsResourceRef;
+ visit?: OpenmrsResourceRef & { startDatetime?: string; visitType?: OpenmrsResourceRef };
+ encounterProviders?: Array;
+ auditInfo?: AuditInfo;
+}
+
+export interface ConceptDescription {
+ description?: string;
+ locale?: string;
+}
+
+/**
+ * An obs value is a plain string or number for free-text, numeric and datetime obs, and a
+ * reference for coded obs (and for the drug/location values OpenMRS stores as coded-ish obs).
+ */
+export type ObsValue = string | number | boolean | OpenmrsResourceRef | null;
+
+export interface AuditObs {
+ uuid: string;
+ display?: string;
+ obsDatetime?: string;
+ voided?: boolean;
+ value?: ObsValue;
+ comment?: string;
+ formFieldPath?: string;
+ concept?: OpenmrsResourceRef & { descriptions?: Array };
+ /** Set when this obs is a member of an obs group. */
+ obsGroup?: OpenmrsResourceRef;
+ /** Set when this obs replaced an earlier one, i.e. when the value was edited. */
+ previousVersion?: OpenmrsResourceRef;
+ auditInfo?: AuditInfo;
+}
+
+/**
+ * How an obs came to look the way it does, relative to the encounter it belongs to. Derived
+ * rather than stored — see `getObsChangeStatus`.
+ */
+export type ObsChangeStatus =
+ /** Entered when the encounter was first created and never touched since. */
+ | 'unchanged'
+ /** Replaces an earlier obs: someone changed the value after the fact. */
+ | 'edited'
+ /** Added to the encounter after the encounter itself was created. */
+ | 'addedAfterEncounter'
+ /** Voided outright — the value was removed from the encounter. */
+ | 'deleted'
+ /** Voided because a newer obs replaced it, so it holds a value that used to be current. */
+ | 'supersededValue';
+
+/** An obs plus the group nesting and change status the audit table renders. */
+export interface ObsTreeNode {
+ obs: AuditObs;
+ status: ObsChangeStatus;
+ depth: number;
+ children: Array;
+}
diff --git a/packages/esm-audit-app/translations/en.json b/packages/esm-audit-app/translations/en.json
new file mode 100644
index 0000000..0ba0b02
--- /dev/null
+++ b/packages/esm-audit-app/translations/en.json
@@ -0,0 +1,89 @@
+{
+ "action": "Action",
+ "activityLog": "Activity log",
+ "addedLater": "Added later",
+ "addedLaterLegend": "recorded after the encounter itself was created",
+ "age": "Age",
+ "allEncounterTypes": "All encounter types",
+ "audit": "Audit",
+ "auditTrail": "Audit trail",
+ "auditViews": "Audit views",
+ "backToEncounters": "Back to encounters",
+ "backToPatientSearch": "Back to patient search",
+ "birthdate": "Date of birth",
+ "cannotIncludeDeletedEncounters": "Deleted encounters can only be listed for a patient who has an identifier.",
+ "change": "Change",
+ "changedBy": "Changed by",
+ "changedByUser": "Changed by {{user}}",
+ "clearFilters": "Clear filters",
+ "createdBy": "Created by",
+ "deleted": "Deleted",
+ "deletedBy": "Deleted by",
+ "deletedEncountersUnavailable": "Deleted encounters unavailable",
+ "deletedLegend": "removed from the encounter",
+ "deletionReason": "Reason for deletion",
+ "detail": "Detail",
+ "edited": "Edited",
+ "editedLegend": "the value was changed after it was first recorded",
+ "encounter": "Encounter",
+ "encounterAudit": "Encounter audit",
+ "encounterChangedAction": "Changed encounter",
+ "encounterCreatedAction": "Created encounter",
+ "encounterDate": "Encounter date",
+ "encounterDateRange": "Encounter date range",
+ "encounterDeleted": "Deleted encounter",
+ "encounterDeletedAction": "Deleted encounter",
+ "encounterDeletedSubtitle": "This encounter has been deleted. Its observations are shown as they were left.",
+ "encounterNotFound": "That encounter could not be found.",
+ "encounters": "Encounters",
+ "encountersCreated": "Encounters created",
+ "encounterSummary": "Encounter summary",
+ "encounterType": "Encounter type",
+ "firstActivity": "First activity",
+ "form": "Form",
+ "gender": "Gender",
+ "identifier": "Identifier",
+ "includeDeletedEncounters": "Include deleted encounters",
+ "lastActivity": "Last activity",
+ "location": "Location",
+ "noActivity": "There is no recorded activity to show.",
+ "noEncountersFound": "This patient has no encounters to audit.",
+ "noEncountersMatchFilters": "No encounters match these filters.",
+ "noObservations": "This encounter has no observations.",
+ "noPatientsFound": "No patients match \"{{searchTerm}}\".",
+ "noProviders": "No providers are recorded for this encounter.",
+ "obsDeletedAction": "Deleted observation",
+ "obsDeletedCount": "Observations deleted",
+ "obsEditedAction": "Edited observation",
+ "obsEditedCount": "Observations edited",
+ "observations": "Observations",
+ "obsRecordedAction": "Recorded observation",
+ "obsRecordedCount": "Observations recorded",
+ "patient": "Patient",
+ "patientName": "Patient name",
+ "patientSearchResults": "Patient search results",
+ "previousValue": "Previous value",
+ "previousValueLegend": "the value that an edit replaced",
+ "provider": "Provider",
+ "providerName": "Provider name",
+ "providers": "Providers",
+ "questionConcept": "Question concept",
+ "readingObservations": "Reading observations from {{read}} of {{total}} encounters…",
+ "recordActivity": "Patient activity",
+ "recordedBy": "Recorded by",
+ "role": "Role",
+ "searchForPatient": "Search for a patient by name or identifier",
+ "searchToBegin": "Enter a patient name or identifier to begin auditing their records.",
+ "showConceptDescriptions": "Show concept descriptions",
+ "showDeletedObs": "Show deleted observations",
+ "showEveryone": "Show everyone",
+ "showOnlyThisUser": "Show only this user's activity",
+ "status": "Status",
+ "unknownUser": "Unknown user",
+ "user": "User",
+ "value": "Value",
+ "visit": "Visit",
+ "wasPreviously": "was {{value}}",
+ "when": "When",
+ "whoTouchedThisRecord": "Who modified this patient record"
+}
diff --git a/packages/esm-audit-app/translations/es.json b/packages/esm-audit-app/translations/es.json
new file mode 100644
index 0000000..8e76db8
--- /dev/null
+++ b/packages/esm-audit-app/translations/es.json
@@ -0,0 +1,89 @@
+{
+ "action": "Acción",
+ "activityLog": "Registro de actividad",
+ "addedLater": "Añadido posteriormente",
+ "addedLaterLegend": "registrado después de crearse el encuentro",
+ "age": "Edad",
+ "allEncounterTypes": "Todos los tipos de encuentro",
+ "audit": "Auditoría",
+ "auditTrail": "Pista de auditoría",
+ "auditViews": "Vistas de auditoría",
+ "backToEncounters": "Volver a los encuentros",
+ "backToPatientSearch": "Volver a la búsqueda de pacientes",
+ "birthdate": "Fecha de nacimiento",
+ "cannotIncludeDeletedEncounters": "Los encuentros eliminados solo pueden listarse para un paciente que tenga un identificador.",
+ "change": "Cambio",
+ "changedBy": "Modificado por",
+ "changedByUser": "Modificado por {{user}}",
+ "clearFilters": "Borrar los filtros",
+ "createdBy": "Creado por",
+ "deleted": "Eliminado",
+ "deletedBy": "Eliminado por",
+ "deletedEncountersUnavailable": "Encuentros eliminados no disponibles",
+ "deletedLegend": "retirado del encuentro",
+ "deletionReason": "Motivo de la eliminación",
+ "detail": "Detalle",
+ "edited": "Editado",
+ "editedLegend": "el valor se cambió después de registrarse por primera vez",
+ "encounter": "Encuentro",
+ "encounterAudit": "Auditoría del encuentro",
+ "encounterChangedAction": "Encuentro modificado",
+ "encounterCreatedAction": "Encuentro creado",
+ "encounterDate": "Fecha del encuentro",
+ "encounterDateRange": "Rango de fechas de los encuentros",
+ "encounterDeleted": "Encuentro eliminado",
+ "encounterDeletedAction": "Encuentro eliminado",
+ "encounterDeletedSubtitle": "Este encuentro ha sido eliminado. Sus observaciones se muestran tal como quedaron.",
+ "encounterNotFound": "No se ha encontrado ese encuentro.",
+ "encounters": "Encuentros",
+ "encountersCreated": "Encuentros creados",
+ "encounterSummary": "Resumen del encuentro",
+ "encounterType": "Tipo de encuentro",
+ "firstActivity": "Primera actividad",
+ "form": "Formulario",
+ "gender": "Género",
+ "identifier": "Identificador",
+ "includeDeletedEncounters": "Incluir encuentros eliminados",
+ "lastActivity": "Última actividad",
+ "location": "Localidad",
+ "noActivity": "No hay actividad registrada que mostrar.",
+ "noEncountersFound": "Este paciente no tiene encuentros que auditar.",
+ "noEncountersMatchFilters": "Ningún encuentro coincide con estos filtros.",
+ "noObservations": "Este encuentro no tiene observaciones.",
+ "noPatientsFound": "Ningún paciente coincide con «{{searchTerm}}».",
+ "noProviders": "No hay proveedores registrados para este encuentro.",
+ "obsDeletedAction": "Observación eliminada",
+ "obsDeletedCount": "Observaciones eliminadas",
+ "obsEditedAction": "Observación editada",
+ "obsEditedCount": "Observaciones editadas",
+ "observations": "Observaciones",
+ "obsRecordedAction": "Observación registrada",
+ "obsRecordedCount": "Observaciones registradas",
+ "patient": "Paciente",
+ "patientName": "Nombre del paciente",
+ "patientSearchResults": "Resultados de la búsqueda de pacientes",
+ "previousValue": "Valor anterior",
+ "previousValueLegend": "el valor sustituido por una edición",
+ "provider": "Proveedor",
+ "providerName": "Nombre del proveedor",
+ "providers": "Proveedores",
+ "questionConcept": "Concepto de la pregunta",
+ "readingObservations": "Leyendo las observaciones de {{read}} de {{total}} encuentros…",
+ "recordActivity": "Actividad del paciente",
+ "recordedBy": "Registrado por",
+ "role": "Rol",
+ "searchForPatient": "Buscar un paciente por nombre o identificador",
+ "searchToBegin": "Introduzca el nombre o el identificador de un paciente para empezar a auditar su historial.",
+ "showConceptDescriptions": "Mostrar las descripciones de los conceptos",
+ "showDeletedObs": "Mostrar las observaciones eliminadas",
+ "showEveryone": "Mostrar a todos",
+ "showOnlyThisUser": "Mostrar solo la actividad de este usuario",
+ "status": "Estado",
+ "unknownUser": "Usuario desconocido",
+ "user": "Usuario",
+ "value": "Valor",
+ "visit": "Visita",
+ "wasPreviously": "era {{value}}",
+ "when": "Cuándo",
+ "whoTouchedThisRecord": "Quién ha modificado este historial del paciente"
+}
diff --git a/packages/esm-audit-app/translations/fr.json b/packages/esm-audit-app/translations/fr.json
new file mode 100644
index 0000000..c5310fc
--- /dev/null
+++ b/packages/esm-audit-app/translations/fr.json
@@ -0,0 +1,89 @@
+{
+ "action": "Action",
+ "activityLog": "Journal d'activité",
+ "addedLater": "Ajouté ultérieurement",
+ "addedLaterLegend": "enregistré après la création de la rencontre",
+ "age": "Âge",
+ "allEncounterTypes": "Tous les types de rencontre",
+ "audit": "Audit",
+ "auditTrail": "Piste d'audit",
+ "auditViews": "Vues d'audit",
+ "backToEncounters": "Retour aux rencontres",
+ "backToPatientSearch": "Retour à la recherche de patient",
+ "birthdate": "Date de naissance",
+ "cannotIncludeDeletedEncounters": "Les rencontres supprimées ne peuvent être listées que pour un patient possédant un numéro d'identification.",
+ "change": "Modification",
+ "changedBy": "Modifié par",
+ "changedByUser": "Modifié par {{user}}",
+ "clearFilters": "Effacer les filtres",
+ "createdBy": "Créé par",
+ "deleted": "Supprimé",
+ "deletedBy": "Supprimé par",
+ "deletedEncountersUnavailable": "Rencontres supprimées indisponibles",
+ "deletedLegend": "retiré de la rencontre",
+ "deletionReason": "Motif de la suppression",
+ "detail": "Détail",
+ "edited": "Modifié",
+ "editedLegend": "la valeur a été modifiée après son premier enregistrement",
+ "encounter": "Rencontre",
+ "encounterAudit": "Audit de la rencontre",
+ "encounterChangedAction": "Rencontre modifiée",
+ "encounterCreatedAction": "Rencontre créée",
+ "encounterDate": "Date de rencontre",
+ "encounterDateRange": "Période des rencontres",
+ "encounterDeleted": "Rencontre supprimée",
+ "encounterDeletedAction": "Rencontre supprimée",
+ "encounterDeletedSubtitle": "Cette rencontre a été supprimée. Ses observations sont affichées telles qu'elles ont été laissées.",
+ "encounterNotFound": "Cette rencontre est introuvable.",
+ "encounters": "Rencontres",
+ "encountersCreated": "Rencontres créées",
+ "encounterSummary": "Résumé de la rencontre",
+ "encounterType": "Type de rencontre",
+ "firstActivity": "Première activité",
+ "form": "Fiche",
+ "gender": "Sexe",
+ "identifier": "Numéro d'identification",
+ "includeDeletedEncounters": "Inclure les rencontres supprimées",
+ "lastActivity": "Dernière activité",
+ "location": "Lieu",
+ "noActivity": "Aucune activité enregistrée à afficher.",
+ "noEncountersFound": "Ce patient n'a aucune rencontre à auditer.",
+ "noEncountersMatchFilters": "Aucune rencontre ne correspond à ces filtres.",
+ "noObservations": "Cette rencontre n'a aucune observation.",
+ "noPatientsFound": "Aucun patient ne correspond à « {{searchTerm}} ».",
+ "noProviders": "Aucun prestataire de service n'est enregistré pour cette rencontre.",
+ "obsDeletedAction": "Observation supprimée",
+ "obsDeletedCount": "Observations supprimées",
+ "obsEditedAction": "Observation modifiée",
+ "obsEditedCount": "Observations modifiées",
+ "observations": "Observations",
+ "obsRecordedAction": "Observation enregistrée",
+ "obsRecordedCount": "Observations enregistrées",
+ "patient": "Patient",
+ "patientName": "Nom du patient",
+ "patientSearchResults": "Résultats de la recherche de patients",
+ "previousValue": "Valeur précédente",
+ "previousValueLegend": "la valeur remplacée par une modification",
+ "provider": "Prestataire de service",
+ "providerName": "Nom du prestataire de service",
+ "providers": "Prestataires de service",
+ "questionConcept": "Concept de la question",
+ "readingObservations": "Lecture des observations de {{read}} rencontres sur {{total}}…",
+ "recordActivity": "Activité du patient",
+ "recordedBy": "Enregistré par",
+ "role": "Rôle",
+ "searchForPatient": "Rechercher un patient par nom ou numéro d'identification",
+ "searchToBegin": "Saisissez le nom ou le numéro d'identification d'un patient pour commencer l'audit de son dossier.",
+ "showConceptDescriptions": "Afficher les descriptions des concepts",
+ "showDeletedObs": "Afficher les observations supprimées",
+ "showEveryone": "Afficher tout le monde",
+ "showOnlyThisUser": "Afficher uniquement l'activité de cet utilisateur",
+ "status": "Statut",
+ "unknownUser": "Utilisateur inconnu",
+ "user": "Utilisateur",
+ "value": "Valeur",
+ "visit": "Visite",
+ "wasPreviously": "était {{value}}",
+ "when": "Quand",
+ "whoTouchedThisRecord": "Qui a modifié ce dossier patient"
+}
diff --git a/packages/esm-audit-app/tsconfig.json b/packages/esm-audit-app/tsconfig.json
new file mode 100644
index 0000000..54ce28c
--- /dev/null
+++ b/packages/esm-audit-app/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src/**/*"],
+ "exclude": ["src/**/*.test.tsx"]
+}
diff --git a/tools/setup-tests.ts b/tools/setup-tests.ts
index a49fca6..f49da13 100644
--- a/tools/setup-tests.ts
+++ b/tools/setup-tests.ts
@@ -12,10 +12,17 @@ window.spaBase = '/spa';
window.getOpenmrsSpaBase = () => '/openmrs/spa/';
window.HTMLElement.prototype.scrollIntoView = jest.fn();
-window.matchMedia = jest.fn().mockImplementation(() => {
+// Carbon components subscribe with addEventListener; the deprecated addListener pair is kept for
+// anything still using it.
+window.matchMedia = jest.fn().mockImplementation((query: string) => {
return {
matches: false,
+ media: query,
+ onchange: null,
addListener: () => {},
removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
};
});
diff --git a/yarn.lock b/yarn.lock
index d723338..3040006 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2958,6 +2958,85 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/bridge-react-webpack-plugin@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/bridge-react-webpack-plugin@npm:2.8.1"
+ dependencies:
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/67df65b8b9a0f011423b2bac0faab32c03b1607acf40a923315299e1ad663645475194a4fdcaa6f1d5718b64a0ea8849915d7d0aff65a6ae0182971ab9e2c9be
+ languageName: node
+ linkType: hard
+
+"@module-federation/cli@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/cli@npm:2.8.1"
+ dependencies:
+ "@module-federation/dts-plugin": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ commander: "npm:11.1.0"
+ jiti: "npm:2.4.2"
+ bin:
+ mf: bin/mf.js
+ checksum: 10/696437e41c240dbb045171842363779bf7dbd8a560005402d6958006ad1fd25934f3391dcc53a3c14469beaff1932cd16058f239f336d16549bf8ea8d53d3a33
+ languageName: node
+ linkType: hard
+
+"@module-federation/dts-plugin@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/dts-plugin@npm:2.8.1"
+ dependencies:
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/managers": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ "@module-federation/third-party-dts-extractor": "npm:2.8.1"
+ adm-zip: "npm:0.6.0"
+ isomorphic-ws: "npm:5.0.0"
+ undici: "npm:7.28.0"
+ ws: "npm:8.21.0"
+ peerDependencies:
+ typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ vue-tsc: ">=1.0.24"
+ peerDependenciesMeta:
+ vue-tsc:
+ optional: true
+ checksum: 10/51b87277e87725b9ec1e8aa41b810b19675e86eb8b8b3f8f4da068d644a18b808e0630aa67e867d1164884cd93c8e2f9314001c5b46223665fd496382bfdbd39
+ languageName: node
+ linkType: hard
+
+"@module-federation/enhanced@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/enhanced@npm:2.8.1"
+ dependencies:
+ "@module-federation/bridge-react-webpack-plugin": "npm:2.8.1"
+ "@module-federation/cli": "npm:2.8.1"
+ "@module-federation/dts-plugin": "npm:2.8.1"
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/inject-external-runtime-core-plugin": "npm:2.8.1"
+ "@module-federation/managers": "npm:2.8.1"
+ "@module-federation/manifest": "npm:2.8.1"
+ "@module-federation/rspack": "npm:2.8.1"
+ "@module-federation/runtime-tools": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ "@module-federation/webpack-bundler-runtime": "npm:2.8.1"
+ schema-utils: "npm:4.3.0"
+ tapable: "npm:2.3.0"
+ peerDependencies:
+ typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ vue-tsc: ">=1.0.24"
+ webpack: ^5.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ vue-tsc:
+ optional: true
+ webpack:
+ optional: true
+ bin:
+ mf: bin/mf.js
+ checksum: 10/d1f86641b321e5c309166b347c724748b1dcfaa6420a12ae66089d48579ac312dec4fc61d6e9da14d80c342f218983aff694a75fc869050816cb78ced3043511
+ languageName: node
+ linkType: hard
+
"@module-federation/error-codes@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/error-codes@npm:0.22.0"
@@ -2965,6 +3044,66 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/error-codes@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/error-codes@npm:2.8.1"
+ checksum: 10/3608349037c567477693b1cce501f0186f606937a49cc75a190b87953f74e6116bec18e0be576c6f759b9533c10a79de639a6635c21a1827876e915389b9bf17
+ languageName: node
+ linkType: hard
+
+"@module-federation/inject-external-runtime-core-plugin@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/inject-external-runtime-core-plugin@npm:2.8.1"
+ peerDependencies:
+ "@module-federation/runtime-tools": 2.8.1
+ checksum: 10/eaa3b38207074644fdce467df406a6a19be598468f4980c89281c9f2cd269809a4a7ec1b14ab044d9d5c051d6f1c9d98b77718f3a5002f09f7cab416a83bfcec
+ languageName: node
+ linkType: hard
+
+"@module-federation/managers@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/managers@npm:2.8.1"
+ dependencies:
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/3a6d00607c1b70c81c7e7d84574b670548594cd7df345db7077d80805e0a12a8ce1b54ddb2dc0285ac14da9b6a8b42279dfbbfaafcbbc02ca317f60d3c8bba28
+ languageName: node
+ linkType: hard
+
+"@module-federation/manifest@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/manifest@npm:2.8.1"
+ dependencies:
+ "@module-federation/dts-plugin": "npm:2.8.1"
+ "@module-federation/managers": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/4797f1c5b6f5ebb91d198b79d45910ebb211867bd993407b366d237cd0bd29426f2cf24b589bec66799629aa0c947722afe6dbe35030cd7c57f9d17e8278ec62
+ languageName: node
+ linkType: hard
+
+"@module-federation/rspack@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/rspack@npm:2.8.1"
+ dependencies:
+ "@module-federation/bridge-react-webpack-plugin": "npm:2.8.1"
+ "@module-federation/dts-plugin": "npm:2.8.1"
+ "@module-federation/inject-external-runtime-core-plugin": "npm:2.8.1"
+ "@module-federation/managers": "npm:2.8.1"
+ "@module-federation/manifest": "npm:2.8.1"
+ "@module-federation/runtime-tools": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ peerDependencies:
+ "@rspack/core": ^0.7.0 || ^1.0.0 || ^2.0.0-0
+ typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ vue-tsc: ">=1.0.24"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ vue-tsc:
+ optional: true
+ checksum: 10/17468d735d2813e080ce16a5e6c7407b0ec7592452d275a7aae9437799254895deccdf7f84f0cc7908eefaf0313962c43ed8d20110e8833590319072e5216a12
+ languageName: node
+ linkType: hard
+
"@module-federation/runtime-core@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime-core@npm:0.22.0"
@@ -2975,6 +3114,16 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/runtime-core@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/runtime-core@npm:2.8.1"
+ dependencies:
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/3da5cf2a2f89cd091d422e55b9e94bb7447de848cd8b0b29aa4fa9a217ec5439d6915058070ff3b0d33ab7d580411672556a549016404b0c54a1abd33965305d
+ languageName: node
+ linkType: hard
+
"@module-federation/runtime-tools@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime-tools@npm:0.22.0"
@@ -2985,6 +3134,16 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/runtime-tools@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/runtime-tools@npm:2.8.1"
+ dependencies:
+ "@module-federation/runtime": "npm:2.8.1"
+ "@module-federation/webpack-bundler-runtime": "npm:2.8.1"
+ checksum: 10/5e0cc4fa41098364fead905daab38b36b79611eb575fe747a1629728fe934523e78ee75421858bace1e56c0c917637f81aaf3b5f7ffcf24ebbf88b60b068cdda
+ languageName: node
+ linkType: hard
+
"@module-federation/runtime@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime@npm:0.22.0"
@@ -2996,6 +3155,17 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/runtime@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/runtime@npm:2.8.1"
+ dependencies:
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/runtime-core": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/801db9408871c65cb3c48add5615a0bc4eba413b765019f9d70994d3d8ec93e21b2800c666bf6675ae5775eb68e7796e6d4adf5cb7caae282253b9631e839471
+ languageName: node
+ linkType: hard
+
"@module-federation/sdk@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/sdk@npm:0.22.0"
@@ -3003,6 +3173,20 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/sdk@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/sdk@npm:2.8.1"
+ checksum: 10/181d3f80ea5aebf5d99224531e56e0434ff3fc69548c96f17dc2dadcaad9c2c3143fea023c8427f09e8a4fc9c928f8e406e8c8b6fb922883ec4a929d9e6a9146
+ languageName: node
+ linkType: hard
+
+"@module-federation/third-party-dts-extractor@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/third-party-dts-extractor@npm:2.8.1"
+ checksum: 10/387d524b42573d11fd93a5686a07977001e45f94c53e9bc4426da9195179752123a02526ca47d13ba1467db93c1ba98df55e80a9358ded22b5c4a25c321c53e5
+ languageName: node
+ linkType: hard
+
"@module-federation/webpack-bundler-runtime@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/webpack-bundler-runtime@npm:0.22.0"
@@ -3013,6 +3197,17 @@ __metadata:
languageName: node
linkType: hard
+"@module-federation/webpack-bundler-runtime@npm:2.8.1":
+ version: 2.8.1
+ resolution: "@module-federation/webpack-bundler-runtime@npm:2.8.1"
+ dependencies:
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/runtime": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ checksum: 10/33ffd6e412aba1854e016b6efcad67d5af7b4ccb61749fdd2b19d1b19c679808f16e17d892ae5a0e6c79277b6a72678c85782d79e612e68e09a7049aa0261069
+ languageName: node
+ linkType: hard
+
"@napi-rs/wasm-runtime@npm:1.0.7":
version: 1.0.7
resolution: "@napi-rs/wasm-runtime@npm:1.0.7"
@@ -3181,28 +3376,31 @@ __metadata:
languageName: node
linkType: hard
-"@openmrs/esm-api@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-api@npm:9.0.3-pre.4537"
+"@openmrs/esm-api@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-api@npm:10.0.1-pre.5263"
dependencies:
lodash-es: "npm:^4.17.21"
peerDependencies:
- "@openmrs/esm-config": 9.x
- "@openmrs/esm-error-handling": 9.x
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-navigation": 9.x
- checksum: 10/d84d36c273e02c9859a023e84bde34ecb6958717db05c790b3d87b9fc3cf6bc1c5cd6cca6bbc6f3c7781bb9944a5dd417bf1118537c4301f9ccc3413fcc75f35
+ "@openmrs/esm-config": ^10.0.1-pre.5263
+ "@openmrs/esm-error-handling": ^10.0.1-pre.5263
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-navigation": ^10.0.1-pre.5263
+ checksum: 10/c7c9b80e29f42a2ec91184b2324c852830d54573c5fd1e10f02fb96e98605ee0383a786fc3b599651ae5a50c0e955873fe09bf4032a0da5f104f503fd8da7327
languageName: node
linkType: hard
-"@openmrs/esm-app-shell@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-app-shell@npm:9.0.3-pre.4537"
+"@openmrs/esm-app-shell@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-app-shell@npm:10.0.1-pre.5263"
dependencies:
"@carbon/react": "npm:^1.92.1"
"@internationalized/date": "npm:^3.8.0"
- "@openmrs/esm-framework": "npm:9.0.3-pre.4537"
- "@openmrs/esm-styleguide": "npm:9.0.3-pre.4537"
+ "@module-federation/enhanced": "npm:2.8.1"
+ "@module-federation/error-codes": "npm:2.8.1"
+ "@module-federation/sdk": "npm:2.8.1"
+ "@openmrs/esm-framework": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-styleguide": "npm:10.0.1-pre.5263"
"@rspack/cli": "npm:1.7.9"
"@rspack/core": "npm:1.7.9"
dayjs: "npm:^1.11.13"
@@ -3222,70 +3420,70 @@ __metadata:
swc-loader: "npm:0.2.7"
swr: "npm:2.2.5"
webpack-pwa-manifest: "npm:4.3.0"
- checksum: 10/f93dd64dd401cff771559d56549bbd2e18a56804783e79c2a292ddd6534ae162b0c952b360a88fcd8ecd70e2f11051118626c7d86eda894782f9de67e72367b2
+ checksum: 10/e8cf048a0c3a9cc28a437ea4fb5652169f5797a0bb9236b182202eefad4bf61e463e0e6d9baf10a4927ed2c973b8123076acb0d1c97a474ebbb16eb38250a0fb
languageName: node
linkType: hard
-"@openmrs/esm-config@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-config@npm:9.0.3-pre.4537"
+"@openmrs/esm-config@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-config@npm:10.0.1-pre.5263"
dependencies:
lodash-es: "npm:^4.17.21"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-state": 9.x
- "@openmrs/esm-utils": 9.x
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
single-spa: 6.x
- checksum: 10/eedc931b31681166c10dc6c7e302bc68479e5cbfd6e0e7e4a996c160b15105e35f085313a6cd25907710a67a2b4d667af7fb211ed52184e05b4287fa2da72c27
+ checksum: 10/374263344c587dede41e2feeea77e1e4e569242a0f48424a772b747a9a88e5d50413058f6ec822643ed201a4e5d39db4357137f3a46568c08c217c45f7bdaf9e
languageName: node
linkType: hard
-"@openmrs/esm-context@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-context@npm:9.0.3-pre.4537"
+"@openmrs/esm-context@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-context@npm:10.0.1-pre.5263"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-state": 9.x
- checksum: 10/2a25282a4696ccc4d688126031cc444d4e5b9957214e903f2ea63188158effa5a75420334f7b215b13d518914ce54d4d28e0351d7d6b18f022f75f4a5d6c14f3
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ checksum: 10/d616ccdf9db643f2f06cbbf4c0e1b0ba61f0b2237619a8e3accc660c6bf7c0fcc2d94d134aebe98e5da62101d2c26109ac06ff7d4e4dfc1e114a23f6f396ae96
languageName: node
linkType: hard
-"@openmrs/esm-dynamic-loading@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-dynamic-loading@npm:9.0.3-pre.4537"
+"@openmrs/esm-dynamic-loading@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-dynamic-loading@npm:10.0.1-pre.5263"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-translations": 9.x
- checksum: 10/d39d7bff6ed4cc7c3def2a5d7dc43f8d4b3b8b5d2d118811398868de583c37652f82ac404f5c76543248da9754bdacfab5dec60a55d24443e1a8bfa6454f3ed1
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-translations": ^10.0.1-pre.5263
+ checksum: 10/f971781033a9b636e1f498c9fffe18027ba28270db1d8c53d4f8ecd85c7eaedbeb68af333011a84da298996ed40029a6e676c54b5da5cc72f470f37fbd69de1d
languageName: node
linkType: hard
-"@openmrs/esm-emr-api@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-emr-api@npm:9.0.3-pre.4537"
+"@openmrs/esm-emr-api@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-emr-api@npm:10.0.1-pre.5263"
dependencies:
"@types/fhir": "npm:0.0.31"
lodash-es: "npm:^4.17.21"
peerDependencies:
- "@openmrs/esm-api": 9.x
- "@openmrs/esm-offline": 9.x
- "@openmrs/esm-state": 9.x
- checksum: 10/4d8c5583f4d03a6bd9ed0e9a24e5bb2368a5ccd8444cd7c33eafee10675f15cfa936f8ca23385d117017718fd5045b837a44390c80884b9e0548d27a1c978f5b
+ "@openmrs/esm-api": ^10.0.1-pre.5263
+ "@openmrs/esm-offline": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ checksum: 10/fd371b2cffa9a9e692944966796775a4cff876e2511196c505911b8ec0ef8e40c468da5df10986d5382e1dbb561ca6f928614419db7b90f5407dfb577c611ac5
languageName: node
linkType: hard
-"@openmrs/esm-error-handling@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-error-handling@npm:9.0.3-pre.4537"
+"@openmrs/esm-error-handling@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-error-handling@npm:10.0.1-pre.5263"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- checksum: 10/efb2119b575e2ab43992672b8ca53989468991564c09fd026b2866b8a4b36a4d0eb3072a6361e2ed6cb787da37a8f2e47c4043af1897688d83fb868faf580bf3
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ checksum: 10/c69c7f4e74c2bb7254bac649593b459b35ef1495a6f79b05a84ef85ed7b163846e941dae42f0af1b9c9c693942370f0ce1fbd82bc7810b66f9607e0c8a772dda
languageName: node
linkType: hard
-"@openmrs/esm-expression-evaluator@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-expression-evaluator@npm:9.0.3-pre.4537"
+"@openmrs/esm-expression-evaluator@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-expression-evaluator@npm:10.0.1-pre.5263"
dependencies:
"@jsep-plugin/arrow": "npm:^1.0.6"
"@jsep-plugin/new": "npm:^1.0.4"
@@ -3294,60 +3492,60 @@ __metadata:
"@jsep-plugin/template": "npm:^1.0.5"
"@jsep-plugin/ternary": "npm:^1.1.4"
jsep: "npm:^1.4.0"
- checksum: 10/226b5e7d1206df5dda6fd5b220972a5683f2289f9fe0589b2763195c37e253c60e9913785c5bc89e78a6ad2c4d14a8acc14619bb3b833698281a200664091929
+ checksum: 10/0a1ed07461120b6ff92eb61ae4a9df1f4c756c4a2b826c690902327da932d2d28c99ba98a4764c5627d9fb6d9db0525876a1b6fee70b14f9ac9e9ed479c380e3
languageName: node
linkType: hard
-"@openmrs/esm-extensions@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-extensions@npm:9.0.3-pre.4537"
+"@openmrs/esm-extensions@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-extensions@npm:10.0.1-pre.5263"
dependencies:
lodash-es: "npm:^4.17.21"
peerDependencies:
- "@openmrs/esm-api": 9.x
- "@openmrs/esm-config": 9.x
- "@openmrs/esm-expression-evaluator": 9.x
- "@openmrs/esm-feature-flags": 9.x
- "@openmrs/esm-state": 9.x
- "@openmrs/esm-utils": 9.x
+ "@openmrs/esm-api": ^10.0.1-pre.5263
+ "@openmrs/esm-config": ^10.0.1-pre.5263
+ "@openmrs/esm-expression-evaluator": ^10.0.1-pre.5263
+ "@openmrs/esm-feature-flags": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
single-spa: 6.x
- checksum: 10/42a3e036a88aff85b12a28505fd14492b258be0d07064ed402ad1110a14968128f1756800348912e1c959e690a3d56236afbd43eb3c3041ca9a6aea0ef608a72
+ checksum: 10/b511d73ae6aa74e17369b5bc76be848c587a6661987b67f14911db5f4d4e4585e2a384e54212397940941e0f72a0d7cfa75cdc2cd0f4288c09efa59dfd1550f5
languageName: node
linkType: hard
-"@openmrs/esm-feature-flags@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-feature-flags@npm:9.0.3-pre.4537"
+"@openmrs/esm-feature-flags@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-feature-flags@npm:10.0.1-pre.5263"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-state": 9.x
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
single-spa: 6.x
- checksum: 10/4926a621d49439a272e95b6a65638cb0ac507ddd27a4c5d20d8916b7767292c73ac55ded404899df1c8f487871977b4b935453aed56969614acf68a53d2b69ba
- languageName: node
- linkType: hard
-
-"@openmrs/esm-framework@npm:9.0.3-pre.4537, @openmrs/esm-framework@npm:next":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-framework@npm:9.0.3-pre.4537"
- dependencies:
- "@openmrs/esm-api": "npm:9.0.3-pre.4537"
- "@openmrs/esm-config": "npm:9.0.3-pre.4537"
- "@openmrs/esm-context": "npm:9.0.3-pre.4537"
- "@openmrs/esm-dynamic-loading": "npm:9.0.3-pre.4537"
- "@openmrs/esm-emr-api": "npm:9.0.3-pre.4537"
- "@openmrs/esm-error-handling": "npm:9.0.3-pre.4537"
- "@openmrs/esm-expression-evaluator": "npm:9.0.3-pre.4537"
- "@openmrs/esm-extensions": "npm:9.0.3-pre.4537"
- "@openmrs/esm-feature-flags": "npm:9.0.3-pre.4537"
- "@openmrs/esm-globals": "npm:9.0.3-pre.4537"
- "@openmrs/esm-navigation": "npm:9.0.3-pre.4537"
- "@openmrs/esm-offline": "npm:9.0.3-pre.4537"
- "@openmrs/esm-react-utils": "npm:9.0.3-pre.4537"
- "@openmrs/esm-routes": "npm:9.0.3-pre.4537"
- "@openmrs/esm-state": "npm:9.0.3-pre.4537"
- "@openmrs/esm-styleguide": "npm:9.0.3-pre.4537"
- "@openmrs/esm-translations": "npm:9.0.3-pre.4537"
- "@openmrs/esm-utils": "npm:9.0.3-pre.4537"
+ checksum: 10/1c98a4c2e0651cd7c93ac955925455c6c809bc045d79172592ca89ff94c28462e1ed6768b6c3c28740a38e35c0de54e5f268388550e70f3df2e827b6cc906bf8
+ languageName: node
+ linkType: hard
+
+"@openmrs/esm-framework@npm:10.0.1-pre.5263, @openmrs/esm-framework@npm:next":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-framework@npm:10.0.1-pre.5263"
+ dependencies:
+ "@openmrs/esm-api": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-config": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-context": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-dynamic-loading": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-emr-api": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-error-handling": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-expression-evaluator": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-extensions": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-feature-flags": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-globals": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-navigation": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-offline": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-react-utils": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-routes": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-state": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-styleguide": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-translations": "npm:10.0.1-pre.5263"
+ "@openmrs/esm-utils": "npm:10.0.1-pre.5263"
peerDependencies:
dayjs: 1.x
i18next: 25.x
@@ -3357,67 +3555,67 @@ __metadata:
rxjs: 6.x
single-spa: 6.x
swr: 2.x
- checksum: 10/79903a3f0a3b0df8af6fc07faa7f182894c65a2e5e566b894d74a34aa4b60c6e121eea303bf5190fb03b2b422a0cbe4c84a9f08b908392b51f35e33f1ab47eb2
+ checksum: 10/5e5cfcc0ff6736749a423de0e342b070d44352f51b3ede595b9b18e84f5e05ce2c878a1e11aff6796fab87655efccccc53e4c193a3ed5093598ecf084070a8af
languageName: node
linkType: hard
-"@openmrs/esm-globals@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-globals@npm:9.0.3-pre.4537"
+"@openmrs/esm-globals@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-globals@npm:10.0.1-pre.5263"
dependencies:
"@types/fhir": "npm:0.0.31"
peerDependencies:
single-spa: 6.x
- checksum: 10/9f70a194a2c7943e1848e5d2e62e72d353f54f78b3bb6971f07ff12f677ae989c24c6f853a75151b172f1626d483a24e87adc76b51d6278141125833d8c47e91
+ checksum: 10/088fef4af28e88a783645ba2eb15525039b4dd13b4d77d014440707036659fd2884354d51177db70c39c3f6dc30129dca5478d0aace1d9ada688c122a17f873b
languageName: node
linkType: hard
-"@openmrs/esm-navigation@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-navigation@npm:9.0.3-pre.4537"
+"@openmrs/esm-navigation@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-navigation@npm:10.0.1-pre.5263"
dependencies:
path-to-regexp: "npm:^8.3.0"
peerDependencies:
- "@openmrs/esm-state": 9.x
- checksum: 10/f0ca576ff0be1c8474cdff18eeac5d74aaa7dcfe3f91e97a279d6ea7cf7c303534fd6301e25ccde42a68dd822839feab8acb137b2970d07bfd40d58acedcbaf1
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ checksum: 10/24b86caa0e0edc614330dcaf51bad9acac9f532d34733879a8fdf36c5d45fcd15fb5dbd4cf22f285d7c6e2926d01bba2463c2024c33487ebb78105ce8c82ad8d
languageName: node
linkType: hard
-"@openmrs/esm-offline@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-offline@npm:9.0.3-pre.4537"
+"@openmrs/esm-offline@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-offline@npm:10.0.1-pre.5263"
dependencies:
dexie: "npm:^3.0.3"
lodash-es: "npm:^4.17.21"
uuid: "npm:^9.0.1"
workbox-window: "npm:^6.1.5"
peerDependencies:
- "@openmrs/esm-api": 9.x
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-state": 9.x
+ "@openmrs/esm-api": ^10.0.1-pre.5263
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
rxjs: 6.x
- checksum: 10/601b9db0bcc69ba9ffa55647d07ad7e1eeeb946118a6b75bdbcd8d7bbd0cf0937d8d92f2c0a4ee527d0fd8c3ef0fd102150b8a62656eb46f742819310c3b05ad
+ checksum: 10/28369e7d3387fc7ba537ee5273b7032e19e234804d69f95d94526abccaf022179881aa51f09daa0b71625e1287592ea4685697a041cd0f828415f03c4aa92467
languageName: node
linkType: hard
-"@openmrs/esm-react-utils@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-react-utils@npm:9.0.3-pre.4537"
+"@openmrs/esm-react-utils@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-react-utils@npm:10.0.1-pre.5263"
dependencies:
lodash-es: "npm:^4.17.21"
single-spa-react: "npm:^6.0.2"
peerDependencies:
- "@openmrs/esm-api": 9.x
- "@openmrs/esm-config": 9.x
- "@openmrs/esm-context": 9.x
- "@openmrs/esm-emr-api": 9.x
- "@openmrs/esm-error-handling": 9.x
- "@openmrs/esm-extensions": 9.x
- "@openmrs/esm-feature-flags": 9.x
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-navigation": 9.x
- "@openmrs/esm-state": 9.x
- "@openmrs/esm-utils": 9.x
+ "@openmrs/esm-api": ^10.0.1-pre.5263
+ "@openmrs/esm-config": ^10.0.1-pre.5263
+ "@openmrs/esm-context": ^10.0.1-pre.5263
+ "@openmrs/esm-emr-api": ^10.0.1-pre.5263
+ "@openmrs/esm-error-handling": ^10.0.1-pre.5263
+ "@openmrs/esm-extensions": ^10.0.1-pre.5263
+ "@openmrs/esm-feature-flags": ^10.0.1-pre.5263
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-navigation": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
dayjs: 1.x
i18next: 25.x
react: 18.x
@@ -3425,40 +3623,40 @@ __metadata:
react-i18next: 16.x
rxjs: 6.x
swr: 2.x
- checksum: 10/68bdfa11970d63d207ee128b6ca86722c8c662406f8d062cbd1113b26adfff191caad8992a8435b8aa9dccf3d6338014b213cc23423755d5500659b42474cbc6
+ checksum: 10/f8abb61d771b2f6cc1c769ed6b0f12dda45b398549c04c1aa60b1cd4e91de86f47bc709b36ee8b6b2495669b5e6239476463cf5d3e9526aa056176db641882d6
languageName: node
linkType: hard
-"@openmrs/esm-routes@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-routes@npm:9.0.3-pre.4537"
+"@openmrs/esm-routes@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-routes@npm:10.0.1-pre.5263"
peerDependencies:
- "@openmrs/esm-config": 9.x
- "@openmrs/esm-dynamic-loading": 9.x
- "@openmrs/esm-extensions": 9.x
- "@openmrs/esm-feature-flags": 9.x
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-utils": 9.x
+ "@openmrs/esm-config": ^10.0.1-pre.5263
+ "@openmrs/esm-dynamic-loading": ^10.0.1-pre.5263
+ "@openmrs/esm-extensions": ^10.0.1-pre.5263
+ "@openmrs/esm-feature-flags": ^10.0.1-pre.5263
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
single-spa: 6.x
- checksum: 10/8184f80c120e45a45fc8a8ae5949d112e3f7a184004aa85362134e9560ecaa6c00a377e4749d0ac60fd9109bcc77432fe7d41156e202601ec46ab92b7f57b657
+ checksum: 10/94506908d3f842433218f7cf8d937f88b963232de8ece575f59efaa6e5884081b305f45d1028c2a4de44d719287c0cf028650dd99a0bce6b2a6524ab8b8f9989
languageName: node
linkType: hard
-"@openmrs/esm-state@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-state@npm:9.0.3-pre.4537"
+"@openmrs/esm-state@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-state@npm:10.0.1-pre.5263"
dependencies:
zustand: "npm:^4.5.5"
peerDependencies:
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-utils": 9.x
- checksum: 10/a375af05990e86786c96022c19bf023585093d2302156c09aeff9f4c5f175fe8db9ccd3501851480fa19910cec5147ba53a9466de63c31166f75e3be5b0aa20b
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
+ checksum: 10/46ff12464dc5afa1eb1a7b80a123e3fd434d25c7039d151768679a14fe4a8cc850d81ab31cc4a820f6b543171cb3ad0a0b632ee10fd2dc1a8c921219148ee5a8
languageName: node
linkType: hard
-"@openmrs/esm-styleguide@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-styleguide@npm:9.0.3-pre.4537"
+"@openmrs/esm-styleguide@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-styleguide@npm:10.0.1-pre.5263"
dependencies:
"@carbon/charts": "npm:^1.27.0"
"@carbon/react": "npm:^1.92.1"
@@ -3469,18 +3667,18 @@ __metadata:
lodash-es: "npm:^4.17.21"
react-aria-components: "npm:^1.7.1"
peerDependencies:
- "@openmrs/esm-api": 9.x
- "@openmrs/esm-config": 9.x
- "@openmrs/esm-emr-api": 9.x
- "@openmrs/esm-error-handling": 9.x
- "@openmrs/esm-extensions": 9.x
- "@openmrs/esm-globals": 9.x
- "@openmrs/esm-navigation": 9.x
- "@openmrs/esm-react-utils": 9.x
- "@openmrs/esm-routes": 9.x
- "@openmrs/esm-state": 9.x
- "@openmrs/esm-translations": 9.x
- "@openmrs/esm-utils": 9.x
+ "@openmrs/esm-api": ^10.0.1-pre.5263
+ "@openmrs/esm-config": ^10.0.1-pre.5263
+ "@openmrs/esm-emr-api": ^10.0.1-pre.5263
+ "@openmrs/esm-error-handling": ^10.0.1-pre.5263
+ "@openmrs/esm-extensions": ^10.0.1-pre.5263
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
+ "@openmrs/esm-navigation": ^10.0.1-pre.5263
+ "@openmrs/esm-react-utils": ^10.0.1-pre.5263
+ "@openmrs/esm-routes": ^10.0.1-pre.5263
+ "@openmrs/esm-state": ^10.0.1-pre.5263
+ "@openmrs/esm-translations": ^10.0.1-pre.5263
+ "@openmrs/esm-utils": ^10.0.1-pre.5263
dayjs: 1.x
i18next: 25.x
react: 18.x
@@ -3488,24 +3686,24 @@ __metadata:
react-i18next: 16.x
rxjs: 6.x
swr: 2.x
- checksum: 10/74414fc8261c4712144d34d73473b619f77571c1788de0333727e150a5df6fbc368ae14fb518b2460ce00e250a5bb5d3005b297f919c6f6b17e1e377a2e19719
+ checksum: 10/a7cb071064b0af63aa0f3953665fed1b5aac933cbb2390c1c0ede19548ae268fa985ee983628eafb482137ab3a91e43dee68f86468e725989a0619eee3b59cd3
languageName: node
linkType: hard
-"@openmrs/esm-translations@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-translations@npm:9.0.3-pre.4537"
+"@openmrs/esm-translations@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-translations@npm:10.0.1-pre.5263"
dependencies:
i18next: "npm:^25.5.3"
peerDependencies:
i18next: 25.x
- checksum: 10/5c656b25541be1a609b4ce88e411b3f87dbd79047ab8911918c3d7531c662d2f5f0b99f0ed490dacbc0543a5fd74b72a2f6018872ba2fff5e45b0537cbda819c
+ checksum: 10/7b9a912bee54e2d186a14a0504afc27c58bf79f51356e377a9e3318e7ea0d9674b7ae4b0c13083693b1c225838cce81f04e69a40070b9413191b483771aca2f8
languageName: node
linkType: hard
-"@openmrs/esm-utils@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/esm-utils@npm:9.0.3-pre.4537"
+"@openmrs/esm-utils@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/esm-utils@npm:10.0.1-pre.5263"
dependencies:
"@formatjs/intl-durationformat": "npm:^0.7.3"
"@internationalized/date": "npm:^3.8.0"
@@ -3513,18 +3711,19 @@ __metadata:
lodash-es: "npm:^4.17.21"
semver: "npm:^7.7.3"
peerDependencies:
- "@openmrs/esm-globals": 9.x
+ "@openmrs/esm-globals": ^10.0.1-pre.5263
dayjs: 1.x
i18next: 25.x
rxjs: 6.x
- checksum: 10/7d8aeea1b790693b94e3cea553129d5d845c3ec08d668a98ed9ccc2a36cebff8a630388ea6d3c759dba37e5f58816a4c7244dd9788bfb5cb754082fe6eb20ad3
+ checksum: 10/f3d386c5f49fe2dc7175b14726467f6062c2172bc555f7baaf131f044cb47f91c8b2fb695d6bd6514bf81913d784825132c8dcaa72c0e4670f3a6d97f34ca2e0
languageName: node
linkType: hard
-"@openmrs/rspack-config@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/rspack-config@npm:9.0.3-pre.4537"
+"@openmrs/rspack-config@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/rspack-config@npm:10.0.1-pre.5263"
dependencies:
+ "@module-federation/enhanced": "npm:2.8.1"
"@rspack/cli": "npm:1.7.9"
"@rspack/core": "npm:1.7.9"
"@swc/core": "npm:1.15.21"
@@ -3534,19 +3733,21 @@ __metadata:
lodash: "npm:4.17.21"
sass-embedded: "npm:1.89.2"
sass-loader: "npm:16.0.5"
+ semver: "npm:^7.7.3"
style-loader: "npm:3.3.4"
swc-loader: "npm:0.2.7"
ts-checker-rspack-plugin: "npm:1.1.4"
webpack-bundle-analyzer: "npm:4.10.2"
webpack-stats-plugin: "npm:1.1.3"
- checksum: 10/66aca1a0049ef232985923ccc00d8c25fca6f848a5b63a9813c9c09096d5fb86d4c9f88a60dc0c667a0388dbcaa06d631e36176dd3a38017e087764472973f43
+ checksum: 10/3bef1d5f3e224a3e544bf16c272c42ef7f8d1c639d14e0f8f8057502176e00c79f6457fa327f2f52e7e034301608eda6523f8f494ed7f554950375b2714bd01a
languageName: node
linkType: hard
-"@openmrs/webpack-config@npm:9.0.3-pre.4537":
- version: 9.0.3-pre.4537
- resolution: "@openmrs/webpack-config@npm:9.0.3-pre.4537"
+"@openmrs/webpack-config@npm:10.0.1-pre.5263":
+ version: 10.0.1-pre.5263
+ resolution: "@openmrs/webpack-config@npm:10.0.1-pre.5263"
dependencies:
+ "@module-federation/enhanced": "npm:2.8.1"
"@swc/core": "npm:1.15.21"
clean-webpack-plugin: "npm:4.0.0"
copy-webpack-plugin: "npm:11.0.0"
@@ -3555,13 +3756,14 @@ __metadata:
lodash: "npm:4.17.21"
sass-embedded: "npm:1.89.2"
sass-loader: "npm:16.0.5"
+ semver: "npm:^7.7.3"
style-loader: "npm:3.3.4"
swc-loader: "npm:0.2.7"
webpack: "npm:5.105.3"
webpack-bundle-analyzer: "npm:4.10.2"
webpack-cli: "npm:6.0.1"
webpack-stats-plugin: "npm:1.1.3"
- checksum: 10/226878dc2eee47a915f006a2ebd8a326d785b2ae11493c770748da12a752b9c21ba7b06e975f1650d6c67c43a29c43bdead9b3f7e1c369e07d3ae5505f320a80
+ checksum: 10/9b2863783460f6ff55a9355c7dda7cb0e4958c31763206886bd1dfc8369e6e8494744ed76c0c5b418e7577a3a3b3e196e73e37f3ff5a909b86ff23dad6b02e5d
languageName: node
linkType: hard
@@ -3710,6 +3912,22 @@ __metadata:
languageName: node
linkType: hard
+"@pih/esm-audit-app@workspace:packages/esm-audit-app":
+ version: 0.0.0-use.local
+ resolution: "@pih/esm-audit-app@workspace:packages/esm-audit-app"
+ dependencies:
+ "@babel/runtime": "npm:^7.24.0"
+ openmrs: "npm:^10.0.1-pre.5263"
+ peerDependencies:
+ "@openmrs/esm-framework": 8.x
+ i18next: ^19.0.0
+ react: 18.x
+ react-i18next: 16.x
+ react-router-dom: ^6.x
+ swr: 2.x
+ languageName: unknown
+ linkType: soft
+
"@pih/esm-commons-app@workspace:packages/esm-commons-app":
version: 0.0.0-use.local
resolution: "@pih/esm-commons-app@workspace:packages/esm-commons-app"
@@ -7856,6 +8074,13 @@ __metadata:
languageName: node
linkType: hard
+"adm-zip@npm:0.6.0":
+ version: 0.6.0
+ resolution: "adm-zip@npm:0.6.0"
+ checksum: 10/6a49ab39055d8f716958d3a2055491ab86fe3359c4afeed9b14bbce78fb847bcd3cbaa0ff4921aaa19824aaa29d06691def1f53263db5224d06c068f922c5ba2
+ languageName: node
+ linkType: hard
+
"agent-base@npm:6":
version: 6.0.2
resolution: "agent-base@npm:6.0.2"
@@ -9192,6 +9417,13 @@ __metadata:
languageName: node
linkType: hard
+"commander@npm:11.1.0":
+ version: 11.1.0
+ resolution: "commander@npm:11.1.0"
+ checksum: 10/66bd2d8a0547f6cb1d34022efb25f348e433b0e04ad76a65279b1b09da108f59a4d3001ca539c60a7a46ea38bcf399fc17d91adad76a8cf43845d8dcbaf5cda1
+ languageName: node
+ linkType: hard
+
"commander@npm:2, commander@npm:^2.20.0":
version: 2.20.3
resolution: "commander@npm:2.20.3"
@@ -13443,6 +13675,15 @@ __metadata:
languageName: node
linkType: hard
+"isomorphic-ws@npm:5.0.0":
+ version: 5.0.0
+ resolution: "isomorphic-ws@npm:5.0.0"
+ peerDependencies:
+ ws: "*"
+ checksum: 10/e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398
+ languageName: node
+ linkType: hard
+
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
version: 3.2.2
resolution: "istanbul-lib-coverage@npm:3.2.2"
@@ -14076,6 +14317,15 @@ __metadata:
languageName: node
linkType: hard
+"jiti@npm:2.4.2":
+ version: 2.4.2
+ resolution: "jiti@npm:2.4.2"
+ bin:
+ jiti: lib/jiti-cli.mjs
+ checksum: 10/e2b07eb2e3fbb245e29ad288dddecab31804967fc84d5e01d39858997d2743b5e248946defcecf99272275a00284ecaf7ec88b8c841331324f0c946d8274414b
+ languageName: node
+ linkType: hard
+
"jpeg-js@npm:^0.4.2":
version: 0.4.4
resolution: "jpeg-js@npm:0.4.4"
@@ -15575,14 +15825,14 @@ __metadata:
languageName: node
linkType: hard
-"openmrs@npm:next":
- version: 9.0.3-pre.4537
- resolution: "openmrs@npm:9.0.3-pre.4537"
+"openmrs@npm:^10.0.1-pre.5263, openmrs@npm:next":
+ version: 10.0.1-pre.5263
+ resolution: "openmrs@npm:10.0.1-pre.5263"
dependencies:
"@inquirer/prompts": "npm:8.3.2"
- "@openmrs/esm-app-shell": "npm:9.0.3-pre.4537"
- "@openmrs/rspack-config": "npm:9.0.3-pre.4537"
- "@openmrs/webpack-config": "npm:9.0.3-pre.4537"
+ "@openmrs/esm-app-shell": "npm:10.0.1-pre.5263"
+ "@openmrs/rspack-config": "npm:10.0.1-pre.5263"
+ "@openmrs/webpack-config": "npm:10.0.1-pre.5263"
"@pnpm/npm-conf": "npm:^3.0.2"
"@rspack/cli": "npm:1.7.9"
"@rspack/core": "npm:1.7.9"
@@ -15621,7 +15871,7 @@ __metadata:
openmrs: ./dist/cli.js
rspack: ./bin/rspack.cjs
webpack: ./bin/webpack.cjs
- checksum: 10/e5dd60c4e67428e25c0aafef89de1c3d6d7d5f995cb05e1844385d46e5909d61b70655910d5bf9ec05c1315d3811f0903dfc433f32a6a4815ea3b64c29294363
+ checksum: 10/19a4dbd8226c224349ba0e5334df9af4ef31b406a383770d35ada87bcf1946a2789440e41ff3f71bb1d8baa1af2fcf985c941b5ca82c5ac6c6cfd37130e2673f
languageName: node
linkType: hard
@@ -17624,6 +17874,18 @@ __metadata:
languageName: node
linkType: hard
+"schema-utils@npm:4.3.0":
+ version: 4.3.0
+ resolution: "schema-utils@npm:4.3.0"
+ dependencies:
+ "@types/json-schema": "npm:^7.0.9"
+ ajv: "npm:^8.9.0"
+ ajv-formats: "npm:^2.1.1"
+ ajv-keywords: "npm:^5.1.0"
+ checksum: 10/86c5a7c72a275c56f140bc3cdd832d56efb11428c88ad588127db12cb9b2c83ccaa9540e115d7baa9c6175b5e360094457e29c44e6fb76787c9498c2eb6df5d6
+ languageName: node
+ linkType: hard
+
"schema-utils@npm:^3.0.0":
version: 3.3.0
resolution: "schema-utils@npm:3.3.0"
@@ -18564,6 +18826,13 @@ __metadata:
languageName: node
linkType: hard
+"tapable@npm:2.3.0, tapable@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "tapable@npm:2.3.0"
+ checksum: 10/496a841039960533bb6e44816a01fffc2a1eb428bb2051ecab9e87adf07f19e1f937566cbbbb09dceff31163c0ffd81baafcad84db900b601f0155dd0b37e9f2
+ languageName: node
+ linkType: hard
+
"tapable@npm:^1.0.0":
version: 1.1.3
resolution: "tapable@npm:1.1.3"
@@ -18578,13 +18847,6 @@ __metadata:
languageName: node
linkType: hard
-"tapable@npm:^2.3.0":
- version: 2.3.0
- resolution: "tapable@npm:2.3.0"
- checksum: 10/496a841039960533bb6e44816a01fffc2a1eb428bb2051ecab9e87adf07f19e1f937566cbbbb09dceff31163c0ffd81baafcad84db900b601f0155dd0b37e9f2
- languageName: node
- linkType: hard
-
"tar@npm:7.5.13, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.13
resolution: "tar@npm:7.5.13"
@@ -19184,6 +19446,13 @@ __metadata:
languageName: node
linkType: hard
+"undici@npm:7.28.0":
+ version: 7.28.0
+ resolution: "undici@npm:7.28.0"
+ checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d
+ languageName: node
+ linkType: hard
+
"undici@npm:^7.12.0":
version: 7.16.0
resolution: "undici@npm:7.16.0"
@@ -20045,6 +20314,21 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:8.21.0":
+ version: 8.21.0
+ resolution: "ws@npm:8.21.0"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ">=5.0.2"
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 10/088411956432c8f876158409d5a285cb9ad1382f593391f51d3a599bd0a5b277f876609ebd00fc3596321c4a4c9064d6fffe1ebad960e8ea7fd9ae25324f35c2
+ languageName: node
+ linkType: hard
+
"ws@npm:^7.3.1":
version: 7.5.9
resolution: "ws@npm:7.5.9"