@openmrs/esm-patient-common-lib 11.3.1-patch.9310 → 11.3.1-pre.10001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-patient-common-lib",
3
- "version": "11.3.1-patch.9310",
3
+ "version": "11.3.1-pre.10001",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Library for common patient chart components",
6
6
  "browser": "dist/openmrs-esm-patient-common-lib.js",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@carbon/react": "^1.83.0",
33
- "lodash-es": "^4.17.21",
33
+ "lodash-es": "^4.17.23",
34
34
  "uuid": "^8.3.2"
35
35
  },
36
36
  "peerDependencies": {
@@ -28,5 +28,6 @@ export interface FormRendererProps {
28
28
  preFilledQuestions?: Record<string, string>;
29
29
  launchChildWorkspace?: Workspace2DefinitionProps['launchChildWorkspace'];
30
30
  closeWorkspace?: Workspace2DefinitionProps['closeWorkspace'];
31
+ closeWorkspaceWithSavedChanges?: () => void;
31
32
  setHasUnsavedChanges?(hasUnsavedChanges: boolean);
32
33
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './dashboards/createDashboardLink';
4
4
  export * from './empty-state';
5
5
  export * from './error-state';
6
6
  export * from './form-entry/form-entry';
7
+ export * from './results';
7
8
  export * from './launchStartVisitPrompt';
8
9
  export * from './offline/visit';
9
10
  export * from './orders';
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type Concept,
3
+ type Encounter,
3
4
  openmrsFetch,
4
5
  type OpenmrsResource,
5
6
  parseDate,
@@ -7,20 +8,28 @@ import {
7
8
  type Visit,
8
9
  } from '@openmrs/esm-framework';
9
10
  import { type OrderBasketStore, orderBasketStore } from './store';
10
- import type {
11
- DrugOrderPost,
12
- TestOrderPost,
13
- ExtractedOrderErrorObject,
14
- OrderBasketItem,
15
- OrderErrorObject,
16
- OrderPost,
17
- } from './types';
11
+ import type { ExtractedOrderErrorObject, Order, OrderBasketItem, OrderErrorObject, OrderPost } from './types';
12
+
13
+ function getOrdersPayloadFromOrderBasket(patientUuid: string, ordererUuid: string) {
14
+ const { items, postDataPrepFunctions }: OrderBasketStore = orderBasketStore.getState();
15
+ const patientItems = items[patientUuid];
16
+
17
+ const orders: Array<OrderPost> = [];
18
+ Object.entries(patientItems).forEach(([grouping, groupOrders]) => {
19
+ groupOrders.forEach((order) => {
20
+ orders.push(postDataPrepFunctions[grouping](order, patientUuid, null, ordererUuid));
21
+ });
22
+ });
23
+
24
+ return orders;
25
+ }
18
26
 
19
27
  export async function postOrdersOnNewEncounter(
20
28
  patientUuid: string,
21
29
  orderEncounterType: string,
22
30
  currentVisit: Visit | null,
23
- sessionLocationUuid: string,
31
+ orderLocationUuid: string,
32
+ ordererUuid: string,
24
33
  abortController?: AbortController,
25
34
 
26
35
  /**
@@ -29,36 +38,23 @@ export async function postOrdersOnNewEncounter(
29
38
  */
30
39
  encounterDate?: Date,
31
40
  ) {
32
- const now = new Date();
33
- const visitStartDate = parseDate(currentVisit?.startDatetime);
34
- const visitEndDate = parseDate(currentVisit?.stopDatetime);
35
41
  if (!encounterDate) {
36
- if (!currentVisit || (visitStartDate < now && (!visitEndDate || visitEndDate > now))) {
37
- encounterDate = now;
42
+ if (currentVisit?.stopDatetime) {
43
+ encounterDate = parseDate(currentVisit.startDatetime);
38
44
  } else {
39
- console.warn(
40
- 'postOrdersOnNewEncounter received an active visit that is not currently active. This is a programming error. Attempting to place the order using the visit start date.',
41
- );
42
- encounterDate = visitStartDate;
45
+ encounterDate = null;
43
46
  }
44
47
  }
45
48
 
46
- const { items, postDataPrepFunctions }: OrderBasketStore = orderBasketStore.getState();
47
- const patientItems = items[patientUuid];
48
-
49
- const orders: Array<DrugOrderPost | TestOrderPost> = [];
50
-
51
- Object.entries(patientItems).forEach(([grouping, groupOrders]) => {
52
- groupOrders.forEach((order) => {
53
- orders.push(postDataPrepFunctions[grouping](order, patientUuid, null));
54
- });
55
- });
49
+ const orders = getOrdersPayloadFromOrderBasket(patientUuid, ordererUuid);
56
50
 
57
51
  const encounterPostData: EncounterPost = {
58
52
  patient: patientUuid,
59
- location: sessionLocationUuid,
53
+ location: orderLocationUuid,
60
54
  encounterType: orderEncounterType,
61
- encounterDatetime: encounterDate,
55
+ // only specify the encounterDatetime if it's given, otherwise
56
+ // don't specify that let the server default it to `now`
57
+ ...(encounterDate ? { encounterDatetime: encounterDate } : {}),
62
58
  visit: currentVisit?.uuid,
63
59
  obs: [],
64
60
  orders,
@@ -75,53 +71,70 @@ export interface EncounterPost {
75
71
  patient: string;
76
72
  location: string;
77
73
  encounterType: string;
78
- encounterDatetime: Date;
74
+ encounterDatetime?: Date;
79
75
  visit?: string;
80
76
  obs: ObsPayload[];
81
77
  orders: OrderPost[];
82
78
  }
83
79
 
84
80
  export async function postEncounter(encounterPostData: EncounterPost, abortController?: AbortController) {
85
- return openmrsFetch<OpenmrsResource>(`${restBaseUrl}/encounter`, {
81
+ return openmrsFetch<Encounter>(`${restBaseUrl}/encounter`, {
86
82
  headers: {
87
83
  'Content-Type': 'application/json',
88
84
  },
89
85
  method: 'POST',
90
86
  body: encounterPostData,
91
87
  signal: abortController?.signal,
92
- }).then((res) => res?.data?.uuid);
88
+ }).then((res) => res?.data);
93
89
  }
94
90
 
95
- export async function postOrders(patientUuid: string, encounterUuid: string, abortController: AbortController) {
91
+ export async function postOrders(
92
+ patientUuid: string,
93
+ encounterUuid: string,
94
+ abortController: AbortController,
95
+ ordererUuid: string,
96
+ ) {
96
97
  const { items, postDataPrepFunctions }: OrderBasketStore = orderBasketStore.getState();
97
98
  const patientItems = items[patientUuid];
98
99
 
99
100
  const erroredItems: Array<OrderBasketItem> = [];
100
- for (let grouping in patientItems) {
101
+ const postedOrders: Array<Order> = [];
102
+ const promises: Array<Promise<void>> = [];
103
+
104
+ for (const grouping in patientItems) {
101
105
  const orders = patientItems[grouping];
106
+ const dataPrepFn = postDataPrepFunctions[grouping];
107
+
108
+ if (typeof dataPrepFn !== 'function') {
109
+ console.warn(`The postDataPrep function registered for ${grouping} orders is not a function`);
110
+ continue;
111
+ }
112
+
102
113
  for (let i = 0; i < orders.length; i++) {
103
114
  const order = orders[i];
104
- const dataPrepFn = postDataPrepFunctions[grouping];
105
-
106
- if (typeof dataPrepFn !== 'function') {
107
- console.warn(`The postDataPrep function registered for ${grouping} orders is not a function`);
108
- continue;
109
- }
110
-
111
- await postOrder(dataPrepFn(order, patientUuid, encounterUuid), abortController).catch((error) => {
112
- erroredItems.push({
113
- ...order,
114
- orderError: error,
115
- extractedOrderError: extractErrorDetails(error),
115
+
116
+ const promise = postOrder(dataPrepFn(order, patientUuid, encounterUuid, ordererUuid), abortController)
117
+ .then((response) => {
118
+ postedOrders.push(response.data);
119
+ })
120
+ .catch((error) => {
121
+ erroredItems.push({
122
+ ...order,
123
+ orderError: error,
124
+ extractedOrderError: extractErrorDetails(error),
125
+ });
116
126
  });
117
- });
127
+
128
+ promises.push(promise);
118
129
  }
119
130
  }
120
- return erroredItems;
131
+ await Promise.allSettled(promises);
132
+
133
+ return { postedOrders, erroredItems };
121
134
  }
122
135
 
123
136
  export function postOrder(body: OrderPost, abortController?: AbortController) {
124
- return openmrsFetch(`${restBaseUrl}/order`, {
137
+ return openmrsFetch<Order>(`${restBaseUrl}/order`, {
125
138
  method: 'POST',
126
139
  signal: abortController?.signal,
127
140
  headers: { 'Content-Type': 'application/json' },
@@ -1,28 +1,86 @@
1
- import { showSnackbar } from '@openmrs/esm-framework';
2
- import { type TFunction } from 'i18next';
1
+ import { showSnackbar, translateFrom } from '@openmrs/esm-framework';
3
2
  import { type OrderBasketItem } from './types';
4
3
 
5
- export function showOrderSuccessToast(t: TFunction, patientOrderItems: OrderBasketItem[]) {
6
- const orderedString = patientOrderItems
7
- .filter((item) => ['NEW', 'RENEW'].includes(item.action))
8
- .map((item) => item.display)
9
- .join(', ');
10
- const updatedString = patientOrderItems
11
- .filter((item) => item.action === 'REVISE')
12
- .map((item) => item.display)
13
- .join(', ');
14
- const discontinuedString = patientOrderItems
15
- .filter((item) => item.action === 'DISCONTINUE')
16
- .map((item) => item.display)
17
- .join(', ');
4
+ type OrderAction = 'placed' | 'updated' | 'discontinued';
5
+
6
+ function getNotificationTitle(
7
+ moduleName: string,
8
+ placedOrders: OrderBasketItem[],
9
+ updatedOrders: OrderBasketItem[],
10
+ discontinuedOrders: OrderBasketItem[],
11
+ activeActions: OrderAction[],
12
+ ): string {
13
+ if (activeActions.length > 1) {
14
+ return translateFrom(moduleName, 'ordersCompleted', 'Orders completed');
15
+ }
16
+
17
+ const action = activeActions[0];
18
+
19
+ if (action === 'placed') {
20
+ return placedOrders.length === 1
21
+ ? translateFrom(moduleName, 'orderPlaced', 'Order placed')
22
+ : translateFrom(moduleName, 'ordersPlaced', 'Orders placed');
23
+ }
24
+
25
+ if (action === 'updated') {
26
+ return updatedOrders.length === 1
27
+ ? translateFrom(moduleName, 'orderUpdated', 'Order updated')
28
+ : translateFrom(moduleName, 'ordersUpdated', 'Orders updated');
29
+ }
30
+
31
+ // action === 'discontinued'
32
+ return discontinuedOrders.length === 1
33
+ ? translateFrom(moduleName, 'orderDiscontinued', 'Order discontinued')
34
+ : translateFrom(moduleName, 'ordersDiscontinued', 'Orders discontinued');
35
+ }
36
+
37
+ /**
38
+ * Shows a success toast notification for order operations.
39
+ * The notification title dynamically reflects the action(s) taken (placed, updated, discontinued)
40
+ * and whether it's singular or plural.
41
+ *
42
+ * @param moduleName - The module name (e.g., '@openmrs/esm-patient-orders-app') to use for translations
43
+ * @param patientOrderItems - Array of order basket items that were processed
44
+ */
45
+ export function showOrderSuccessToast(moduleName: string, patientOrderItems: OrderBasketItem[]) {
46
+ if (patientOrderItems.length === 0) {
47
+ return;
48
+ }
49
+
50
+ const placedOrders = patientOrderItems.filter((item) => ['NEW', 'RENEW'].includes(item.action));
51
+ const updatedOrders = patientOrderItems.filter((item) => item.action === 'REVISE');
52
+ const discontinuedOrders = patientOrderItems.filter((item) => item.action === 'DISCONTINUE');
53
+
54
+ const orderedString = placedOrders.map((item) => item.display).join(', ');
55
+ const updatedString = updatedOrders.map((item) => item.display).join(', ');
56
+ const discontinuedString = discontinuedOrders.map((item) => item.display).join(', ');
57
+
58
+ const activeActions: OrderAction[] = [
59
+ orderedString && 'placed',
60
+ updatedString && 'updated',
61
+ discontinuedString && 'discontinued',
62
+ ].filter(Boolean) as OrderAction[];
63
+
64
+ const title = getNotificationTitle(moduleName, placedOrders, updatedOrders, discontinuedOrders, activeActions);
65
+
66
+ const subtitleParts: string[] = [];
67
+
68
+ if (orderedString) {
69
+ subtitleParts.push(`${translateFrom(moduleName, 'orderedFor', 'Placed order for')} ${orderedString}.`);
70
+ }
71
+
72
+ if (updatedString) {
73
+ subtitleParts.push(`${translateFrom(moduleName, 'updated', 'Updated')} ${updatedString}.`);
74
+ }
75
+
76
+ if (discontinuedString) {
77
+ subtitleParts.push(`${translateFrom(moduleName, 'discontinued', 'Discontinued')} ${discontinuedString}.`);
78
+ }
18
79
 
19
80
  showSnackbar({
20
81
  isLowContrast: true,
21
82
  kind: 'success',
22
- title: t('orderCompleted', 'Placed orders'),
23
- subtitle:
24
- (orderedString && `${t('ordered', 'Placed order for')} ${orderedString}. `) +
25
- (updatedString && `${t('updated', 'Updated')} ${updatedString}. `) +
26
- (discontinuedString && `${t('discontinued', 'Discontinued')} ${discontinuedString}.`),
83
+ title,
84
+ subtitle: subtitleParts.join(' '),
27
85
  });
28
86
  }
@@ -47,8 +47,6 @@ export interface OrderBasketItem {
47
47
  action: OrderAction;
48
48
  display: string;
49
49
  uuid?: string;
50
- orderer?: string;
51
- careSetting?: string;
52
50
  orderError?: Error & {
53
51
  responseBody?: {
54
52
  error?: {
@@ -118,7 +116,7 @@ export interface DrugOrderPost extends OrderPost {
118
116
  dosingInstructions?: string;
119
117
  }
120
118
 
121
- export interface TestOrderPost extends OrderPost {}
119
+ export type TestOrderPost = OrderPost;
122
120
 
123
121
  export interface PatientOrderFetchResponse {
124
122
  results: Array<Order>;
@@ -129,7 +127,7 @@ export interface Order {
129
127
  action: OrderAction;
130
128
  asNeeded: boolean;
131
129
  asNeededCondition?: string;
132
- autoExpireDate: string;
130
+ autoExpireDate?: string | null;
133
131
  brandName?: string;
134
132
  careSetting: OpenmrsResource;
135
133
  commentToFulfiller: string;
@@ -137,17 +135,17 @@ export interface Order {
137
135
  dateActivated: string;
138
136
  dateStopped?: string | null;
139
137
  dispenseAsWritten: boolean;
140
- dose: number;
141
- doseUnits: OpenmrsResource;
138
+ dose: number | null;
139
+ doseUnits: OpenmrsResource | null;
142
140
  dosingInstructions: string | null;
143
141
  dosingType?: 'org.openmrs.FreeTextDosingInstructions' | 'org.openmrs.SimpleDosingInstructions';
144
- drug: Drug;
145
- duration: number;
146
- durationUnits: OpenmrsResource;
142
+ drug: Drug | null;
143
+ duration: number | null;
144
+ durationUnits: OpenmrsResource | null;
147
145
  encounter: Encounter;
148
- frequency: OpenmrsResource;
146
+ frequency: OpenmrsResource | null;
149
147
  instructions?: string | null;
150
- numRefills: number;
148
+ numRefills: number | null;
151
149
  orderNumber: string;
152
150
  orderReason: string | null;
153
151
  orderReasonNonCoded: string | null;
@@ -169,9 +167,9 @@ export interface Order {
169
167
  };
170
168
  patient: OpenmrsResource;
171
169
  previousOrder: { uuid: string; type: string; display: string } | null;
172
- quantity: number;
173
- quantityUnits: OpenmrsResource;
174
- route: OpenmrsResource;
170
+ quantity: number | null;
171
+ quantityUnits: OpenmrsResource | null;
172
+ route: OpenmrsResource | null;
175
173
  scheduleDate: null;
176
174
  urgency: OrderUrgency;
177
175
 
@@ -211,10 +209,15 @@ export interface OrderType {
211
209
 
212
210
  export type FulfillerStatus = 'RECEIVED' | 'IN_PROGRESS' | 'EXCEPTION' | 'ON_HOLD' | 'DECLINED' | 'COMPLETED';
213
211
 
212
+ /**
213
+ * A function type that converts a OrderBasketItem into
214
+ * a POST order payload
215
+ */
214
216
  export type PostDataPrepFunction = (
215
217
  order: OrderBasketItem,
216
218
  patientUuid: string,
217
219
  encounterUuid: string | null,
220
+ orderingProviderUuid: string,
218
221
  ) => OrderPost;
219
222
 
220
223
  export interface OrderBasketExtensionProps {
@@ -226,21 +229,21 @@ export interface OrderBasketExtensionProps {
226
229
 
227
230
  export interface DrugOrderBasketItem extends OrderBasketItem {
228
231
  drug: Drug;
229
- unit: DosingUnit;
232
+ unit: DosingUnit | null;
230
233
  commonMedicationName: string;
231
- dosage: number;
232
- frequency: MedicationFrequency;
233
- route: MedicationRoute;
234
- quantityUnits: QuantityUnit;
235
- patientInstructions: string;
234
+ dosage: number | null;
235
+ frequency: MedicationFrequency | null;
236
+ route: MedicationRoute | null;
237
+ quantityUnits: QuantityUnit | null;
238
+ patientInstructions: string | null;
236
239
  asNeeded: boolean;
237
- asNeededCondition: string;
240
+ asNeededCondition: string | null;
238
241
  startDate: Date | string;
239
- durationUnit: DurationUnit;
242
+ durationUnit: DurationUnit | null;
240
243
  duration: number | null;
241
244
  pillsDispensed: number | null;
242
245
  numRefills: number | null;
243
- indication: string;
246
+ indication: string | null;
244
247
  isFreeTextDosage: boolean;
245
248
  freeTextDosage: string;
246
249
  previousOrder?: string;
@@ -308,6 +311,7 @@ export interface TestOrderBasketItem extends OrderBasketItem {
308
311
 
309
312
  export interface OrderBasketWindowProps {
310
313
  encounterUuid: string;
314
+ onOrderBasketSubmitted?: (encounterUuid: string, postedOrders: Array<Order>) => void;
311
315
  }
312
316
 
313
317
  export interface ExportedOrderBasketWindowProps {
@@ -319,4 +323,5 @@ export interface ExportedOrderBasketWindowProps {
319
323
  patientUuid: string;
320
324
  visitContext: Visit;
321
325
  mutateVisitContext: () => void;
326
+ onOrderBasketSubmitted?: (encounterUuid: string, postedOrders: Array<Order>) => void;
322
327
  }
@@ -1,6 +1,6 @@
1
- import { restBaseUrl } from '@openmrs/esm-framework';
2
1
  import { useCallback } from 'react';
3
2
  import { useSWRConfig } from 'swr';
3
+ import { restBaseUrl } from '@openmrs/esm-framework';
4
4
 
5
5
  /**
6
6
  * Returns a function which refreshes the patient orders cache. Uses SWR's mutate function.
@@ -12,9 +12,13 @@ export function useMutatePatientOrders(patientUuid: string) {
12
12
  const { mutate } = useSWRConfig();
13
13
  const mutateOrders = useCallback(
14
14
  () =>
15
- mutate((key) => {
16
- return typeof key === 'string' && key.startsWith(`${restBaseUrl}/order?patient=${patientUuid}`);
17
- }),
15
+ mutate(
16
+ (key) => {
17
+ return typeof key === 'string' && key.startsWith(`${restBaseUrl}/order?patient=${patientUuid}`);
18
+ },
19
+ undefined,
20
+ { revalidate: true },
21
+ ),
18
22
  [patientUuid, mutate],
19
23
  );
20
24
 
@@ -1,7 +1,7 @@
1
- import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
1
+ import { useMemo } from 'react';
2
2
  import useSWRImmutable from 'swr/immutable';
3
+ import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
3
4
  import type { OrderTypeFetchResponse } from './types';
4
- import { useMemo } from 'react';
5
5
 
6
6
  export function useOrderTypes() {
7
7
  const orderTypesUrl = `${restBaseUrl}/ordertype`;
@@ -60,7 +60,7 @@ function useOrderableConceptSWR(searchTerm: string, orderableConceptSets?: Array
60
60
  };
61
61
  }
62
62
 
63
- export interface OrderableConcept extends OpenmrsResource {}
63
+ export type OrderableConcept = OpenmrsResource;
64
64
 
65
65
  export function useOrderableConceptSets(searchTerm: string, orderableConcepts: Array<string>) {
66
66
  const { data, isLoading, error } = useOrderableConceptSWR(
@@ -15,6 +15,9 @@ export const drugCustomRepresentation =
15
15
  'frequency:ref,asNeeded,asNeededCondition,quantity,quantityUnits:ref,numRefills,dosingInstructions,' +
16
16
  'duration,durationUnits:ref,route:ref,brandName,dispenseAsWritten)';
17
17
 
18
+ export const orderCustomRepresentation =
19
+ 'custom:(uuid,display,orderNumber,accessionNumber,patient,concept,action,careSetting,previousOrder,dateActivated,scheduledDate,dateStopped,autoExpireDate,encounter:(uuid,display,visit),orderer:ref,orderReason,orderReasonNonCoded,orderType,urgency,instructions,commentToFulfiller,fulfillerStatus)';
20
+
18
21
  export function usePatientOrders(
19
22
  patientUuid: string,
20
23
  status?: Status,
@@ -23,10 +26,11 @@ export function usePatientOrders(
23
26
  endDate?: string,
24
27
  ) {
25
28
  const { mutate } = useSWRConfig();
29
+ const activeStatusParams = status === 'ACTIVE' ? '&excludeCanceledAndExpired=true' : '';
26
30
  const baseOrdersUrl =
27
31
  startDate && endDate
28
- ? `${restBaseUrl}/order?patient=${patientUuid}&careSetting=${careSettingUuid}&v=default&activatedOnOrAfterDate=${startDate}&activatedOnOrBeforeDate=${endDate}`
29
- : `${restBaseUrl}/order?patient=${patientUuid}&careSetting=${careSettingUuid}&v=default&status=${status}`;
32
+ ? `${restBaseUrl}/order?patient=${patientUuid}&careSetting=${careSettingUuid}&v=${orderCustomRepresentation}&activatedOnOrAfterDate=${startDate}&activatedOnOrBeforeDate=${endDate}&excludeDiscontinueOrders=true${activeStatusParams}`
33
+ : `${restBaseUrl}/order?patient=${patientUuid}&careSetting=${careSettingUuid}&v=${orderCustomRepresentation}&status=${status}&excludeDiscontinueOrders=true`;
30
34
  const ordersUrl = orderType ? `${baseOrdersUrl}&orderTypes=${orderType}` : baseOrdersUrl;
31
35
 
32
36
  const { data, error, isLoading, isValidating } = useSWR<FetchResponse<PatientOrderFetchResponse>, Error>(
@@ -36,8 +40,12 @@ export function usePatientOrders(
36
40
 
37
41
  const mutateOrders = useCallback(
38
42
  () =>
39
- mutate((key) => typeof key === 'string' && key.startsWith(`${restBaseUrl}/order?patient=${patientUuid}`), data),
40
- [data, mutate, patientUuid],
43
+ mutate(
44
+ (key) => typeof key === 'string' && key.startsWith(`${restBaseUrl}/order?patient=${patientUuid}`),
45
+ undefined,
46
+ { revalidate: true },
47
+ ),
48
+ [mutate, patientUuid],
41
49
  );
42
50
 
43
51
  const orders = useMemo(
@@ -63,6 +71,19 @@ export function getDrugOrderByUuid(orderUuid: string) {
63
71
  return openmrsFetch<Order>(`${restBaseUrl}/order/${orderUuid}?v=${drugCustomRepresentation}`);
64
72
  }
65
73
 
74
+ export function useDrugOrderByUuid(orderUuid: string) {
75
+ const { data, error, isLoading } = useSWR<FetchResponse<Order>, Error>(
76
+ orderUuid ? `${restBaseUrl}/order/${orderUuid}?v=${drugCustomRepresentation}` : null,
77
+ openmrsFetch,
78
+ );
79
+
80
+ return {
81
+ data: data?.data ?? null,
82
+ error,
83
+ isLoading,
84
+ };
85
+ }
86
+
66
87
  // See the Urgency enum in https://github.com/openmrs/openmrs-core/blob/492dcd35b85d48730bd19da48f6db146cc882c22/api/src/main/java/org/openmrs/Order.java
67
88
  export const priorityOptions: PriorityOption[] = [
68
89
  { value: 'ROUTINE', label: translateFrom(patientChartAppModuleName, 'Routine') },
@@ -0,0 +1,123 @@
1
+ import { type ReferenceRanges } from '../types';
2
+ import { exist, formatReferenceRange, assessValue } from './helpers';
3
+
4
+ describe('exist', () => {
5
+ it('returns true when all values are defined', () => {
6
+ expect(exist(1, 2, 3)).toBe(true);
7
+ expect(exist(0)).toBe(true);
8
+ expect(exist('')).toBe(true);
9
+ });
10
+
11
+ it('returns false when any value is null or undefined', () => {
12
+ expect(exist(null)).toBe(false);
13
+ expect(exist(undefined)).toBe(false);
14
+ expect(exist(1, null, 3)).toBe(false);
15
+ expect(exist(1, undefined)).toBe(false);
16
+ });
17
+
18
+ it('returns true for empty arguments', () => {
19
+ expect(exist()).toBe(true);
20
+ });
21
+ });
22
+
23
+ describe('formatReferenceRange', () => {
24
+ it('returns formatted range with en-dash', () => {
25
+ expect(formatReferenceRange({ lowNormal: 35, hiNormal: 147 })).toBe('35 – 147');
26
+ });
27
+
28
+ it('appends units from ranges object', () => {
29
+ expect(formatReferenceRange({ lowNormal: 35, hiNormal: 147, units: 'U/L' })).toBe('35 – 147 U/L');
30
+ });
31
+
32
+ it('appends units from parameter when ranges.units is absent', () => {
33
+ expect(formatReferenceRange({ lowNormal: 35, hiNormal: 147 }, 'mg/dL')).toBe('35 – 147 mg/dL');
34
+ });
35
+
36
+ it('prefers ranges.units over the units parameter', () => {
37
+ expect(formatReferenceRange({ lowNormal: 35, hiNormal: 147, units: 'U/L' }, 'mg/dL')).toBe('35 – 147 U/L');
38
+ });
39
+
40
+ it('returns -- when ranges is null', () => {
41
+ expect(formatReferenceRange(null)).toBe('--');
42
+ });
43
+
44
+ it('returns -- when lowNormal or hiNormal is missing', () => {
45
+ expect(formatReferenceRange({ lowNormal: 35 })).toBe('--');
46
+ expect(formatReferenceRange({ hiNormal: 147 })).toBe('--');
47
+ expect(formatReferenceRange({})).toBe('--');
48
+ });
49
+
50
+ it('handles zero values correctly', () => {
51
+ expect(formatReferenceRange({ lowNormal: 0, hiNormal: 10 })).toBe('0 – 10');
52
+ });
53
+ });
54
+
55
+ describe('assessValue', () => {
56
+ const fullRanges: ReferenceRanges = {
57
+ lowAbsolute: 0,
58
+ lowCritical: 10,
59
+ lowNormal: 35,
60
+ hiNormal: 100,
61
+ hiCritical: 150,
62
+ hiAbsolute: 200,
63
+ };
64
+
65
+ it('returns NORMAL for values within range', () => {
66
+ expect(assessValue(50, fullRanges)).toBe('NORMAL');
67
+ expect(assessValue(70, fullRanges)).toBe('NORMAL');
68
+ });
69
+
70
+ it('returns NORMAL for NaN', () => {
71
+ expect(assessValue(NaN, fullRanges)).toBe('NORMAL');
72
+ });
73
+
74
+ it('returns HIGH when value exceeds hiNormal', () => {
75
+ expect(assessValue(101, fullRanges)).toBe('HIGH');
76
+ expect(assessValue(149, fullRanges)).toBe('HIGH');
77
+ });
78
+
79
+ it('returns CRITICALLY_HIGH when value reaches hiCritical (inclusive)', () => {
80
+ expect(assessValue(150, fullRanges)).toBe('CRITICALLY_HIGH');
81
+ expect(assessValue(175, fullRanges)).toBe('CRITICALLY_HIGH');
82
+ });
83
+
84
+ it('returns OFF_SCALE_HIGH when value reaches hiAbsolute (inclusive)', () => {
85
+ expect(assessValue(200, fullRanges)).toBe('OFF_SCALE_HIGH');
86
+ expect(assessValue(999, fullRanges)).toBe('OFF_SCALE_HIGH');
87
+ });
88
+
89
+ it('returns LOW when value is below lowNormal', () => {
90
+ expect(assessValue(34, fullRanges)).toBe('LOW');
91
+ expect(assessValue(11, fullRanges)).toBe('LOW');
92
+ });
93
+
94
+ it('returns CRITICALLY_LOW when value reaches lowCritical (inclusive)', () => {
95
+ expect(assessValue(10, fullRanges)).toBe('CRITICALLY_LOW');
96
+ expect(assessValue(5, fullRanges)).toBe('CRITICALLY_LOW');
97
+ });
98
+
99
+ it('returns OFF_SCALE_LOW when value reaches lowAbsolute (inclusive)', () => {
100
+ expect(assessValue(0, fullRanges)).toBe('OFF_SCALE_LOW');
101
+ expect(assessValue(-5, fullRanges)).toBe('OFF_SCALE_LOW');
102
+ });
103
+
104
+ // Boundary behavior: normal thresholds are exclusive, critical/absolute are inclusive
105
+ it('treats values at hiNormal as NORMAL (exclusive)', () => {
106
+ expect(assessValue(100, fullRanges)).toBe('NORMAL');
107
+ });
108
+
109
+ it('treats values at lowNormal as NORMAL (exclusive)', () => {
110
+ expect(assessValue(35, fullRanges)).toBe('NORMAL');
111
+ });
112
+
113
+ it('works with only normal thresholds', () => {
114
+ const normalOnly: ReferenceRanges = { lowNormal: 35, hiNormal: 100 };
115
+ expect(assessValue(50, normalOnly)).toBe('NORMAL');
116
+ expect(assessValue(101, normalOnly)).toBe('HIGH');
117
+ expect(assessValue(34, normalOnly)).toBe('LOW');
118
+ });
119
+
120
+ it('returns NORMAL when no thresholds are provided', () => {
121
+ expect(assessValue(50, {})).toBe('NORMAL');
122
+ });
123
+ });
@@ -0,0 +1,63 @@
1
+ import { type OBSERVATION_INTERPRETATION, type ReferenceRanges } from '../types';
2
+
3
+ /**
4
+ * Checks if all provided values exist (are not null or undefined).
5
+ */
6
+ export function exist(...args: unknown[]): boolean {
7
+ return args.every((y) => y !== null && y !== undefined);
8
+ }
9
+
10
+ /**
11
+ * Formats reference range as a string with optional units.
12
+ * Uses en-dash (–) for range separator.
13
+ *
14
+ * @param ranges - The reference ranges object
15
+ * @param units - Optional units string to append
16
+ * @returns Formatted string like "35 – 147 U/L" or "--" if no valid range
17
+ */
18
+ export function formatReferenceRange(ranges: ReferenceRanges | null, units?: string): string {
19
+ if (!ranges) return '--';
20
+ const { lowNormal, hiNormal } = ranges;
21
+ const displayUnits = ranges.units || units || '';
22
+ if (exist(lowNormal, hiNormal)) {
23
+ return `${lowNormal} – ${hiNormal}${displayUnits ? ` ${displayUnits}` : ''}`;
24
+ }
25
+ return '--';
26
+ }
27
+
28
+ /**
29
+ * Determines the interpretation of a lab value based on reference ranges.
30
+ * Returns the appropriate interpretation level based on the value's
31
+ * relationship to normal, critical, and absolute thresholds.
32
+ *
33
+ * @param value - The numeric lab result value
34
+ * @param ranges - The reference ranges to compare against
35
+ * @returns The interpretation category
36
+ */
37
+ export function assessValue(value: number, ranges: ReferenceRanges): OBSERVATION_INTERPRETATION {
38
+ if (isNaN(value)) {
39
+ return 'NORMAL';
40
+ }
41
+ // Critical and absolute thresholds use inclusive (>=, <=) comparisons
42
+ // because values at those boundaries should be flagged for clinical safety.
43
+ // Normal thresholds use exclusive (>, <) so values at the boundary are still normal.
44
+ if (exist(ranges.hiAbsolute) && value >= ranges.hiAbsolute!) {
45
+ return 'OFF_SCALE_HIGH';
46
+ }
47
+ if (exist(ranges.hiCritical) && value >= ranges.hiCritical!) {
48
+ return 'CRITICALLY_HIGH';
49
+ }
50
+ if (exist(ranges.hiNormal) && value > ranges.hiNormal!) {
51
+ return 'HIGH';
52
+ }
53
+ if (exist(ranges.lowAbsolute) && value <= ranges.lowAbsolute!) {
54
+ return 'OFF_SCALE_LOW';
55
+ }
56
+ if (exist(ranges.lowCritical) && value <= ranges.lowCritical!) {
57
+ return 'CRITICALLY_LOW';
58
+ }
59
+ if (exist(ranges.lowNormal) && value < ranges.lowNormal!) {
60
+ return 'LOW';
61
+ }
62
+ return 'NORMAL';
63
+ }
@@ -0,0 +1,3 @@
1
+ export * from './helpers';
2
+ export * from './useReferenceRanges';
3
+ export { ReferenceRangeDisplay } from './reference-range-display.component';
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { type ReferenceRanges } from '../types';
4
+ import { formatReferenceRange } from './helpers';
5
+ import styles from './results.scss';
6
+
7
+ interface ReferenceRangeDisplayProps {
8
+ ranges: ReferenceRanges | null;
9
+ units?: string;
10
+ }
11
+
12
+ /**
13
+ * Displays a formatted reference range with units.
14
+ * Shows "N/A" when no valid range is available.
15
+ */
16
+ export const ReferenceRangeDisplay: React.FC<ReferenceRangeDisplayProps> = ({ ranges, units }) => {
17
+ const { t } = useTranslation();
18
+ const formatted = formatReferenceRange(ranges, units);
19
+
20
+ if (formatted === '--') {
21
+ return <span className={styles.noRange}>{t('notApplicable', 'N/A')}</span>;
22
+ }
23
+
24
+ return <span className={styles.referenceRange}>{formatted}</span>;
25
+ };
@@ -0,0 +1,11 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .referenceRange {
6
+ @include type.type-style('body-compact-01');
7
+ }
8
+
9
+ .noRange {
10
+ color: colors.$gray-50;
11
+ }
@@ -0,0 +1,72 @@
1
+ import { useMemo } from 'react';
2
+ import useSWR from 'swr';
3
+ import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
4
+ import { type ReferenceRanges } from '../types';
5
+
6
+ interface ReferenceRangeResponse {
7
+ uuid: string;
8
+ concept: string;
9
+ lowNormal?: number;
10
+ hiNormal?: number;
11
+ lowAbsolute?: number;
12
+ hiAbsolute?: number;
13
+ lowCritical?: number;
14
+ hiCritical?: number;
15
+ units?: string;
16
+ }
17
+
18
+ export interface UseReferenceRangesResult {
19
+ ranges: Map<string, ReferenceRanges>;
20
+ isLoading: boolean;
21
+ error: Error | undefined;
22
+ mutate: () => void;
23
+ }
24
+
25
+ /**
26
+ * Fetches patient-specific reference ranges for given concepts.
27
+ * Uses the /conceptreferencerange REST API endpoint.
28
+ *
29
+ * @param patientUuid - The UUID of the patient (optional; when absent, no request is made)
30
+ * @param conceptUuids - Array of concept UUIDs to fetch ranges for
31
+ * @returns Object containing ranges map, loading state, error, and mutate function
32
+ */
33
+ export function useReferenceRanges(
34
+ patientUuid: string | undefined,
35
+ conceptUuids: Array<string>,
36
+ ): UseReferenceRangesResult {
37
+ const conceptList = conceptUuids.filter(Boolean).join(',');
38
+ const apiUrl =
39
+ patientUuid && conceptList
40
+ ? `${restBaseUrl}/conceptreferencerange?patient=${patientUuid}&concept=${conceptList}&v=full`
41
+ : null;
42
+
43
+ const { data, error, isLoading, mutate } = useSWR<FetchResponse<{ results: Array<ReferenceRangeResponse> }>, Error>(
44
+ apiUrl,
45
+ openmrsFetch,
46
+ );
47
+
48
+ const rangesMap = useMemo(() => {
49
+ const map = new Map<string, ReferenceRanges>();
50
+ data?.data?.results?.forEach((range) => {
51
+ if (range.concept) {
52
+ map.set(range.concept, {
53
+ lowNormal: range.lowNormal,
54
+ hiNormal: range.hiNormal,
55
+ lowAbsolute: range.lowAbsolute,
56
+ hiAbsolute: range.hiAbsolute,
57
+ lowCritical: range.lowCritical,
58
+ hiCritical: range.hiCritical,
59
+ units: range.units,
60
+ });
61
+ }
62
+ });
63
+ return map;
64
+ }, [data]);
65
+
66
+ return {
67
+ ranges: rangesMap,
68
+ isLoading,
69
+ error,
70
+ mutate,
71
+ };
72
+ }
@@ -16,7 +16,7 @@ const patientChartStore = createGlobalStore<PatientChartStore>(patientChartStore
16
16
  mutateVisitContext: null,
17
17
  });
18
18
 
19
- const patientCharStoreActions = {
19
+ const patientChartStoreActions = {
20
20
  setPatient(_, patient: fhir.Patient) {
21
21
  return { patient, patientUuid: patient?.id ?? null };
22
22
  },
@@ -33,11 +33,11 @@ const patientCharStoreActions = {
33
33
  * the clinical forms workspace in the ward app)
34
34
  * should have the patient / visitContext explicitly passed in as props.
35
35
  *
36
- * As a safety feature, this hook requires the the patientUuid as the input, and only
36
+ * As a safety feature, this hook requires the patientUuid as the input, and only
37
37
  * returns the actual store values if input patientUuid matches that in the store.
38
38
  */
39
39
  export function usePatientChartStore(patientUuid: string) {
40
- const store = useStoreWithActions(patientChartStore, patientCharStoreActions);
40
+ const store = useStoreWithActions(patientChartStore, patientChartStoreActions);
41
41
  if (store.patientUuid === patientUuid) {
42
42
  return store;
43
43
  } else {
@@ -39,8 +39,19 @@ export type OBSERVATION_INTERPRETATION =
39
39
  | 'LOW'
40
40
  | 'CRITICALLY_LOW'
41
41
  | 'OFF_SCALE_LOW'
42
+ // Legacy sentinel for "no data"; avoid new usage and prefer undefined instead.
42
43
  | '--';
43
44
 
45
+ export interface ReferenceRanges {
46
+ hiAbsolute?: number;
47
+ hiCritical?: number;
48
+ hiNormal?: number;
49
+ lowAbsolute?: number;
50
+ lowCritical?: number;
51
+ lowNormal?: number;
52
+ units?: string;
53
+ }
54
+
44
55
  export interface ExternalOverviewProps {
45
56
  patientUuid: string;
46
57
  filter: (filterProps: PanelFilterProps) => boolean;
@@ -2,6 +2,7 @@ import {
2
2
  invalidateVisitHistory,
3
3
  invalidatePatientEncounters,
4
4
  invalidateVisitAndEncounterData,
5
+ invalidateCurrentVisit,
5
6
  } from './revalidation-utils';
6
7
 
7
8
  const mockMutate = jest.fn();
@@ -95,4 +96,30 @@ describe('revalidation-utils', () => {
95
96
  expect(encounterMatcherFn('/ws/rest/v1/encounter?patient=test-patient-123&v=custom')).toBe(true);
96
97
  });
97
98
  });
99
+
100
+ describe('invalidateCurrentVisit', () => {
101
+ it('should invalidate only current visit keys', () => {
102
+ const patientUuid = 'test-patient-123';
103
+
104
+ invalidateCurrentVisit(mockMutate, patientUuid);
105
+
106
+ expect(mockMutate).toHaveBeenCalledTimes(1);
107
+ expect(mockMutate).toHaveBeenCalledWith(expect.any(Function));
108
+
109
+ const matcherFn = mockMutate.mock.calls[0][0];
110
+
111
+ // Should match current visit key (includeInactive=false)
112
+ expect(matcherFn('/ws/rest/v1/visit?patient=test-patient-123&v=custom&includeInactive=false')).toBe(true);
113
+
114
+ // Should not match other visit keys
115
+ expect(matcherFn('/ws/rest/v1/visit?patient=test-patient-123&v=custom&includeInactive=true')).toBe(false);
116
+ expect(
117
+ matcherFn('/ws/rest/v1/visit?patient=test-patient-123&v=custom&limit=10&startIndex=0&totalCount=true'),
118
+ ).toBe(false);
119
+ expect(matcherFn('/ws/rest/v1/visit/test-patient-123')).toBe(false);
120
+
121
+ // Should not match encounter keys
122
+ expect(matcherFn('/ws/rest/v1/encounter?patient=test-patient-123&v=custom')).toBe(false);
123
+ });
124
+ });
98
125
  });
@@ -81,6 +81,24 @@ export function invalidatePatientEncounters(mutate: KeyedMutator<unknown>, patie
81
81
  });
82
82
  }
83
83
 
84
+ /**
85
+ * Invalidates only the current (active) visit cache for a specific patient.
86
+ *
87
+ * This refreshes components using useVisit without triggering the visit revalidation cascade.
88
+ *
89
+ * @param mutate - SWR mutate function from useSWRConfig()
90
+ * @param patientUuid - Patient UUID to target current visit data for
91
+ */
92
+ export function invalidateCurrentVisit(mutate: KeyedMutator<unknown>, patientUuid: string): void {
93
+ mutate((key) => {
94
+ return (
95
+ typeof key === 'string' &&
96
+ key.includes(`${restBaseUrl}/visit?patient=${patientUuid}`) &&
97
+ key.includes('includeInactive=false')
98
+ );
99
+ });
100
+ }
101
+
84
102
  /**
85
103
  * Combination utility that invalidates both visit history and encounter data.
86
104
  *
@@ -30,7 +30,7 @@ export function useOptimisticVisitMutations(patientUuid: string) {
30
30
  (visitUuid: string, updates: Partial<Visit>) => {
31
31
  // Update current visit SWR cache if it matches
32
32
  if (visitContext?.uuid === visitUuid) {
33
- mutateVisitContext();
33
+ mutateVisitContext?.();
34
34
  }
35
35
 
36
36
  // Update visit lists across all hooks using regex pattern matching
@@ -82,7 +82,7 @@ export function useOptimisticVisitMutations(patientUuid: string) {
82
82
 
83
83
  // If deleted visit was current, revalidate current visit to get new state
84
84
  if (visitContext?.uuid === visitUuid) {
85
- mutateVisitContext();
85
+ mutateVisitContext?.();
86
86
  }
87
87
  },
88
88
  [visitContext, mutateVisitContext, mutate, patientUuid],
package/src/workspaces.ts CHANGED
@@ -21,8 +21,10 @@ export interface PatientChartWorkspaceActionButtonProps {
21
21
  groupProps: PatientWorkspaceGroupProps;
22
22
  }
23
23
 
24
- export interface PatientWorkspace2DefinitionProps<WorkspaceProps extends Object, WindowProps extends Object>
25
- extends Workspace2DefinitionProps<WorkspaceProps, WindowProps, PatientWorkspaceGroupProps> {}
24
+ export type PatientWorkspace2DefinitionProps<
25
+ WorkspaceProps extends object,
26
+ WindowProps extends object,
27
+ > = Workspace2DefinitionProps<WorkspaceProps, WindowProps, PatientWorkspaceGroupProps>;
26
28
 
27
29
  export function launchPatientChartWithWorkspaceOpen({
28
30
  patientUuid,
@@ -45,7 +47,7 @@ export function useLaunchWorkspaceRequiringVisit<T extends object>(patientUuid:
45
47
  (workspaceProps?: T, windowProps?: any, groupProps?: any) => {
46
48
  startVisitIfNeeded().then((didStartVisit) => {
47
49
  if (didStartVisit) {
48
- launchWorkspace2(workspaceName, workspaceProps, windowProps);
50
+ launchWorkspace2(workspaceName, workspaceProps, windowProps, groupProps);
49
51
  }
50
52
  });
51
53
  },