diff --git a/__mocks__/index.ts b/__mocks__/index.ts
index c3dc7e9195..96c0f6cb01 100644
--- a/__mocks__/index.ts
+++ b/__mocks__/index.ts
@@ -9,6 +9,8 @@ export * from './immunizations.mock';
export * from './location.mock';
export * from './medication.mock';
export * from './mockDeceasedPatient';
+export * from './order-stock-data.mock';
+export * from './order-price-data.mock';
export * from './patient-flags.mock';
export * from './programs.mock';
export * from './relationships.mock';
diff --git a/__mocks__/order-price-data.mock.ts b/__mocks__/order-price-data.mock.ts
new file mode 100644
index 0000000000..4948dac1d0
--- /dev/null
+++ b/__mocks__/order-price-data.mock.ts
@@ -0,0 +1,40 @@
+import type { OrderPriceData } from '@openmrs/esm-patient-orders-app/src/types/order';
+
+export const mockOrderPriceData: OrderPriceData = {
+ resourceType: 'Bundle',
+ id: 'test-id',
+ meta: {
+ lastUpdated: '2024-01-01T00:00:00Z',
+ },
+ type: 'searchset',
+ link: [
+ {
+ relation: 'self',
+ url: 'test-url',
+ },
+ ],
+ entry: [
+ {
+ resource: {
+ resourceType: 'ChargeItemDefinition',
+ id: 'test-resource-id',
+ name: 'Test Item',
+ status: 'active',
+ date: '2024-01-01',
+ propertyGroup: [
+ {
+ priceComponent: [
+ {
+ type: 'base',
+ amount: {
+ value: 99.99,
+ currency: 'USD',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/__mocks__/order-stock-data.mock.ts b/__mocks__/order-stock-data.mock.ts
new file mode 100644
index 0000000000..e6a43f3857
--- /dev/null
+++ b/__mocks__/order-stock-data.mock.ts
@@ -0,0 +1,46 @@
+export const mockOrderStockData = {
+ resourceType: 'Bundle',
+ id: 'test-id',
+ meta: {
+ lastUpdated: '2024-01-01T00:00:00Z',
+ },
+ type: 'searchset',
+ link: [
+ {
+ relation: 'self',
+ url: 'test-url',
+ },
+ ],
+ entry: [
+ {
+ resource: {
+ resourceType: 'InventoryItem',
+ id: 'test-resource-id',
+ meta: {
+ profile: ['test-profile'],
+ },
+ status: 'active',
+ code: [
+ {
+ coding: [
+ {
+ system: 'test-system',
+ code: 'test-code',
+ display: 'Test Item',
+ },
+ ],
+ },
+ ],
+ name: [
+ {
+ name: 'Test Item',
+ },
+ ],
+ netContent: {
+ value: 10,
+ unit: 'units',
+ },
+ },
+ },
+ ],
+};
diff --git a/packages/esm-patient-medications-app/src/add-drug-order/drug-search/order-basket-search-results.scss b/packages/esm-patient-medications-app/src/add-drug-order/drug-search/order-basket-search-results.scss
index d4a410bbb5..6aa3654baa 100644
--- a/packages/esm-patient-medications-app/src/add-drug-order/drug-search/order-basket-search-results.scss
+++ b/packages/esm-patient-medications-app/src/add-drug-order/drug-search/order-basket-search-results.scss
@@ -27,6 +27,7 @@
flex-direction: column;
justify-content: space-between;
border: 1px solid $grey-2;
+
&:not(:last-of-type) {
margin-bottom: layout.$spacing-03;
}
diff --git a/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.component.tsx b/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.component.tsx
index aa958d2dc1..ba029cb484 100644
--- a/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.component.tsx
+++ b/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.component.tsx
@@ -1,8 +1,8 @@
-import React, { type ComponentProps, useRef } from 'react';
+import React, { useMemo, useRef } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
-import { Button, ClickableTile, Tile } from '@carbon/react';
-import { TrashCanIcon, useLayoutType, WarningIcon } from '@openmrs/esm-framework';
+import { ClickableTile, IconButton, Tile } from '@carbon/react';
+import { ExtensionSlot, TrashCanIcon, useLayoutType, WarningIcon } from '@openmrs/esm-framework';
import { type DrugOrderBasketItem } from '../types';
import styles from './order-basket-item-tile.scss';
@@ -24,70 +24,84 @@ export default function OrderBasketItemTile({ orderBasketItem, onItemClick, onRe
// Hence, we manually prevent the handleClick callback from being invoked as soon as the button is pressed once.
const shouldOnClickBeCalled = useRef(true);
+ const additionalInfoSlotState = useMemo(
+ () => ({
+ orderItemUuid: orderBasketItem.drug.uuid,
+ }),
+ [orderBasketItem],
+ );
+
const tileContent = (
-
-
-
- {orderBasketItem.isFreeTextDosage ? (
-
- {orderBasketItem.drug?.display}
- {orderBasketItem.freeTextDosage && (
- — {orderBasketItem.freeTextDosage}
- )}
-
- ) : (
-
-
{orderBasketItem.drug?.display}
+
+
+
+
+ {orderBasketItem.isFreeTextDosage ? (
+
+ {orderBasketItem.drug?.display}
+ {orderBasketItem.freeTextDosage && (
+ — {orderBasketItem.freeTextDosage}
+ )}
+
+ ) : (
+
+ {orderBasketItem.drug?.display}
+
+ {' '}
+ {orderBasketItem.drug?.strength && <>— {orderBasketItem.drug?.strength}>}{' '}
+ {orderBasketItem.drug?.dosageForm?.display && <>— {orderBasketItem.drug.dosageForm?.display}>}
+
+
+ )}
+
+ {t('dose', 'Dose').toUpperCase()}{' '}
+
+ {orderBasketItem.dosage} {orderBasketItem.unit?.value}
+ {' '}
- {' '}
- {orderBasketItem.drug?.strength && <>— {orderBasketItem.drug?.strength}>}{' '}
- {orderBasketItem.drug?.dosageForm?.display && <>— {orderBasketItem.drug.dosageForm?.display}>}
+ — {orderBasketItem.route?.value ? <>{orderBasketItem.route.value} — > : null}
+ {orderBasketItem.frequency?.value ? <>{orderBasketItem.frequency.value} — > : null}
+ {t('refills', 'Refills').toUpperCase()} {orderBasketItem.numRefills}{' '}
+ {t('quantity', 'Quantity').toUpperCase()}{' '}
+ {`${orderBasketItem.pillsDispensed} ${orderBasketItem.quantityUnits?.value?.toLowerCase() ?? ''}`}
+ {orderBasketItem.patientInstructions && <>— {orderBasketItem.patientInstructions}>}
-
- )}
-
- {t('dose', 'Dose').toUpperCase()}{' '}
-
- {orderBasketItem.dosage} {orderBasketItem.unit?.value}
- {' '}
-
- — {orderBasketItem.route?.value ? <>{orderBasketItem.route.value} — > : null}
- {orderBasketItem.frequency?.value ? <>{orderBasketItem.frequency.value} — > : null}
- {t('refills', 'Refills').toUpperCase()} {orderBasketItem.numRefills}{' '}
- {t('quantity', 'Quantity').toUpperCase()}{' '}
- {`${orderBasketItem.pillsDispensed} ${orderBasketItem.quantityUnits?.value?.toLowerCase() ?? ''}`}
- {orderBasketItem.patientInstructions && <>— {orderBasketItem.patientInstructions}>}
-
-
-
- {t('indication', 'Indication').toUpperCase()}{' '}
-
- {!!orderBasketItem.indication ? orderBasketItem.indication : {t('none', 'None')}}
+
+
+ {t('indication', 'Indication').toUpperCase()}{' '}
+
+ {!!orderBasketItem.indication ? orderBasketItem.indication : {t('none', 'None')}}
+
+ {!!orderBasketItem.orderError && (
+ <>
+
+
+ {' '}
+ {t('error', 'Error').toUpperCase()}
+ {orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
+
+ >
+ )}
- {!!orderBasketItem.orderError && (
- <>
-
-
- {' '}
- {t('error', 'Error').toUpperCase()}
- {orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
-
- >
- )}
-
+
+
{
+ shouldOnClickBeCalled.current = false;
+ onRemoveClick();
+ }}
+ >
+
+
-
);
diff --git a/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.scss b/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.scss
index 61fd91f76b..4f234bce28 100644
--- a/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.scss
+++ b/packages/esm-patient-medications-app/src/drug-order-basket-panel/order-basket-item-tile.scss
@@ -83,9 +83,7 @@
}
.removeButton {
- svg {
- fill: $danger;
- }
+ fill: $danger;
}
.label01 {
@@ -99,3 +97,16 @@
overflow: hidden;
white-space: nowrap;
}
+
+.additionalInfoContainer {
+ padding: layout.$spacing-05 0;
+ display: flex;
+ flex-flow: row;
+ justify-content: flex-start;
+ align-items: center;
+ gap: layout.$spacing-05;
+}
+
+.additionalInfoContainer:not(:has(div:not(:empty))) {
+ padding: 0;
+}
diff --git a/packages/esm-patient-orders-app/src/components/order-price-details.component.tsx b/packages/esm-patient-orders-app/src/components/order-price-details.component.tsx
new file mode 100644
index 0000000000..5b90a3508c
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-price-details.component.tsx
@@ -0,0 +1,75 @@
+import React, { useMemo } from 'react';
+import { useOrderPrice } from '../hooks/useOrderPrice';
+import styles from './order-price-details.scss';
+import { SkeletonText, Tooltip } from '@carbon/react';
+import { useTranslation } from 'react-i18next';
+import { getLocale, InformationIcon } from '@openmrs/esm-framework';
+
+interface OrderPriceDetailsComponentProps {
+ orderItemUuid: string;
+}
+
+const OrderPriceDetailsComponent: React.FC
= ({ orderItemUuid }) => {
+ const { t } = useTranslation();
+ const locale = getLocale();
+ const { data: priceData, isLoading, error } = useOrderPrice(orderItemUuid);
+
+ const amount = useMemo(() => {
+ if (!priceData || priceData.entry.length === 0) {
+ return null;
+ }
+ return priceData.entry[0].resource.propertyGroup[0]?.priceComponent[0]?.amount;
+ }, [priceData]);
+
+ const formattedPrice = useMemo((): string => {
+ if (!amount) return '';
+ try {
+ new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: amount.currency,
+ });
+
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: amount.currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount.value);
+ } catch (error) {
+ console.error(`Invalid currency code: ${amount.currency}. Error: ${error.message}`);
+ return `${new Intl.NumberFormat(locale, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount.value)} ${amount.currency}`;
+ }
+ }, [locale, amount]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!priceData || !amount || error) {
+ return null;
+ }
+
+ return (
+
+ {t('price', 'Price')}:
+ {formattedPrice}
+
+
+
+
+ );
+};
+
+export default OrderPriceDetailsComponent;
diff --git a/packages/esm-patient-orders-app/src/components/order-price-details.scss b/packages/esm-patient-orders-app/src/components/order-price-details.scss
new file mode 100644
index 0000000000..5d366938cc
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-price-details.scss
@@ -0,0 +1,28 @@
+@use '@carbon/type';
+@use '@carbon/layout';
+
+.priceDetailsContainer {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+
+.priceLabel {
+ @include type.type-style('heading-compact-01');
+ padding-inline-end: layout.$spacing-02;
+}
+
+.priceToolTip {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.priceToolTipTrigger {
+ display: flex;
+ padding: 0 0 0 layout.$spacing-02;
+ border: none;
+ outline: none;
+}
diff --git a/packages/esm-patient-orders-app/src/components/order-price-details.test.tsx b/packages/esm-patient-orders-app/src/components/order-price-details.test.tsx
new file mode 100644
index 0000000000..228dfa510c
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-price-details.test.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import OrderPriceDetailsComponent from './order-price-details.component';
+import { useOrderPrice } from '../hooks/useOrderPrice';
+import { renderWithSwr } from 'tools';
+import { mockOrderPriceData } from '__mocks__';
+import { getLocale } from '@openmrs/esm-framework';
+
+const mockGetLocale = jest.mocked(getLocale);
+const mockUseOrderPrice = jest.mocked(useOrderPrice);
+
+jest.mock('../hooks/useOrderPrice', () => ({
+ useOrderPrice: jest.fn(),
+}));
+
+describe('OrderPriceDetailsComponent', () => {
+ const mockOrderItemUuid = 'test-uuid';
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ mockGetLocale.mockReturnValue('en-US');
+ });
+
+ it('renders loading skeleton when data is loading', () => {
+ mockUseOrderPrice.mockReturnValue({
+ data: null,
+ isLoading: true,
+ error: null,
+ });
+
+ renderWithSwr();
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('renders nothing when amount is null', () => {
+ mockUseOrderPrice.mockReturnValue({
+ data: {
+ ...mockOrderPriceData,
+ entry: [],
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ const { container } = renderWithSwr();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders price correctly with USD currency', () => {
+ mockUseOrderPrice.mockReturnValue({
+ data: mockOrderPriceData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText('Price:')).toBeInTheDocument();
+ expect(screen.getByText('$99.99')).toBeInTheDocument();
+ });
+
+ it('formats price correctly for different locales', () => {
+ mockUseOrderPrice.mockReturnValue({
+ data: mockOrderPriceData,
+ isLoading: false,
+ error: null,
+ });
+
+ // Change to German locale for this test
+ mockGetLocale.mockReturnValue('de-DE');
+
+ renderWithSwr();
+
+ expect(screen.getByText('99,99 $')).toBeInTheDocument();
+ });
+
+ it('handles invalid currency codes gracefully', () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
+
+ mockUseOrderPrice.mockReturnValue({
+ data: {
+ ...mockOrderPriceData,
+ entry: [
+ {
+ resource: {
+ ...mockOrderPriceData.entry[0].resource,
+ propertyGroup: [
+ {
+ priceComponent: [
+ {
+ type: 'base',
+ amount: { value: 99.99, currency: 'INVALID' },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText('Price:')).toBeInTheDocument();
+ expect(screen.getByText('99.99 INVALID')).toBeInTheDocument();
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid currency code: INVALID'));
+
+ consoleSpy.mockRestore();
+ });
+
+ it('displays tooltip with price disclaimer', () => {
+ mockUseOrderPrice.mockReturnValue({
+ data: mockOrderPriceData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ expect(screen.getByLabelText(/This price is indicative/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-patient-orders-app/src/components/order-stock-details.component.tsx b/packages/esm-patient-orders-app/src/components/order-stock-details.component.tsx
new file mode 100644
index 0000000000..83a7a50a35
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-stock-details.component.tsx
@@ -0,0 +1,49 @@
+import React, { useMemo } from 'react';
+import { CheckmarkFilledIcon, CloseFilledIcon } from '@openmrs/esm-framework';
+import { useOrderStockInfo } from '../hooks/useOrderStockInfo';
+import styles from './order-stock-details.scss';
+import { SkeletonText } from '@carbon/react';
+import { useTranslation } from 'react-i18next';
+
+interface OrderStockDetailsComponentProps {
+ orderItemUuid: string;
+}
+
+const OrderStockDetailsComponent: React.FC = ({ orderItemUuid }) => {
+ const { t } = useTranslation();
+ const { data: stockData, isLoading, error } = useOrderStockInfo(orderItemUuid);
+
+ const isInStock = useMemo(() => {
+ if (!stockData?.entry?.length) {
+ return false;
+ }
+ const resource = stockData.entry[0]?.resource;
+ return (
+ resource?.status === 'active' && typeof resource?.netContent?.value === 'number' && resource.netContent.value > 0
+ );
+ }, [stockData]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!stockData || error) {
+ return null;
+ }
+
+ return (
+
+ {isInStock ? (
+
+ {t('inStock', 'In stock')}
+
+ ) : (
+
+ {t('outOfStock', 'Out of stock')}
+
+ )}
+
+ );
+};
+
+export default OrderStockDetailsComponent;
diff --git a/packages/esm-patient-orders-app/src/components/order-stock-details.scss b/packages/esm-patient-orders-app/src/components/order-stock-details.scss
new file mode 100644
index 0000000000..140dbe70e5
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-stock-details.scss
@@ -0,0 +1,28 @@
+@use '@carbon/layout';
+@use '@openmrs/esm-styleguide/src/vars' as *;
+
+.itemInStock {
+ color: $support-02;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ .itemInStockIcon {
+ fill: $support-02;
+ margin-inline-end: layout.$spacing-02;
+ }
+}
+
+.itemOutOfStock {
+ color: $danger;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ .itemOutOfStockIcon {
+ fill: $danger;
+ margin-inline-end: layout.$spacing-02;
+ }
+}
diff --git a/packages/esm-patient-orders-app/src/components/order-stock-details.test.tsx b/packages/esm-patient-orders-app/src/components/order-stock-details.test.tsx
new file mode 100644
index 0000000000..136aa9957e
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/components/order-stock-details.test.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import OrderStockDetailsComponent from './order-stock-details.component';
+import { useOrderStockInfo } from '../hooks/useOrderStockInfo';
+import { renderWithSwr } from 'tools';
+import { useTranslation } from 'react-i18next';
+import { mockOrderStockData } from '__mocks__';
+
+const mockUseOrderStockInfo = jest.mocked(useOrderStockInfo);
+
+jest.mock('../hooks/useOrderStockInfo', () => ({
+ useOrderStockInfo: jest.fn(),
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: jest.fn(),
+}));
+
+const mockUseTranslation = useTranslation as jest.Mock;
+
+describe('OrderStockDetailsComponent', () => {
+ const mockOrderItemUuid = 'test-uuid';
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ mockUseTranslation.mockImplementation(() => ({
+ t: (key: string, fallback: string) => fallback,
+ }));
+ });
+
+ it('renders loading skeleton when data is loading', () => {
+ mockUseOrderStockInfo.mockReturnValue({
+ data: null,
+ isLoading: true,
+ error: null,
+ });
+
+ renderWithSwr();
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('renders nothing when stock data is null', () => {
+ mockUseOrderStockInfo.mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: null,
+ });
+
+ const { container } = renderWithSwr();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders "In Stock" when item is active and has positive quantity', () => {
+ mockUseOrderStockInfo.mockReturnValue({
+ data: mockOrderStockData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText(/In stock/i)).toBeInTheDocument();
+ expect(screen.getByText('CheckmarkFilledIcon')).toBeInTheDocument();
+ });
+
+ it('renders "Out of Stock" when item has zero quantity', () => {
+ const outOfStockData = {
+ ...mockOrderStockData,
+ entry: [
+ {
+ ...mockOrderStockData.entry[0],
+ resource: {
+ ...mockOrderStockData.entry[0].resource,
+ netContent: {
+ value: 0,
+ unit: 'units',
+ },
+ },
+ },
+ ],
+ };
+
+ mockUseOrderStockInfo.mockReturnValue({
+ data: outOfStockData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText(/Out of stock/i)).toBeInTheDocument();
+ expect(screen.getByText('CloseFilledIcon')).toBeInTheDocument();
+ });
+
+ it('renders "Out of Stock" when item is inactive', () => {
+ const inactiveData = {
+ ...mockOrderStockData,
+ entry: [
+ {
+ ...mockOrderStockData.entry[0],
+ resource: {
+ ...mockOrderStockData.entry[0].resource,
+ status: 'inactive',
+ },
+ },
+ ],
+ };
+
+ mockUseOrderStockInfo.mockReturnValue({
+ data: inactiveData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText(/Out of stock/i)).toBeInTheDocument();
+ expect(screen.getByText('CloseFilledIcon')).toBeInTheDocument();
+ });
+
+ it('renders "Out of Stock" when entry array is empty', () => {
+ const emptyData = {
+ ...mockOrderStockData,
+ entry: [],
+ };
+
+ mockUseOrderStockInfo.mockReturnValue({
+ data: emptyData,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithSwr();
+
+ expect(screen.getByText(/Out of stock/i)).toBeInTheDocument();
+ expect(screen.getByText('CloseFilledIcon')).toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-patient-orders-app/src/hooks/useAreBackendModuleInstalled.tsx b/packages/esm-patient-orders-app/src/hooks/useAreBackendModuleInstalled.tsx
new file mode 100644
index 0000000000..6db3558476
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/hooks/useAreBackendModuleInstalled.tsx
@@ -0,0 +1,27 @@
+import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
+import useSWR from 'swr';
+import { useMemo } from 'react';
+
+interface ModuleData {
+ results: Array<{ uuid: string }>;
+}
+
+export const useAreBackendModuleInstalled = (modules: string | string[]) => {
+ const { data, isLoading, error } = useSWR, Error>(
+ `${restBaseUrl}/module?v=custom:(uuid)`,
+ openmrsFetch,
+ );
+
+ return useMemo(() => {
+ const installedModules = data?.data?.results?.map((module) => module.uuid) ?? [];
+ const modulesToCheck = Array.isArray(modules) ? modules : [modules];
+
+ const areModulesInstalled = modulesToCheck.every((module) => installedModules.includes(module));
+
+ return {
+ areModulesInstalled,
+ isCheckingModules: isLoading,
+ moduleCheckError: error,
+ };
+ }, [data, isLoading, error, modules]);
+};
diff --git a/packages/esm-patient-orders-app/src/hooks/useOrderPrice.test.ts b/packages/esm-patient-orders-app/src/hooks/useOrderPrice.test.ts
new file mode 100644
index 0000000000..cd39991378
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/hooks/useOrderPrice.test.ts
@@ -0,0 +1,100 @@
+import { act, renderHook } from '@testing-library/react';
+import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework';
+import { type OrderPriceData } from '../types/order';
+import { useOrderPrice } from './useOrderPrice';
+import { mockOrderPriceData } from '__mocks__';
+
+const mockedOpenmrsFetch = jest.mocked(openmrsFetch);
+
+describe('useOrderPrice', () => {
+ beforeEach(() => {
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ { uuid: 'billing', display: 'Billing Module' },
+ ],
+ },
+ } as FetchResponse);
+ }
+ return Promise.resolve({ data: null } as FetchResponse);
+ });
+ });
+
+ it('returns null data when orderItemUuid is not provided', () => {
+ const { result } = renderHook(() => useOrderPrice(''));
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.isLoading).toBeFalsy();
+ });
+
+ it('fetches and returns price data when required modules are installed', async () => {
+ const mockPricePromise = Promise.resolve({
+ data: mockOrderPriceData,
+ } as FetchResponse);
+
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ { uuid: 'billing', display: 'Billing Module' },
+ ],
+ },
+ } as FetchResponse);
+ }
+ return mockPricePromise;
+ });
+
+ const { result } = renderHook(() => useOrderPrice('test-uuid'));
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.isLoading).toBeTruthy();
+
+ await act(async () => {
+ await mockPricePromise;
+ });
+
+ expect(result.current.data).toEqual(mockOrderPriceData);
+ expect(result.current.isLoading).toBeFalsy();
+ });
+
+ it('does not fetch price data when required modules are not installed', async () => {
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ // billing module missing
+ ],
+ },
+ } as FetchResponse);
+ }
+ return Promise.resolve({ data: null } as FetchResponse);
+ });
+
+ const { result } = renderHook(() => useOrderPrice('test-uuid-2'));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.data).toBeNull();
+ });
+
+ it('handles module check error gracefully', async () => {
+ mockedOpenmrsFetch.mockRejectedValueOnce(new Error('Failed to fetch modules'));
+
+ const { result } = renderHook(() => useOrderPrice('test-uuid-2'));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.data).toBeNull();
+ });
+});
diff --git a/packages/esm-patient-orders-app/src/hooks/useOrderPrice.ts b/packages/esm-patient-orders-app/src/hooks/useOrderPrice.ts
new file mode 100644
index 0000000000..6de2827728
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/hooks/useOrderPrice.ts
@@ -0,0 +1,25 @@
+import { type OrderPriceData } from '../types/order';
+import { type FetchResponse, fhirBaseUrl, openmrsFetch } from '@openmrs/esm-framework';
+import useSWR from 'swr';
+import { useMemo } from 'react';
+import { useAreBackendModuleInstalled } from './useAreBackendModuleInstalled';
+
+export const useOrderPrice = (orderItemUuid: string) => {
+ const { areModulesInstalled, isCheckingModules } = useAreBackendModuleInstalled(['fhirproxy', 'billing']);
+
+ const { data, isLoading, error } = useSWR>(
+ orderItemUuid && areModulesInstalled && !isCheckingModules
+ ? `${fhirBaseUrl}/ChargeItemDefinition?code=${orderItemUuid}`
+ : null,
+ openmrsFetch,
+ );
+
+ return useMemo(
+ () => ({
+ data: data?.data ?? null,
+ isLoading,
+ error,
+ }),
+ [data, isLoading, error],
+ );
+};
diff --git a/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.test.ts b/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.test.ts
new file mode 100644
index 0000000000..234991ed60
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.test.ts
@@ -0,0 +1,100 @@
+import { act, renderHook } from '@testing-library/react';
+import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework';
+import { type OrderStockData } from '../types/order';
+import { useOrderStockInfo } from './useOrderStockInfo';
+import { mockOrderStockData } from '../../../../__mocks__/order-stock-data.mock';
+
+const mockedOpenmrsFetch = jest.mocked(openmrsFetch);
+
+describe('useOrderStockInfo', () => {
+ beforeEach(() => {
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ { uuid: 'stockmanagement', display: 'Stock Management' },
+ ],
+ },
+ } as FetchResponse);
+ }
+ return Promise.resolve({ data: null } as FetchResponse);
+ });
+ });
+
+ it('returns null data when orderItemUuid is not provided', () => {
+ const { result } = renderHook(() => useOrderStockInfo(''));
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.isLoading).toBeFalsy();
+ });
+
+ it('fetches and returns stock data when required modules are installed', async () => {
+ const mockStockPromise = Promise.resolve({
+ data: mockOrderStockData,
+ } as FetchResponse);
+
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ { uuid: 'stockmanagement', display: 'Stock Management' },
+ ],
+ },
+ } as FetchResponse);
+ }
+ return mockStockPromise;
+ });
+
+ const { result } = renderHook(() => useOrderStockInfo('test-uuid'));
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.isLoading).toBeTruthy();
+
+ await act(async () => {
+ await mockStockPromise;
+ });
+
+ expect(result.current.data).toEqual(mockOrderStockData);
+ expect(result.current.isLoading).toBeFalsy();
+ });
+
+ it('does not fetch stock data when required modules are not installed', async () => {
+ mockedOpenmrsFetch.mockImplementation((url) => {
+ if (url.includes('/ws/rest/v1/module')) {
+ return Promise.resolve({
+ data: {
+ results: [
+ { uuid: 'fhirproxy', display: 'FHIR Proxy' },
+ // stockmanagement module missing
+ ],
+ },
+ } as FetchResponse);
+ }
+ return Promise.resolve({ data: null } as FetchResponse);
+ });
+
+ const { result } = renderHook(() => useOrderStockInfo('test-uuid-2'));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.data).toBeNull();
+ });
+
+ it('handles module check error gracefully', async () => {
+ mockedOpenmrsFetch.mockRejectedValueOnce(new Error('Failed to fetch modules'));
+
+ const { result } = renderHook(() => useOrderStockInfo('test-uuid-2'));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.data).toBeNull();
+ });
+});
diff --git a/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.ts b/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.ts
new file mode 100644
index 0000000000..54a6828bf7
--- /dev/null
+++ b/packages/esm-patient-orders-app/src/hooks/useOrderStockInfo.ts
@@ -0,0 +1,25 @@
+import useSWR from 'swr';
+import { type FetchResponse, fhirBaseUrl, openmrsFetch } from '@openmrs/esm-framework';
+import { useMemo } from 'react';
+import { type OrderStockData } from '../types/order';
+import { useAreBackendModuleInstalled } from './useAreBackendModuleInstalled';
+
+export const useOrderStockInfo = (orderItemUuid: string) => {
+ const { areModulesInstalled, isCheckingModules } = useAreBackendModuleInstalled(['fhirproxy', 'stockmanagement']);
+
+ const { data, isLoading, error } = useSWR>(
+ orderItemUuid && areModulesInstalled && !isCheckingModules
+ ? `${fhirBaseUrl}/InventoryItem?code=${orderItemUuid}`
+ : null,
+ openmrsFetch,
+ );
+
+ return useMemo(
+ () => ({
+ data: data?.data ?? null,
+ isLoading,
+ error,
+ }),
+ [data, isLoading, error],
+ );
+};
diff --git a/packages/esm-patient-orders-app/src/index.ts b/packages/esm-patient-orders-app/src/index.ts
index c28d16d4cf..b2abee2a57 100644
--- a/packages/esm-patient-orders-app/src/index.ts
+++ b/packages/esm-patient-orders-app/src/index.ts
@@ -1,4 +1,4 @@
-import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle, translateFrom } from '@openmrs/esm-framework';
+import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
import { createDashboardLink } from '@openmrs/esm-patient-common-lib';
import { configSchema } from './config-schema';
import orderBasketActionMenuComponent from './order-basket-action-button/order-basket-action-button.extension';
@@ -35,6 +35,15 @@ export const testResultsFormWorkspace = getAsyncLifecycle(
export const orderBasketActionMenu = getSyncLifecycle(orderBasketActionMenuComponent, options);
+export const orderPriceDetailsExtension = getAsyncLifecycle(
+ () => import('./components/order-price-details.component'),
+ options,
+);
+export const orderStockDetailsExtension = getAsyncLifecycle(
+ () => import('./components/order-stock-details.component'),
+ options,
+);
+
export const ordersDashboardLink =
// t('Orders', 'Orders')
getSyncLifecycle(
diff --git a/packages/esm-patient-orders-app/src/routes.json b/packages/esm-patient-orders-app/src/routes.json
index 6e29ba9674..8e632703fe 100644
--- a/packages/esm-patient-orders-app/src/routes.json
+++ b/packages/esm-patient-orders-app/src/routes.json
@@ -4,6 +4,18 @@
"webservices.rest": "^2.2.0"
},
"extensions": [
+ {
+ "name": "order-price-details",
+ "component": "orderPriceDetailsExtension",
+ "slot": "order-item-additional-info-slot",
+ "order": 0
+ },
+ {
+ "name": "order-stock-details",
+ "component": "orderStockDetailsExtension",
+ "slot": "order-item-additional-info-slot",
+ "order": 1
+ },
{
"name": "order-basket-action-menu",
"component": "orderBasketActionMenu",
diff --git a/packages/esm-patient-orders-app/src/types/order.ts b/packages/esm-patient-orders-app/src/types/order.ts
index 5de9f4f822..f0de855598 100644
--- a/packages/esm-patient-orders-app/src/types/order.ts
+++ b/packages/esm-patient-orders-app/src/types/order.ts
@@ -14,3 +14,71 @@ export interface OrderDiscontinuationPayload {
concept: string;
orderer: { display: string; person: { display: string }; uuid: string };
}
+
+export interface OrderPriceData {
+ resourceType: string;
+ id: string;
+ meta: {
+ lastUpdated: string;
+ };
+ type: string;
+ link: {
+ relation: string;
+ url: string;
+ }[];
+ entry: {
+ resource: {
+ resourceType: string;
+ id: string;
+ name: string;
+ status: string;
+ date: string;
+ propertyGroup: {
+ priceComponent: {
+ type: string;
+ amount: {
+ value: number;
+ currency: string;
+ };
+ }[];
+ }[];
+ };
+ }[];
+}
+
+export interface OrderStockData {
+ resourceType: string;
+ id: string;
+ meta: {
+ lastUpdated: string;
+ };
+ type: string;
+ link: {
+ relation: string;
+ url: string;
+ }[];
+ entry: {
+ resource: {
+ resourceType: string;
+ id: string;
+ meta: {
+ profile: string[];
+ };
+ status: string;
+ code: {
+ coding: {
+ system: string;
+ code: string;
+ display: string;
+ }[];
+ }[];
+ name: {
+ name: string;
+ }[];
+ netContent: {
+ value: number;
+ unit: string;
+ };
+ };
+ }[];
+}
diff --git a/packages/esm-patient-orders-app/translations/en.json b/packages/esm-patient-orders-app/translations/en.json
index 4e3f28edb0..7699f55597 100644
--- a/packages/esm-patient-orders-app/translations/en.json
+++ b/packages/esm-patient-orders-app/translations/en.json
@@ -23,6 +23,7 @@
"errorCancellingOrder": "Error cancelling order",
"errorSavingLabResults": "Error saving lab results",
"indication": "Indication",
+ "inStock": "In stock",
"launchOrderBasket": "Launch order basket",
"loading": "Loading",
"loadingInitialValues": "Loading initial values",
@@ -48,8 +49,11 @@
"orders": "Orders",
"Orders": "Orders",
"orderType": "Order type",
+ "outOfStock": "Out of stock",
"pleaseFillField": "Please fill at least one field",
"pleaseFillRequiredFields": "Please fill all the required fields",
+ "price": "Price",
+ "priceDisclaimer": "This price is indicative and may not reflect final costs, which could vary due to discounts, insurance coverage, or other pricing rules",
"print": "Print",
"printedBy": "Printed by",
"printOrder": "Print order",
diff --git a/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.component.tsx b/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.component.tsx
index 867822b52c..bbd02f259a 100644
--- a/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.component.tsx
+++ b/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.component.tsx
@@ -6,11 +6,11 @@ import { ShoppingCartArrowUp } from '@carbon/react/icons';
import {
ArrowRightIcon,
closeWorkspace,
+ ResponsiveWrapper,
ShoppingCartArrowDownIcon,
useDebounce,
useLayoutType,
useSession,
- ResponsiveWrapper,
} from '@openmrs/esm-framework';
import { type LabOrderBasketItem, launchPatientWorkspace, useOrderBasket } from '@openmrs/esm-patient-common-lib';
import { type TestType, useTestTypes } from './useTestTypes';
diff --git a/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.scss b/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.scss
index 358d5c8998..17d6ec4434 100644
--- a/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.scss
+++ b/packages/esm-patient-tests-app/src/lab-orders/add-lab-order/test-type-search.scss
@@ -64,6 +64,7 @@
flex-direction: column;
justify-content: space-between;
border: 1px solid $grey-2;
+
&:not(:last-of-type) {
margin-bottom: layout.$spacing-03;
}
diff --git a/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.component.tsx b/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.component.tsx
index bb0e9d2e89..c65055f319 100644
--- a/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.component.tsx
+++ b/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.component.tsx
@@ -1,8 +1,8 @@
-import React, { type ComponentProps, useRef } from 'react';
+import React, { useMemo, useRef } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
-import { Button, ClickableTile, Tile } from '@carbon/react';
-import { TrashCanIcon, useLayoutType, WarningIcon } from '@openmrs/esm-framework';
+import { ClickableTile, IconButton, Tile } from '@carbon/react';
+import { ExtensionSlot, TrashCanIcon, useLayoutType, WarningIcon } from '@openmrs/esm-framework';
import { type LabOrderBasketItem } from '@openmrs/esm-patient-common-lib';
import styles from './lab-order-basket-item-tile.scss';
@@ -24,37 +24,51 @@ export function LabOrderBasketItemTile({ orderBasketItem, onItemClick, onRemoveC
// Hence, we manually prevent the handleClick callback from being invoked as soon as the button is pressed once.
const shouldOnClickBeCalled = useRef(true);
+ const additionalInfoSlotState = useMemo(
+ () => ({
+ orderItemUuid: orderBasketItem.testType.conceptUuid,
+ }),
+ [orderBasketItem],
+ );
+
const labTile = (
-
-
-
-
-
{orderBasketItem.testType?.label}
-
- {!!orderBasketItem.orderError && (
- <>
-
-
-
-
- {t('error', 'Error').toUpperCase()}
- {orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
-
- >
- )}
-
+
+
+
+
+
+ {orderBasketItem.testType?.label}
+
+ {!!orderBasketItem.orderError && (
+ <>
+
+
+
+
+ {t('error', 'Error').toUpperCase()}
+ {orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
+
+ >
+ )}
+
+
+
{
+ shouldOnClickBeCalled.current = false;
+ onRemoveClick();
+ }}
+ align="left"
+ >
+
+
-
);
diff --git a/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.scss b/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.scss
index 2fc8dde661..dad71d0444 100644
--- a/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.scss
+++ b/packages/esm-patient-tests-app/src/lab-orders/lab-order-basket-panel/lab-order-basket-item-tile.scss
@@ -82,3 +82,15 @@
overflow: hidden;
white-space: nowrap;
}
+
+.additionalInfoContainer {
+ padding: layout.$spacing-05 0;
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ gap: layout.$spacing-05;
+}
+
+.additionalInfoContainer:not(:has(div:not(:empty))) {
+ padding: 0;
+}
diff --git a/packages/esm-patient-tests-app/src/routes.json b/packages/esm-patient-tests-app/src/routes.json
index 2fcc6ca502..d9ccb3e3ab 100644
--- a/packages/esm-patient-tests-app/src/routes.json
+++ b/packages/esm-patient-tests-app/src/routes.json
@@ -23,7 +23,10 @@
},
{
"name": "results-viewer",
- "slots": ["patient-chart-results-viewer-slot", "patient-chart-test-results-dashboard-slot"],
+ "slots": [
+ "patient-chart-results-viewer-slot",
+ "patient-chart-test-results-dashboard-slot"
+ ],
"component": "resultsViewer"
},
{