Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-4009: Display stock and price information upon ordering #2028

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __mocks__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
40 changes: 40 additions & 0 deletions __mocks__/order-price-data.mock.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
],
},
],
},
},
],
};
46 changes: 46 additions & 0 deletions __mocks__/order-stock-data.mock.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
},
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = (
<div className={styles.orderBasketItemTile}>
<div className={styles.clipTextWithEllipsis}>
<OrderActionLabel orderBasketItem={orderBasketItem} />
{orderBasketItem.isFreeTextDosage ? (
<div>
<span className={styles.drugName}>{orderBasketItem.drug?.display}</span>
{orderBasketItem.freeTextDosage && (
<span className={styles.dosageInfo}> &mdash; {orderBasketItem.freeTextDosage}</span>
)}
</div>
) : (
<div>
<span className={styles.drugName}>{orderBasketItem.drug?.display}</span>
<div>
<div className={styles.orderBasketItemTile}>
<div className={styles.clipTextWithEllipsis}>
<OrderActionLabel orderBasketItem={orderBasketItem} />
{orderBasketItem.isFreeTextDosage ? (
<div>
<span className={styles.drugName}>{orderBasketItem.drug?.display}</span>
{orderBasketItem.freeTextDosage && (
<span className={styles.dosageInfo}> &mdash; {orderBasketItem.freeTextDosage}</span>
)}
</div>
) : (
<div>
<span className={styles.drugName}>{orderBasketItem.drug?.display}</span>
<span className={styles.dosageInfo}>
{' '}
{orderBasketItem.drug?.strength && <>&mdash; {orderBasketItem.drug?.strength}</>}{' '}
{orderBasketItem.drug?.dosageForm?.display && <>&mdash; {orderBasketItem.drug.dosageForm?.display}</>}
</span>
</div>
)}
<span className={styles.label01}>
<span className={styles.doseCaption}>{t('dose', 'Dose').toUpperCase()}</span>{' '}
<span className={styles.dosageLabel}>
{orderBasketItem.dosage} {orderBasketItem.unit?.value}
</span>{' '}
<span className={styles.dosageInfo}>
{' '}
{orderBasketItem.drug?.strength && <>&mdash; {orderBasketItem.drug?.strength}</>}{' '}
{orderBasketItem.drug?.dosageForm?.display && <>&mdash; {orderBasketItem.drug.dosageForm?.display}</>}
&mdash; {orderBasketItem.route?.value ? <>{orderBasketItem.route.value} &mdash; </> : null}
{orderBasketItem.frequency?.value ? <>{orderBasketItem.frequency.value} &mdash; </> : null}
{t('refills', 'Refills').toUpperCase()} {orderBasketItem.numRefills}{' '}
{t('quantity', 'Quantity').toUpperCase()}{' '}
{`${orderBasketItem.pillsDispensed} ${orderBasketItem.quantityUnits?.value?.toLowerCase() ?? ''}`}
{orderBasketItem.patientInstructions && <>&mdash; {orderBasketItem.patientInstructions}</>}
</span>
</div>
)}
<span className={styles.label01}>
<span className={styles.doseCaption}>{t('dose', 'Dose').toUpperCase()}</span>{' '}
<span className={styles.dosageLabel}>
{orderBasketItem.dosage} {orderBasketItem.unit?.value}
</span>{' '}
<span className={styles.dosageInfo}>
&mdash; {orderBasketItem.route?.value ? <>{orderBasketItem.route.value} &mdash; </> : null}
{orderBasketItem.frequency?.value ? <>{orderBasketItem.frequency.value} &mdash; </> : null}
{t('refills', 'Refills').toUpperCase()} {orderBasketItem.numRefills}{' '}
{t('quantity', 'Quantity').toUpperCase()}{' '}
{`${orderBasketItem.pillsDispensed} ${orderBasketItem.quantityUnits?.value?.toLowerCase() ?? ''}`}
{orderBasketItem.patientInstructions && <>&mdash; {orderBasketItem.patientInstructions}</>}
</span>
</span>
<br />
<span className={styles.label01}>
<span className={styles.indicationLabel}>{t('indication', 'Indication').toUpperCase()}</span>{' '}
<span className={styles.dosageInfo}>
{!!orderBasketItem.indication ? orderBasketItem.indication : <i>{t('none', 'None')}</i>}
<br />
<span className={styles.label01}>
<span className={styles.indicationLabel}>{t('indication', 'Indication').toUpperCase()}</span>{' '}
<span className={styles.dosageInfo}>
{!!orderBasketItem.indication ? orderBasketItem.indication : <i>{t('none', 'None')}</i>}
</span>
{!!orderBasketItem.orderError && (
<>
<br />
<span className={styles.orderErrorText}>
<WarningIcon size={16} /> &nbsp;{' '}
<span className={styles.label01}>{t('error', 'Error').toUpperCase()}</span> &nbsp;
{orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
</span>
</>
)}
</span>
{!!orderBasketItem.orderError && (
<>
<br />
<span className={styles.orderErrorText}>
<WarningIcon size={16} /> &nbsp;{' '}
<span className={styles.label01}>{t('error', 'Error').toUpperCase()}</span> &nbsp;
{orderBasketItem.orderError.responseBody?.error?.message ?? orderBasketItem.orderError.message}
</span>
</>
)}
</span>
</div>
<IconButton
kind="ghost"
align="left"
size={isTablet ? 'lg' : 'sm'}
label={t('removeFromBasket', 'Remove from basket')}
onClick={() => {
shouldOnClickBeCalled.current = false;
onRemoveClick();
}}
>
<TrashCanIcon size={16} className={styles.removeButton} />
</IconButton>
</div>
<Button
className={styles.removeButton}
kind="ghost"
hasIconOnly={true}
renderIcon={(props: ComponentProps<typeof TrashCanIcon>) => <TrashCanIcon size={16} {...props} />}
iconDescription={t('removeFromBasket', 'Remove from basket')}
onClick={() => {
shouldOnClickBeCalled.current = false;
onRemoveClick();
}}
tooltipPosition="left"
<ExtensionSlot
name="order-item-additional-info-slot"
state={additionalInfoSlotState}
className={styles.additionalInfoContainer}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@
}

.removeButton {
svg {
fill: $danger;
}
fill: $danger;
}

.label01 {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<OrderPriceDetailsComponentProps> = ({ 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 <SkeletonText width="100px" role="progressbar" />;
}

if (!priceData || !amount || error) {
return null;
}

return (
<div className={styles.priceDetailsContainer}>
<span className={styles.priceLabel}>{t('price', 'Price')}:</span>
{formattedPrice}
<Tooltip
align="bottom-left"
className={styles.priceToolTip}
label={t(
'priceDisclaimer',
'This price is indicative and may not reflect final costs, which could vary due to discounts, insurance coverage, or other pricing rules',
)}
>
<button className={styles.priceToolTipTrigger} type="button">
<InformationIcon size={16} />
</button>
</Tooltip>
</div>
);
};

export default OrderPriceDetailsComponent;
Original file line number Diff line number Diff line change
@@ -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;
}
Loading