diff --git a/__mocks__/index.ts b/__mocks__/index.ts index e5b128935a..0f2cebfba3 100644 --- a/__mocks__/index.ts +++ b/__mocks__/index.ts @@ -10,6 +10,8 @@ export * from './location.mock'; export * from './medication.mock'; export * from './mockBasePanel.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..007a48b8d2 100644 --- a/packages/esm-patient-orders-app/src/routes.json +++ b/packages/esm-patient-orders-app/src/routes.json @@ -3,7 +3,30 @@ "backendDependencies": { "webservices.rest": "^2.2.0" }, + "optionalBackendDependencies": { + "fhirproxy": { + "version": "1.0.0-SNAPSHOT" + }, + "stockmanagement": { + "version": "2.0.2-SNAPSHOT" + }, + "billing": { + "version": "1.2.0-SNAPSHOT" + } + }, "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" }, {