Skip to content

Commit

Permalink
Merge pull request #88 from Rugute/main
Browse files Browse the repository at this point in the history
(feat) added billing modules
Rugute authored Jun 20, 2024
2 parents fbbfd1c + 79fb24f commit 80e17ee
Showing 107 changed files with 7,788 additions and 5 deletions.
4 changes: 4 additions & 0 deletions packages/esm-billing-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

# ESM Billing App

This is a frontend module that provides billing functionality.
151 changes: 151 additions & 0 deletions packages/esm-billing-app/__mocks__/visit.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
export const mockCurrentVisit = {
uuid: 'ee527f74-7373-4494-98bc-002c979971d1',
encounters: [],
patient: { uuid: '0b25b92a-add3-4d52-8491-778bec556e02' },
visitType: { uuid: '3371a4d4-f66f-4454-a86d-92c7b3da990c', name: 'Outpatient', display: 'Outpatient' },
attributes: [
{
uuid: 'e1522ecb-d027-4c30-aa25-38698ba18020',
display: 'Source form: 13',
attributeType: {
name: 'Source form',
datatypeClassname: 'org.openmrs.module.kenyaemr.datatype.FormDatatype',
uuid: '8bfab185-6947-4958-b7ab-dfafae1a3e3d',
},
value: {
uuid: '23b4ebbd-29ad-455e-be0e-04aa6bc30798',
display: 'MOH 257 Visit Summary',
name: 'MOH 257 Visit Summary',
description: null,
encounterType: {
uuid: 'a0034eee-1940-4e35-847f-97537a35d05e',
display: 'HIV Consultation',
},
version: '1',
build: null,
published: true,
formFields: [],
retired: false,
resourceVersion: '1.9',
},
},
{
uuid: '5dbb093d-b377-4684-9583-05074ae7187a',
display: 'Payment Method: 28989582-e8c3-46b0-96d0-c249cb06d5c6',
attributeType: {
name: 'Payment Method',
datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype',
uuid: 'e6cb0c3b-04b0-4117-9bc6-ce24adbda802',
},
value: '28989582-e8c3-46b0-96d0-c249cb06d5c6',
},
{
uuid: '85abc096-8524-4c2e-863c-b44c99c144f7',
display: 'Patient Type: false',
attributeType: {
name: 'Patient Type',
datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype',
uuid: '3b9dfac8-9e4d-11ee-8c90-0242ac120002',
},
value: 'false',
},
{
uuid: '7eb402bb-2bf8-43b7-91aa-ee3f5c753de1',
display: 'Visit queue number: CLI-090',
attributeType: {
name: 'Visit queue number',
datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype',
uuid: 'c61ce16f-272a-41e7-9924-4c555d0932c5',
},
value: 'CLI-090',
},
],
location: {
uuid: '233de33e-2778-4f9a-a398-fa09da9daa14',
name: 'Wamagana Health Centre',
display: 'Wamagana Health Centre',
},
startDatetime: '2024-05-29T15:19:00.000+0300',
stopDatetime: '',
};

export const mockBills = [
{
id: 1888,
uuid: '3b784fa7-c124-4710-9152-cad3dbaa19e8',
patientName: ' Test Unit patient',
identifier: 'MGTKYE ',
patientUuid: '0b25b92a-add3-4d52-8491-778bec556e02',
status: 'PENDING',
receiptNumber: '1916-6',
cashier: {
uuid: '693acc9b-734f-488d-b6d2-60368d02cec0',
display: '23797304 - Test Patient',
},
cashPointUuid: '54065383-b4d4-42d2-af4d-d250a1fd2590',
cashPointName: 'OPD Cash Point',
cashPointLocation: 'Moi Teaching Refferal Hospital',
dateCreated: '29 — May — 2024',
lineItems: [
{
uuid: '528c3411-b3b8-41dc-bf88-174b4adc1b5b',
display: 'BillLineItem',
voided: false,
voidReason: null,
item: '',
billableService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist',
quantity: 1,
price: 50,
priceName: 'Default',
priceUuid: '',
lineItemOrder: 0,
paymentStatus: 'PENDING',
order: null,
resourceVersion: '1.8',
},
],
billingService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist',
payments: [],
display: '1916-6',
totalAmount: 50,
},
{
id: 1213,
uuid: '5b633220-9a99-4517-bcdf-c06a7d38dd23',
patientName: ' peter ndungu mairo',
identifier: 'MGTKYE ',
patientUuid: '0b25b92a-add3-4d52-8491-778bec556e02',
status: 'PENDING',
receiptNumber: '1228-6',
cashier: {
uuid: '48b55692-e061-4ffa-b1f2-fd4aaf506224',
display: 'admin - Super User',
},
cashPointUuid: '54065383-b4d4-42d2-af4d-d250a1fd2590',
cashPointName: 'OPD Cash Point',
cashPointLocation: 'Moi Teaching Refferal Hospital',
dateCreated: '15 — May — 2024',
lineItems: [
{
uuid: '16cc8b90-f2d4-4907-a3d5-0f57486e5dcf',
display: 'BillLineItem',
voided: false,
voidReason: null,
item: '',
billableService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist',
quantity: 1,
price: 50,
priceName: 'Default',
priceUuid: '',
lineItemOrder: 0,
paymentStatus: 'PENDING',
order: null,
resourceVersion: '1.8',
},
],
billingService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist',
payments: [],
display: '1228-6',
totalAmount: 50,
},
];
8 changes: 8 additions & 0 deletions packages/esm-billing-app/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const rootConfig = require('../../jest.config.js');

const packageConfig = {
...rootConfig,
collectCoverage: false,
};

module.exports = packageConfig;
54 changes: 54 additions & 0 deletions packages/esm-billing-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@ampath/esm-billing-app",
"version": "5.1.1",
"description": "Billing app for AMRS",
"browser": "dist/ampath-esm-billing-app.js",
"main": "src/index.ts",
"source": true,
"license": "MPL-2.0",
"homepage": "https://github.com/AMPATH/ampath-esm-3.x#readme",
"scripts": {
"start": "openmrs develop",
"serve": "webpack serve --mode=development",
"debug": "npm run serve",
"build": "webpack --mode production",
"analyze": "webpack --mode=production --env.analyze=true",
"lint": "eslint src --ext ts,tsx",
"typescript": "tsc",
"extract-translations": "i18next 'src/**/*.component.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js",
"test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests",
"test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js",
"coverage": "yarn test --coverage"
},
"browserslist": [
"extends browserslist-config-openmrs"
],
"keywords": [
"openmrs"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/AMPATH/ampath-esm-3.x.git"
},
"bugs": {
"url": "git+https://github.com/AMPATH/ampath-esm-3.x.git"
},
"dependencies": {
"@carbon/react": "^1.42.1",
"lodash-es": "^4.17.15",
"react-to-print": "^2.14.13"
},
"peerDependencies": {
"@openmrs/esm-framework": "5.x",
"react": "^18.1.0",
"react-i18next": "11.x",
"react-router-dom": "6.x",
"swr": "2.x"
},
"devDependencies": {
"webpack": "^5.74.0"
}
}
202 changes: 202 additions & 0 deletions packages/esm-billing-app/src/bill-history/bill-history.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
DataTableSkeleton,
Layer,
DataTable,
TableContainer,
Table,
TableHead,
TableHeader,
TableRow,
TableBody,
TableCell,
Tile,
Pagination,
TableExpandHeader,
TableExpandRow,
TableExpandedRow,
Button,
} from '@carbon/react';
import { Add } from '@carbon/react/icons';
import { isDesktop, useLayoutType, usePagination, launchWorkspace } from '@openmrs/esm-framework';
import { EmptyDataIllustration, ErrorState, usePaginationInfo, CardHeader } from '@openmrs/esm-patient-common-lib';
import { useBills } from '../billing.resource';
import InvoiceTable from '../invoice/invoice-table.component';
import styles from './bill-history.scss';

interface BillHistoryProps {
patientUuid: string;
}

const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
const { t } = useTranslation();
const { bills, isLoading, error } = useBills(patientUuid);
const layout = useLayoutType();
const [pageSize, setPageSize] = React.useState(10);
const responsiveSize = isDesktop(layout) ? 'sm' : 'lg';
const { paginated, goTo, results, currentPage } = usePagination(bills, pageSize);
const { pageSizes } = usePaginationInfo(pageSize, bills?.length, currentPage, results?.length);

const headerData = [
{
header: t('visitTime', 'Visit time'),
key: 'visitTime',
},
{
header: t('identifier', 'Identifier'),
key: 'identifier',
},
{
header: t('billedItems', 'Billed Items'),
key: 'billedItems',
},
{
header: t('billTotal', 'Bill total'),
key: 'billTotal',
},
];

const setBilledItems = (bill) =>
bill.lineItems.reduce(
(acc, item) => acc + (acc ? ' & ' : '') + (item.billableService?.split(':')[1] || item.item?.split(':')[1] || ''),
'',
);

const rowData = results?.map((bill) => ({
id: bill.uuid,
uuid: bill.uuid,
billTotal: bill.totalAmount,
visitTime: bill.dateCreated,
identifier: bill.identifier,
billedItems: setBilledItems(bill),
}));

if (isLoading) {
return (
<div className={styles.loaderContainer}>
<DataTableSkeleton showHeader={false} showToolbar={false} zebra size={responsiveSize} />
</div>
);
}

if (error) {
return (
<div className={styles.errorContainer}>
<Layer>
<ErrorState error={error} headerTitle={t('billsList', 'Bill list')} />
</Layer>
</div>
);
}

if (bills.length === 0) {
return (
<>
<CardHeader title={t('patientBilling', 'Patient billing')}>
<></>
</CardHeader>
<Layer>
<Tile className={styles.tile}>
<div className={styles.illo}>
<EmptyDataIllustration />
</div>
<p className={styles.content}>There are no bills to display.</p>
<Button onClick={() => launchWorkspace('billing-form', { workspaceTitle: 'Billing Form' })} kind="ghost">
{t('launchBillForm', 'Launch bill form')}
</Button>
</Tile>
</Layer>
</>
);
}

return (
<div>
<CardHeader title={t('patientBilling', 'Patient billing')}>
<Button
renderIcon={Add}
onClick={() => launchWorkspace('billing-form', { workspaceTitle: 'Billing Form' })}
kind="ghost">
{t('addBill', 'Add bill item(s)')}
</Button>
</CardHeader>
<div className={styles.billHistoryContainer}>
<DataTable isSortable rows={rowData} headers={headerData} size={responsiveSize} useZebraStyles>
{({
rows,
headers,
getExpandHeaderProps,
getTableProps,
getTableContainerProps,
getHeaderProps,
getRowProps,
}) => (
<TableContainer {...getTableContainerProps}>
<Table className={styles.table} {...getTableProps()} aria-label="Bill list">
<TableHead>
<TableRow>
<TableExpandHeader enableToggle {...getExpandHeaderProps()} />
{headers.map((header, i) => (
<TableHeader
key={i}
{...getHeaderProps({
header,
})}>
{header.header}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => {
const currentBill = bills?.find((bill) => bill.uuid === row.id);

return (
<React.Fragment key={row.id}>
<TableExpandRow {...getRowProps({ row })}>
{row.cells.map((cell) => (
<TableCell key={cell.id}>{cell.value}</TableCell>
))}
</TableExpandRow>
{row.isExpanded ? (
<TableExpandedRow className={styles.expandedRow} colSpan={headers.length + 1}>
<div className={styles.container} key={i}>
<InvoiceTable bill={currentBill} isSelectable={false} />
</div>
</TableExpandedRow>
) : (
<TableExpandedRow className={styles.hiddenRow} colSpan={headers.length + 2} />
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</DataTable>
{paginated && (
<Pagination
forwardText={t('nextPage', 'Next page')}
backwardText={t('previousPage', 'Previous page')}
page={currentPage}
pageSize={pageSize}
pageSizes={pageSizes}
totalItems={bills.length}
className={styles.pagination}
size={responsiveSize}
onChange={({ page: newPage, pageSize }) => {
if (newPage !== currentPage) {
goTo(newPage);
}
setPageSize(pageSize);
}}
/>
)}
</div>
</div>
);
};

export default BillHistory;
165 changes: 165 additions & 0 deletions packages/esm-billing-app/src/bill-history/bill-history.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
@use '@carbon/layout';
@use '@carbon/type';
@import '~@openmrs/esm-styleguide/src/vars';

.container {
margin: 2rem 0;
}

.billHistoryContainer {
background-color: $ui-02;
border: 1px solid $ui-03;
border-bottom: none;
width: 100%;
margin: 0 auto;
max-width: 95vw;
padding-bottom: 0;
}

.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
padding: layout.$spacing-04 0 layout.$spacing-04 layout.$spacing-05;
background-color: $ui-02;
}

.backgroundDataFetchingIndicator {
align-items: center;
display: flex;
flex: 1 1 0%;
justify-content: center;
}

.tableContainer section {
position: relative;
}

.tableContainer a {
text-decoration: none;
}

.pagination {
overflow: hidden;

&:global(.cds--pagination) {
border-top: none;
}
}

.hiddenRow {
display: none;
}

.emptyRow {
padding: 0 1rem;
display: flex;
align-items: center;
}

.visitSummaryContainer {
width: 100%;
max-width: 768px;
margin: 1rem auto;
}

.expandedActiveVisitRow > td > div {
max-height: max-content !important;
}

.expandedActiveVisitRow td {
padding: 0 2rem;
}

.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child {
padding: 0 1rem;
}

.action {
margin-bottom: layout.$spacing-03;
}

.illo {
margin-top: layout.$spacing-05;
}

.content {
@include type.type-style('heading-compact-01');
color: $text-02;
margin-top: layout.$spacing-05;
margin-bottom: layout.$spacing-03;
}

.desktopHeading,
.tabletHeading {
text-align: left;
text-transform: capitalize;

h4 {
@include type.type-style('heading-compact-02');
color: $text-02;

&:after {
content: '';
display: block;
width: 2rem;
padding-top: 3px;
border-bottom: 0.375rem solid;
@include brand-03(border-bottom-color);
}
}
}

.tile {
text-align: center;
// border: 1px solid $ui-03;
}

.filterEmptyState {
display: flex;
justify-content: center;
align-items: center;
padding: layout.$spacing-05;
margin: layout.$spacing-09;
text-align: center;
}

.filterEmptyStateTile {
margin: auto;
}

.filterEmptyStateContent {
@include type.type-style('heading-compact-02');
color: $text-02;
margin-bottom: 0.5rem;
}

.filterEmptyStateHelper {
@include type.type-style('body-compact-01');
color: $text-02;
}

.table {
tr[data-child-row] td {
padding-left: 2rem !important;
}
}

.billingHeading {
position: relative;
max-width: 400px;
text-align: 'left';
margin-bottom: 40px;
}

.billingHeading::after {
position: absolute;
bottom: -10px;
width: 50px;
height: 5px;
background: green;
content: '';
display: block;
left: 6%;
transform: translatex(-50%);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Layer, ClickableTile } from '@carbon/react';
import { ArrowRight } from '@carbon/react/icons';

const BillableServicesCardLink: React.FC = () => {
const { t } = useTranslation();
const header = t('manageBillableServices', 'Manage billable services');
return (
<Layer>
<ClickableTile href={`${window.spaBase}/billable-services`} target="_blank" rel="noopener noreferrer">
<div>
<div className="heading">{header}</div>
<div className="content">{t('billableServices', 'Billable Services')}</div>
</div>
<div className="iconWrapper">
<ArrowRight size={16} />
</div>
</ClickableTile>
</Layer>
);
};

export default BillableServicesCardLink;
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import {
StructuredListHead,
StructuredListRow,
StructuredListCell,
StructuredListBody,
StructuredListWrapper,
Layer,
Checkbox,
} from '@carbon/react';
import { useTranslation } from 'react-i18next';
import { convertToCurrency, extractString } from '../../helpers';
import { type MappedBill, type LineItem } from '../../types';
import styles from './bill-waiver.scss';
import BillWaiverForm from './bill-waiver-form.component';

const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (patientUuid) => void }> = ({
bills,
setPatientUuid,
}) => {
const { t } = useTranslation();
const [selectedBills, setSelectedBills] = React.useState<Array<LineItem>>([]);

const checkBoxLabel = (lineItem) => {
return `${lineItem.item === '' ? lineItem.billableService : lineItem.item} ${convertToCurrency(lineItem.price)}`;
};

const handleOnCheckBoxChange = (event, { checked, id }) => {
const selectedLineItem = bills.lineItems.find((lineItem) => lineItem.uuid === id);
if (checked) {
setSelectedBills([...selectedBills, selectedLineItem]);
} else {
setSelectedBills(selectedBills.filter((lineItem) => lineItem.uuid !== id));
}
};
return (
<Layer>
<StructuredListWrapper className={styles.billListContainer} isCondensed selection={true}>
<StructuredListHead>
<StructuredListRow head>
<StructuredListCell head>{t('billItem', 'Bill item')}</StructuredListCell>
<StructuredListCell head>{t('quantity', 'Quantity')}</StructuredListCell>
<StructuredListCell head>{t('unitPrice', 'Unit Price')}</StructuredListCell>
<StructuredListCell head>{t('total', 'Total')}</StructuredListCell>
<StructuredListCell head>{t('actions', 'Actions')}</StructuredListCell>
</StructuredListRow>
</StructuredListHead>
<StructuredListBody>
{bills?.lineItems.map((lineItem) => (
<StructuredListRow>
<StructuredListCell>
{lineItem.item === '' ? extractString(lineItem.billableService) : extractString(lineItem.item)}
</StructuredListCell>
<StructuredListCell>{lineItem.quantity}</StructuredListCell>
<StructuredListCell>{convertToCurrency(lineItem.price)}</StructuredListCell>
<StructuredListCell>{convertToCurrency(lineItem.price * lineItem.quantity)}</StructuredListCell>
<StructuredListCell>
<Checkbox
hideLabel
onChange={(event, { checked, id }) => handleOnCheckBoxChange(event, { checked, id })}
labelText={checkBoxLabel(lineItem)}
id={lineItem.uuid}
/>
</StructuredListCell>
</StructuredListRow>
))}
</StructuredListBody>
</StructuredListWrapper>
<BillWaiverForm bill={bills} lineItems={selectedBills} setPatientUuid={setPatientUuid} />
</Layer>
);
};

export default PatientBillsSelections;
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Form, Stack, FormGroup, Layer, Button, NumberInput } from '@carbon/react';
import { TaskAdd } from '@carbon/react/icons';
import { useTranslation } from 'react-i18next';
import styles from './bill-waiver-form.scss';
import { type LineItem, type MappedBill } from '../../types';
import { createBillWaiverPayload } from './utils';
import { convertToCurrency, extractString } from '../../helpers';
import { processBillPayment, usePaymentModes } from '../../billing.resource';
import { showSnackbar } from '@openmrs/esm-framework';
import { mutate } from 'swr';

type BillWaiverFormProps = {
bill: MappedBill;
lineItems: Array<LineItem>;
setPatientUuid: (patientUuid) => void;
};

const BillWaiverForm: React.FC<BillWaiverFormProps> = ({ bill, lineItems, setPatientUuid }) => {
const { t } = useTranslation();
const [waiverAmount, setWaiverAmount] = useState(0);

const totalAmount = lineItems.reduce((acc, curr) => acc + curr.price * curr.quantity, 0);
const { paymentModes } = usePaymentModes(false);

if (lineItems?.length === 0) {
return null;
}

const handleProcessPayment = () => {
const waiverEndPointPayload = createBillWaiverPayload(bill, waiverAmount, totalAmount, lineItems, paymentModes);

processBillPayment(waiverEndPointPayload, bill.uuid).then(
(resp) => {
showSnackbar({
title: t('billWaiver', 'Bill waiver'),
subtitle: t('billWaiverSuccess', 'Bill waiver successful'),
kind: 'success',
timeoutInMs: 3500,
isLowContrast: true,
});
setPatientUuid('');
mutate((key) => typeof key === 'string' && key.startsWith('/ws/rest/v1/cashier/bill?v=full'), undefined, {
revalidate: true,
});
},
(error) => {
showSnackbar({
title: t('billWaiver', 'Bill waiver'),
subtitle: t('billWaiverError', 'Bill waiver failed {{error}}', {
error: error?.responseBody?.error?.message ?? error.message,
}),
kind: 'error',
timeoutInMs: 3500,
isLowContrast: true,
});
},
);
};

return (
<Form className={styles.billWaiverForm} aria-label={t('waiverForm', 'Waiver form')}>
<hr />
<Stack gap={7}>
<FormGroup legendText={t('waiverForm', 'Waiver form')}>
<section className={styles.billWaiverDescription}>
<label className={styles.label}>{t('billItems', 'Bill Items')}</label>
<p className={styles.value}>
{t('billName', ' {{billName}} ', {
billName: lineItems.map((item) => extractString(item.item || item.billableService)).join(', ') ?? '--',
})}
</p>
</section>
<section className={styles.billWaiverDescription}>
<label className={styles.label}>{t('billTotal', 'Bill total')}</label>
<p className={styles.value}>{convertToCurrency(totalAmount)}</p>
</section>

<Layer className={styles.formControlLayer}>
<NumberInput
id="waiverAmount"
label={t('amountToWaiveLabel', 'Amount to Waive')}
helperText={t('amountToWaiveHelper', 'Specify the amount to be deducted from the bill')}
aria-label={t('amountToWaiveAriaLabel', 'Enter amount to waive')}
hideSteppers
disableWheel
min={0}
max={totalAmount}
invalidText={t('invalidWaiverAmountMessage', 'Amount to waive cannot be greater than total amount')}
value={waiverAmount}
onChange={(event) => setWaiverAmount(event.target.value)}
/>
</Layer>
</FormGroup>
<div className={styles.buttonContainer}>
<Button kind="tertiary" renderIcon={TaskAdd} onClick={handleProcessPayment}>
{t('postWaiver', 'Post waiver')}
</Button>
</div>
</Stack>
</Form>
);
};

export default BillWaiverForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@use '@carbon/layout';
@use '@carbon/type';

.billWaiverForm {
margin-top: layout.$spacing-05;
padding: 0 layout.$spacing-05;
}

.buttonContainer {
display: flex;
justify-content: flex-end;
margin-top: -1rem;
}

.formControlLayer {
padding: layout.$spacing-05 0;
}

.billWaiverDescription {
display: grid;
grid-template-columns: 5rem 1fr;
column-gap: 1rem;
align-items: center;
margin-top: 0.125rem;
margin-bottom: 0.5rem;
}

.label {
@include type.type-style('heading-compact-01');
}

.value {
@include type.type-style('body-01');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { ExtensionSlot, UserHasAccess } from '@openmrs/esm-framework';
import PatientBills from './patient-bills.component';
import styles from './bill-waiver.scss';
import BillWaiverForm from './bill-waiver-form.component';
import { useBills } from '../../billing.resource';

type BillWaiverProps = {};

const BillWaiver: React.FC<BillWaiverProps> = () => {
const [patientUuid, setPatientUuid] = React.useState<string>('');
const { bills } = useBills(patientUuid);
const filterBills = bills.filter((bill) => bill.status !== 'PAID' && patientUuid === bill.patientUuid) ?? [];
return (
<UserHasAccess privilege="coreapps.systemAdministration">
<div className={styles.billWaiverContainer}>
<ExtensionSlot
name="patient-search-bar-slot"
state={{
selectPatientAction: (patientUuid) => setPatientUuid(patientUuid),
buttonProps: {
kind: 'primary',
},
}}
/>

<PatientBills patientUuid={patientUuid} bills={filterBills} setPatientUuid={setPatientUuid} />
</div>
</UserHasAccess>
);
};

export default BillWaiver;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@use '@carbon/layout';

.billWaiverContainer {
margin: layout.$layout-01;
row-gap: layout.$layout-01;
}

.billListContainer {
background-color: white;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React from 'react';
import { useBills } from '../../billing.resource';
import {
Layer,
DataTable,
TableContainer,
Table,
TableHead,
TableRow,
TableExpandHeader,
TableHeader,
TableBody,
TableExpandRow,
TableCell,
TableExpandedRow,
Tile,
} from '@carbon/react';
import { convertToCurrency, extractString } from '../../helpers';
import { useTranslation } from 'react-i18next';
import { EmptyDataIllustration } from '@openmrs/esm-patient-common-lib';
import PatientBillsSelections from './bill-selection.component';
import { type MappedBill } from '../../types';
import styles from '../../bills-table/bills-table.scss';

type PatientBillsProps = {
patientUuid: string;
bills: Array<MappedBill>;
setPatientUuid: (patientUuid: string) => void;
};

const PatientBills: React.FC<PatientBillsProps> = ({ patientUuid, bills, setPatientUuid }) => {
const { t } = useTranslation();

if (!patientUuid) {
return;
}

const tableHeaders = [
{ header: 'Date', key: 'date' },
{ header: 'Billable Service', key: 'billableService' },
{ header: 'Total Amount', key: 'totalAmount' },
];

const tableRows = bills.map((bill) => ({
id: `${bill.uuid}`,
date: bill.dateCreated,
billableService: extractString(bill.billingService),
totalAmount: convertToCurrency(bill.totalAmount),
}));

if (bills.length === 0 && patientUuid !== '') {
return (
<>
<div style={{ marginTop: '0.625rem' }}>
<Layer className={styles.emptyStateContainer}>
<Tile className={styles.tile}>
<div className={styles.illo}>
<EmptyDataIllustration />
</div>
<p className={styles.content}>{t('noBilltoDisplay', 'There are no bills to display for this patient')}</p>
</Tile>
</Layer>
</div>
</>
);
}

return (
<div style={{ marginTop: '1rem' }}>
<DataTable
rows={tableRows}
headers={tableHeaders}
size="sm"
useZebraStyles
render={({
rows,
headers,
getHeaderProps,
getExpandHeaderProps,
getRowProps,
getExpandedRowProps,
getTableProps,
getTableContainerProps,
}) => (
<TableContainer
title={t('patientBills', 'Patient bill')}
description={t('patientBillsDescription', 'List of patient bills')}
{...getTableContainerProps()}>
<Table {...getTableProps()} aria-label="sample table">
<TableHead>
<TableRow>
<TableExpandHeader enableToggle={true} {...getExpandHeaderProps()} />
{headers.map((header, i) => (
<TableHeader
key={i}
{...getHeaderProps({
header,
})}>
{header.header}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<React.Fragment key={row.id}>
<TableExpandRow
{...getRowProps({
row,
})}>
{row.cells.map((cell) => (
<TableCell key={cell.id}>{cell.value}</TableCell>
))}
</TableExpandRow>
<TableExpandedRow
colSpan={headers.length + 1}
className="demo-expanded-td"
{...getExpandedRowProps({
row,
})}>
<div>
<PatientBillsSelections bills={bills[index]} setPatientUuid={setPatientUuid} />
</div>
</TableExpandedRow>
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
)}
/>
</div>
);
};

export default PatientBills;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type OpenmrsResource } from '@openmrs/esm-framework';
import { type LineItem, type MappedBill } from '../../types';

export const createBillWaiverPayload = (
bill: MappedBill,
amountWaived: number,
totalAmount: number,
lineItems: Array<LineItem>,
paymentModes: Array<OpenmrsResource>,
) => {
const { cashier } = bill;

const billPayment = {
amount: parseFloat(totalAmount.toFixed(2)),
amountTendered: parseFloat(Number(amountWaived).toFixed(2)),
attributes: [],
instanceType: paymentModes?.find((mode) => mode.name.toLowerCase().includes('waiver'))?.uuid,
};

const processedLineItems = lineItems.map((lineItem) => ({
...lineItem,
billableService: processBillItem(lineItem),
item: processBillItem(lineItem),
paymentStatus: 'PAID',
}));

const processedPayment = {
cashPoint: bill.cashPointUuid,
cashier: cashier.uuid,
lineItems: processedLineItems,
payments: [...bill.payments, billPayment],
patient: bill.patientUuid,
};

return processedPayment;
};

const processBillItem = (item) => (item.item || item.billableService)?.split(':')[0];
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type OpenmrsResource, openmrsFetch } from '@openmrs/esm-framework';
import useSWR from 'swr';
import { type ServiceConcept } from '../types';

type ResponseObject = {
results: Array<OpenmrsResource>;
};

export const useBillableServices = () => {
const url = `/ws/rest/v1/cashier/billableService?v=custom:(uuid,name,shortName,serviceStatus,serviceType:(display),servicePrices:(uuid,name,price))`;
const { data, isLoading, isValidating, error, mutate } = useSWR<{ data: ResponseObject }>(url, openmrsFetch, {});
return { billableServices: data?.data.results ?? [], isLoading, isValidating, error, mutate };
};

export function useServiceTypes() {
const url = `/ws/rest/v1/concept/d7bd4cc0-90b1-4f22-90f2-ab7fde936727?v=custom:(setMembers:(uuid,display))`;
const { data, error, isLoading } = useSWR<{ data: any }>(url, openmrsFetch, {});
return { serviceTypes: data?.data.setMembers ?? [], error, isLoading };
}

export const usePaymentModes = () => {
const url = `/ws/rest/v1/cashier/paymentMode`;
const { data, error, isLoading } = useSWR<{ data: ResponseObject }>(url, openmrsFetch, {});
return { paymentModes: data?.data.results ?? [], error, isLoading };
};

export const createBillableService = (payload: any) => {
const url = `/ws/rest/v1/cashier/api/billable-service`;
return openmrsFetch(url, {
method: 'POST',
body: payload,
headers: {
'Content-Type': 'application/json',
},
});
};

export function useConceptsSearch(conceptToLookup: string) {
const conditionsSearchUrl = `/ws/rest/v1/conceptsearch?q=${conceptToLookup}`;

const { data, error, isLoading } = useSWR<{ data: { results: Array<ServiceConcept> } }, Error>(
conceptToLookup ? conditionsSearchUrl : null,
openmrsFetch,
);

return {
searchResults: data?.data?.results ?? [],
error: error,
isSearching: isLoading,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BillableServicesDashboard } from './dashboard/dashboard.component';
import AddBillableService from './create-edit/add-billable-service.component';
import { SideNav, SideNavItems, SideNavLink } from '@carbon/react';
import styles from './billable-services.scss';
import BillingHeader from '../billing-header/billing-header.component';
import { Wallet, Money } from '@carbon/react/icons';
import { UserHasAccess, navigate } from '@openmrs/esm-framework';
import BillWaiver from './bill-waiver/bill-waiver.component';
const basePath = `${window.spaBase}/billable-services`;
const BillableServiceHome: React.FC = () => {
const { t } = useTranslation();

const handleNavigation = (path: string) => {
navigate({ to: `${basePath}/${path}` });
};

return (
<BrowserRouter basename={`${window.spaBase}/billable-services`}>
<main className={styles.mainSection}>
<section>
<SideNav>
<SideNavItems>
<SideNavLink onClick={() => handleNavigation('')} renderIcon={Wallet} isActive>
{t('billableServices', 'Billable Services')}
</SideNavLink>
<UserHasAccess privilege="coreapps.systemAdministration">
<SideNavLink onClick={() => handleNavigation('waive-bill')} renderIcon={Money}>
{t('billWaiver', 'Bill waiver')}
</SideNavLink>
</UserHasAccess>
</SideNavItems>
</SideNav>
</section>
<section>
<BillingHeader title={t('billServicesManagement', 'Bill services management')} />
<Routes>
<Route path="/" element={<BillableServicesDashboard />} />
<Route path="/add-service" element={<AddBillableService />} />
<Route path="/waive-bill" element={<BillWaiver />} />
</Routes>
</section>
</main>
</BrowserRouter>
);
};

export default BillableServiceHome;
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import React, { useCallback, useMemo, useState } from 'react';
import classNames from 'classnames';
import {
DataTable,
InlineLoading,
Layer,
Pagination,
Search,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tile,
Button,
} from '@carbon/react';
import { useLayoutType, isDesktop, useConfig, usePagination, ErrorState, navigate } from '@openmrs/esm-framework';
import { EmptyState } from '@openmrs/esm-patient-common-lib';
import styles from './billable-services.scss';
import { useTranslation } from 'react-i18next';
import { useBillableServices } from './billable-service.resource';
import { ArrowRight } from '@carbon/react/icons';

const BillableServices = () => {
const { t } = useTranslation();
const { billableServices, isLoading, isValidating, error, mutate } = useBillableServices();
const layout = useLayoutType();
const config = useConfig();
const [searchString, setSearchString] = useState('');
const responsiveSize = isDesktop(layout) ? 'lg' : 'sm';
const pageSizes = config?.billableServices?.pageSizes ?? [10, 20, 30, 40, 50];
const [pageSize, setPageSize] = useState(config?.billableServices?.pageSize ?? 10);

//creating service state
const [showOverlay, setShowOverlay] = useState(false);
const [overlayHeader, setOverlayTitle] = useState('');

const headerData = [
{
header: t('serviceName', 'Service Name'),
key: 'serviceName',
},
{
header: t('shortName', 'Short Name'),
key: 'shortName',
},
{
header: t('serviceType', 'Service Type'),
key: 'serviceType',
},
{
header: t('status', 'Service Status'),
key: 'status',
},
{
header: t('prices', 'Prices'),
key: 'prices',
},
{
header: t('actions', 'Actions'),
key: 'actions',
},
];

const launchBillableServiceForm = useCallback(() => {
navigate({ to: window.getOpenmrsSpaBase() + 'billable-services/add-service' });
}, []);

const searchResults = useMemo(() => {
if (billableServices !== undefined && billableServices.length > 0) {
if (searchString && searchString.trim() !== '') {
const search = searchString.toLowerCase();
return billableServices?.filter((service) =>
Object.entries(service).some(([header, value]) => {
return header === 'uuid' ? false : `${value}`.toLowerCase().includes(search);
}),
);
}
}
return billableServices;
}, [searchString, billableServices]);

const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize);

let rowData = [];
if (results) {
results.forEach((service, index) => {
const s = {
id: `${index}`,
uuid: service.uuid,
serviceName: service.name,
shortName: service.shortName,
serviceType: service?.serviceType?.display,
status: service.serviceStatus,
prices: '--',
actions: '--',
};
let cost = '';
service.servicePrices.forEach((price) => {
cost += `${price.name} (${price.price}) `;
});
s.prices = cost;
rowData.push(s);
});
}

const handleSearch = useCallback(
(e) => {
goTo(1);
setSearchString(e.target.value);
},
[goTo, setSearchString],
);

if (isLoading) {
<InlineLoading status="active" iconDescription="Loading" description="Loading data..." />;
}
if (error) {
<ErrorState headerTitle={t('billableService', 'Billable Service')} error={error} />;
}
if (billableServices.length === 0) {
<EmptyState
displayText={t('billableService', 'Billable Service')}
headerTitle={t('billableService', 'Billable Service')}
launchForm={launchBillableServiceForm}
/>;
}

return (
<>
{billableServices?.length > 0 ? (
<div className={styles.serviceContainer}>
<FilterableTableHeader
handleSearch={handleSearch}
isValidating={isValidating}
layout={layout}
responsiveSize={responsiveSize}
t={t}
/>
<DataTable
isSortable
rows={rowData}
headers={headerData}
size={responsiveSize}
useZebraStyles={rowData?.length > 1 ? true : false}>
{({ rows, headers, getRowProps, getTableProps }) => (
<TableContainer>
<Table {...getTableProps()} aria-label="service list">
<TableHead>
<TableRow>
{headers.map((header) => (
<TableHeader key={header.key}>{header.header}</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={row.id}
{...getRowProps({
row,
})}>
{row.cells.map((cell) => (
<TableCell key={cell.id}>{cell.value}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</DataTable>
{searchResults?.length === 0 && (
<div className={styles.filterEmptyState}>
<Layer level={0}>
<Tile className={styles.filterEmptyStateTile}>
<p className={styles.filterEmptyStateContent}>
{t('noMatchingServicesToDisplay', 'No matching services to display')}
</p>
<p className={styles.filterEmptyStateHelper}>{t('checkFilters', 'Check the filters above')}</p>
</Tile>
</Layer>
</div>
)}
{paginated && (
<Pagination
forwardText="Next page"
backwardText="Previous page"
page={currentPage}
pageSize={pageSize}
pageSizes={pageSizes}
totalItems={searchResults?.length}
className={styles.pagination}
size={responsiveSize}
onChange={({ pageSize: newPageSize, page: newPage }) => {
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
if (newPage !== currentPage) {
goTo(newPage);
}
}}
/>
)}
</div>
) : (
<EmptyState
launchForm={launchBillableServiceForm}
displayText={t('noServicesToDisplay', 'There are no services to display')}
headerTitle={t('billableService', 'Billable service')}
/>
)}
</>
);
};

function FilterableTableHeader({ layout, handleSearch, isValidating, responsiveSize, t }) {
return (
<>
<div className={styles.headerContainer}>
<div
className={classNames({
[styles.tabletHeading]: !isDesktop(layout),
[styles.desktopHeading]: isDesktop(layout),
})}>
<h4>{t('servicesList', 'Services list')}</h4>
</div>
<div className={styles.backgroundDataFetchingIndicator}>
<span>{isValidating ? <InlineLoading /> : null}</span>
</div>
</div>
<div className={styles.actionsContainer}>
<Search
labelText=""
placeholder={t('filterTable', 'Filter table')}
onChange={handleSearch}
size={responsiveSize}
/>
<Button
size={responsiveSize}
kind="primary"
renderIcon={(props) => <ArrowRight size={16} {...props} />}
onClick={() => {
navigate({ to: window.getOpenmrsSpaBase() + 'billable-services/add-service' });
}}
iconDescription={t('addNewBillableService', 'Add new billable service')}>
{t('addNewService', 'Add new service')}
</Button>
</div>
</>
);
}
export default BillableServices;
219 changes: 219 additions & 0 deletions packages/esm-billing-app/src/billable-services/billable-services.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
@use '@carbon/layout';
@use '@carbon/type';
@use '@carbon/styles/scss/spacing';
@import '~@openmrs/esm-styleguide/src/vars';

.container {
margin: 2rem 0;
}

.emptyStateContainer,
.loaderContainer {
@extend .container;
}

.serviceContainer {
background-color: $ui-02;
border: 1px solid $ui-03;
width: 100%;
margin: 0 auto;
max-width: 95vw;
padding-bottom: 0;

:has(.filterEmptyState) {
border-bottom: none;
}
}
.left-justified-items {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
align-items: center;
}

.filterContainer {
flex: 1;

:global(.cds--dropdown__wrapper--inline) {
gap: 0;
}

:global(.cds--list-box__menu-icon) {
height: 1rem;
}

:global(.cds--list-box__menu) {
min-width: max-content;
}

:global(.cds--list-box) {
margin-left: layout.$spacing-03;
}
}

.menu {
margin-left: layout.$spacing-03;
}

.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
padding: layout.$spacing-04 layout.$spacing-05;
background-color: $ui-02;
}

.backgroundDataFetchingIndicator {
align-items: center;
display: flex;
flex: 1;
justify-content: space-between;

&:global(.cds--inline-loading) {
max-height: 1rem;
}
}

.tableContainer section {
position: relative;
}

.tableContainer a {
text-decoration: none;
}

.pagination {
overflow: hidden;

&:global(.cds--pagination) {
border-top: none;
}
}

.hiddenRow {
display: none;
}

.emptyRow {
padding: 0 1rem;
display: flex;
align-items: center;
}

.visitSummaryContainer {
width: 100%;
max-width: 768px;
margin: 1rem auto;
}

.expandedActiveVisitRow > td > div {
max-height: max-content !important;
}

.expandedActiveVisitRow td {
padding: 0 2rem;
}

.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child {
padding: 0 1rem;
}

.action {
margin-bottom: layout.$spacing-03;
}

.illo {
margin-top: layout.$spacing-05;
}

.content {
@include type.type-style('heading-compact-01');
color: $text-02;
margin-top: layout.$spacing-05;
margin-bottom: layout.$spacing-03;
}

.desktopHeading,
.tabletHeading {
text-align: left;
text-transform: capitalize;
flex: 1;

h4 {
@include type.type-style('heading-compact-02');
color: $text-02;

&:after {
content: '';
display: block;
width: 2rem;
padding-top: 3px;
border-bottom: 0.375rem solid;
@include brand-03(border-bottom-color);
}
}
}

.tile {
text-align: center;
border: 1px solid $ui-03;
}

.menuitem {
max-width: none;
}

.filterEmptyState {
display: flex;
justify-content: center;
align-items: center;
padding: layout.$spacing-05;
margin: layout.$spacing-09;
text-align: center;
}

.filterEmptyStateTile {
margin: auto;
}

.filterEmptyStateContent {
@include type.type-style('heading-compact-02');
color: $text-02;
margin-bottom: 0.5rem;
}

.filterEmptyStateHelper {
@include type.type-style('body-compact-01');
color: $text-02;
}

.metricsContainer {
display: flex;
justify-content: space-between;
background-color: $ui-02;
height: spacing.$spacing-10;
align-items: center;
padding: 0 spacing.$spacing-05;
}

.metricsTitle {
@include type.type-style('heading-03');
color: $ui-05;
}

.actionsContainer {
display: flex;
justify-content: space-between;
align-items: center;
background-color: $ui-02;
}
.actionBtn {
display: flex;
column-gap: 0.5rem;
}

.mainSection {
display: grid;
grid-template-columns: 16rem 1fr;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { type Drug } from '@openmrs/esm-patient-common-lib';
import { type DosingUnit, type MedicationFrequency, type MedicationRoute, type QuantityUnit } from '../../types';
import { useBillableItem, useSockItemInventory } from './useBilliableItem';
import { useTranslation } from 'react-i18next';
import styles from './drug-order.scss';
import { convertToCurrency } from '../../helpers';

type DrugOrderProps = {
order: {
drug: Drug;
unit: DosingUnit;
commonMedicationName: string;
dosage: number;
frequency: MedicationFrequency;
route: MedicationRoute;
quantityUnits: QuantityUnit;
patientInstructions: string;
asNeeded: boolean;
asNeededCondition: string;
};
};

const DrugOrder: React.FC<DrugOrderProps> = ({ order }) => {
const { t } = useTranslation();
const { stockItem, isLoading: isLoadingInventory } = useSockItemInventory(order?.drug?.uuid);
const { billableItem, isLoading } = useBillableItem(order?.drug.concept.uuid);

if (isLoading || isLoadingInventory) {
return null;
}

return (
<div className={styles.drugOrderContainer}>
{stockItem && (
<div className={styles.itemContainer}>
<span className={styles.bold}>
{t('inStock', '{{quantityUoM}}(s) In stock ', { quantityUoM: stockItem?.quantityUoM })}
</span>
<span>{Math.round(stockItem?.quantity)}</span>
</div>
)}
<div>
{billableItem &&
billableItem?.servicePrices.map((item) => (
<div key={item.uuid} className={styles.itemContainer}>
<span className={styles.bold}>{t('unitPrice', 'Unit price ')}</span>
<span>{convertToCurrency(item.price)}</span>
</div>
))}
</div>
</div>
);
};

export default DrugOrder;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@use '@carbon/type';
@use '@carbon/colors';
@use '@carbon/layout';
@use '@carbon/styles/scss/spacing';

.drugOrderContainer {
margin: spacing.$spacing-03 0;

& > div {
margin: spacing.$spacing-03 0;
}
}

.bold {
font-weight: bold;
}

.itemInfo {
@include type.type-style('body-01');
}

.itemContainer {
display: grid;
grid-template-columns: 1fr 1fr;
padding-left: spacing.$spacing-03;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { convertToCurrency } from '../../helpers';
import { useBillableItem } from './useBilliableItem';

type LabOrderProps = {
order: {
testType?: {
label: string;
conceptUuid: string;
};
};
};

const LabOrder: React.FC<LabOrderProps> = ({ order }) => {
// TODO: Implement logic to display whether the lab order service is available to ensure clinicians can order the service

const { billableItem, error, isLoading } = useBillableItem(order?.testType?.conceptUuid);

const billItems = billableItem?.servicePrices
.map((servicePrice) => `${servicePrice?.paymentMode?.name} - ${convertToCurrency(servicePrice?.price)}`)
.join(' ');

if (isLoading) {
return null;
}

if (error) {
return null;
}

return <p>{billItems}</p>;
};

export default LabOrder;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import useSWRImmutable from 'swr/immutable';
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import useSWR from 'swr';
import first from 'lodash-es/first';

type BillableItemResponse = {
uuid: string;
name: string;
concept: {
uuid: string;
display: string;
};
servicePrices: Array<{
uuid: string;
price: number;
paymentMode: {
uuid: string;
name: string;
};
}>;
};

export const useBillableItem = (billableItemId: string) => {
const customRepresentation = `v=custom:(uuid,name,concept:(uuid,display),servicePrices:(uuid,price,paymentMode:(uuid,name)))`;
const { data, error, isLoading } = useSWRImmutable<{ data: { results: Array<BillableItemResponse> } }>(
`${restBaseUrl}/cashier/billableService?${customRepresentation}`,
openmrsFetch,
);
const billableItem = data?.data?.results?.find((item) => item?.concept?.uuid === billableItemId);

return {
billableItem: billableItem,
isLoading: isLoading,
error,
};
};

export const useSockItemInventory = (stockItemId: string) => {
const url = `/ws/rest/v1/stockmanagement/stockiteminventory?v=default&limit=10&totalCount=true&drugUuid=${stockItemId}`;
const { data, error, isLoading } = useSWR<{ data: { results: Array<{ quantityUoM: string; quantity: number }> } }>(
url,
openmrsFetch,
);
const stockItemsInfo = first(data?.data?.results ?? []);
return {
stockItem: stockItemsInfo,
isLoading: isLoading,
error,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
/* eslint-disable curly */
import React, { useCallback, useRef, useState } from 'react';
import styles from './add-billable-service.scss';
import {
Form,
Button,
TextInput,
ComboBox,
Dropdown,
Layer,
InlineLoading,
Search,
Tile,
FormLabel,
} from '@carbon/react';
import { useTranslation } from 'react-i18next';
import {
createBillableService,
useConceptsSearch,
usePaymentModes,
useServiceTypes,
} from '../billable-service.resource';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Add, TrashCan, WarningFilled } from '@carbon/react/icons';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { navigate, showSnackbar, useDebounce, useLayoutType, useSession } from '@openmrs/esm-framework';
import { type ServiceConcept } from '../../types';

const servicePriceSchema = z.object({
paymentMode: z.string().refine((value) => !!value, 'Payment method is required'),
price: z.union([
z.number().refine((value) => !!value, 'Price is required'),
z.string().refine((value) => !!value, 'Price is required'),
]),
});
const paymentFormSchema = z.object({ payment: z.array(servicePriceSchema) });

type PaymentMode = {
paymentMode: string;
price: string | number;
};
type PaymentModeFormValue = {
payment: Array<PaymentMode>;
};
const DEFAULT_PAYMENT_OPTION = { paymentMode: '', price: 0 };

const AddBillableService: React.FC = () => {
const { t } = useTranslation();

const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes();
const { serviceTypes, isLoading: isLoadingServicesTypes } = useServiceTypes();
const [billableServicePayload, setBillableServicePayload] = useState<any>({});

const {
control,
handleSubmit,
formState: { errors },
} = useForm<any>({
mode: 'all',
defaultValues: {},
resolver: zodResolver(paymentFormSchema),
});
const { fields, remove, append } = useFieldArray({ name: 'payment', control: control });

const handleAppendPaymentMode = useCallback(() => append(DEFAULT_PAYMENT_OPTION), [append]);
const handleRemovePaymentMode = useCallback((index) => remove(index), [remove]);

const isTablet = useLayoutType() === 'tablet';
const searchInputRef = useRef(null);
const handleSearchTermChange = (event: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(event.target.value);

const [selectedConcept, setSelectedConcept] = useState<ServiceConcept>(null);
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm);
const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm);
const handleConceptChange = useCallback((selectedConcept: any) => {
setSelectedConcept(selectedConcept);
}, []);

const handleNavigateToServiceDashboard = () =>
navigate({
to: window.getOpenmrsSpaBase() + 'billable-services',
});

if (isLoadingPaymentModes && isLoadingServicesTypes) {
return (
<InlineLoading
status="active"
iconDescription={t('loadingDescription', 'Loading')}
description={t('loading', 'Loading data...')}
/>
);
}

const onSubmit = (data) => {
const payload: any = {};

let servicePrices = [];
data.payment.forEach((element) => {
element.name = paymentModes.filter((p) => p.uuid === element.paymentMode)[0].name;
servicePrices.push(element);
});
payload.name = billableServicePayload.serviceName;
payload.shortName = billableServicePayload.shortName;
payload.serviceType = billableServicePayload.serviceType.uuid;
payload.servicePrices = servicePrices;
payload.serviceStatus = 'ENABLED';
payload.concept = selectedConcept?.concept?.uuid;

createBillableService(payload).then(
(resp) => {
showSnackbar({
title: t('billableService', 'Billable service'),
subtitle: 'Billable service created successfully',
kind: 'success',
timeoutInMs: 3000,
});
handleNavigateToServiceDashboard();
},
(error) => {
showSnackbar({ title: 'Bill payment error', kind: 'error', subtitle: error });
},
);
};

return (
<Form className={styles.form}>
<h4>{t('addBillableServices', 'Add Billable Services')}</h4>
<section className={styles.section}>
<Layer>
<TextInput
id="serviceName"
type="text"
labelText={t('serviceName', 'Service Name')}
size="md"
onChange={(e) =>
setBillableServicePayload({
...billableServicePayload,
serviceName: e.target.value,
})
}
placeholder="Enter service name"
/>
</Layer>
</section>
<section className={styles.section}>
<Layer>
<TextInput
id="serviceShortName"
type="text"
labelText={t('serviceShortName', 'Short Name')}
size="md"
onChange={(e) =>
setBillableServicePayload({
...billableServicePayload,
shortName: e.target.value,
})
}
placeholder="Enter service short name"
/>
</Layer>
</section>
<section>
<FormLabel className={styles.conceptLabel}>Associated Concept</FormLabel>
<Controller
name="search"
control={control}
render={({ field: { onChange, value, onBlur } }) => (
<ResponsiveWrapper isTablet={isTablet}>
<Search
ref={searchInputRef}
size="md"
id="conceptsSearch"
labelText={t('enterConcept', 'Associated concept')}
placeholder={t('searchConcepts', 'Search associated concept')}
className={errors?.search && styles.serviceError}
onChange={(e) => {
onChange(e);
handleSearchTermChange(e);
}}
renderIcon={errors?.search && <WarningFilled />}
onBlur={onBlur}
onClear={() => {
setSearchTerm('');
setSelectedConcept(null);
}}
value={(() => {
if (selectedConcept) {
return selectedConcept.display;
}
if (debouncedSearchTerm) {
return value;
}
})()}
/>
</ResponsiveWrapper>
)}
/>
{(() => {
if (!debouncedSearchTerm || selectedConcept) return null;
if (isSearching)
return <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />;
if (searchResults && searchResults.length) {
return (
<ul className={styles.conceptsList}>
{/*TODO: use uuid instead of index as the key*/}
{searchResults?.map((searchResult, index) => (
<li
role="menuitem"
className={styles.service}
key={index}
onClick={() => handleConceptChange(searchResult)}>
{searchResult.display}
</li>
))}
</ul>
);
}
return (
<Layer>
<Tile className={styles.emptyResults}>
<span>
{t('noResultsFor', 'No results for')} <strong>"{debouncedSearchTerm}"</strong>
</span>
</Tile>
</Layer>
);
})()}
</section>
<section className={styles.section}>
<Layer>
<ComboBox
id="serviceType"
items={serviceTypes ?? []}
titleText={t('serviceType', 'Service Type')}
itemToString={(item) => item?.display}
onChange={({ selectedItem }) => {
setBillableServicePayload({
...billableServicePayload,
display: selectedItem?.display,
serviceType: selectedItem,
});
}}
placeholder="Select service type"
required
/>
</Layer>
</section>

<section>
<div className={styles.container}>
{fields.map((field, index) => (
<div key={field.id} className={styles.paymentMethodContainer}>
<Controller
control={control}
name={`payment.${index}.paymentMode`}
render={({ field }) => (
<Layer>
<Dropdown
id={`paymentMode-${index}`}
onChange={({ selectedItem }) => field.onChange(selectedItem?.uuid)}
titleText={t('paymentMode', 'Payment Mode')}
label={t('selectPaymentMethod', 'Select payment method')}
items={paymentModes ?? []}
itemToString={(item) => (item ? item.name : '')}
invalid={!!errors?.payment?.[index]?.paymentMode}
invalidText={errors?.payment?.[index]?.paymentMode?.message}
/>
</Layer>
)}
/>
<Controller
control={control}
name={`payment.${index}.price`}
render={({ field }) => (
<Layer>
<TextInput
id={`price-${index}`}
{...field}
invalid={!!errors?.payment?.[index]?.price}
invalidText={errors?.payment?.[index]?.price?.message}
labelText={t('sellingPrice', 'Selling Price')}
placeholder={t('sellingAmount', 'Enter selling price')}
/>
</Layer>
)}
/>
<div className={styles.removeButtonContainer}>
<TrashCan
aria-label={`delete_${index}`}
id={`delete_${index}`}
onClick={() => handleRemovePaymentMode(index)}
className={styles.removeButton}
size={20}
/>
</div>
</div>
))}
<Button
size="md"
onClick={handleAppendPaymentMode}
className={styles.paymentButtons}
renderIcon={(props) => <Add size={24} {...props} />}
iconDescription="Add">
{t('addPaymentOptions', 'Add payment option')}
</Button>
</div>
</section>

<section>
<Button kind="secondary" onClick={handleNavigateToServiceDashboard}>
{t('cancel', 'Cancel')}
</Button>
<Button type="submit" onClick={handleSubmit(onSubmit)}>
{t('save', 'Save')}
</Button>
</section>
</Form>
);
};

function ResponsiveWrapper({ children, isTablet }: { children: React.ReactNode; isTablet: boolean }) {
return isTablet ? <Layer>{children} </Layer> : <>{children}</>;
}

export default AddBillableService;
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
@use '@carbon/styles/scss/spacing';
@use '@carbon/styles/scss/type';
@use '@carbon/colors';
@use '@carbon/layout';
@import '~@openmrs/esm-styleguide/src/vars';

.form {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}

.section {
margin: spacing.$spacing-03;
}

.sectionTitle {
@include type.type-style('heading-compact-02');
color: $text-02;
margin-bottom: spacing.$spacing-04;
}

.modalBody {
padding-bottom: spacing.$spacing-05;
}

.container {
margin: 1rem;
}

.paymentContainer {
margin: layout.$layout-01;
padding: layout.$layout-01;
width: 70%;
border-right: 1px solid colors.$cool-gray-40;
}

.paymentButtons {
margin: layout.$layout-01 0;
}

.paymentMethodContainer {
display: grid;
grid-template-columns: repeat(4, minmax(auto, 1fr));
align-items: flex-start;
column-gap: 1rem;
margin: 0.625rem 0;
width: 100%;
}

.paymentTotals {
margin-top: layout.$spacing-01;
}

.processPayments {
display: flex;
justify-content: flex-end;
margin: layout.$spacing-05;
column-gap: layout.$spacing-04;
}

.errorPaymentContainer {
margin: layout.$spacing-04;
min-height: layout.$spacing-09;
}

.removeButtonContainer {
display: flex;
align-self: center;
cursor: pointer;
margin-left: layout.$spacing-07;
}

.removeButton {
color: colors.$red-60;
}

.service {
padding: 1rem 0.75rem;
}

.conceptsList {
background-color: $ui-02;
max-height: 14rem;
overflow-y: auto;
border: 1px solid $ui-03;

li:hover {
background-color: $ui-03;
}
}

.emptyResults {
@include type.type-style('body-compact-01');
color: $text-02;
min-height: 1rem;
border: 1px solid $ui-03;
}

.conceptLabel {
@include type.type-style('label-02');
margin: 1rem;
}

.errorContainer {
margin: 1rem;
}

.serviceError {
:global(.cds--search-input):focus {
outline: 2.5px solid $danger;
}

:global(.cds--search-magnifier) {
svg {
fill: $danger;
}
}
}

.errorMessage {
@include type.type-style('label-02');
color: $danger;
margin-top: 0.5rem;
}

.spinner {
&:global(.cds--inline-loading) {
min-height: 1rem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import styles from './dashboard.scss';
import ServiceMetrics from './service-metrics.component';
import BillableServices from '../billable-services.component';

export function BillableServicesDashboard() {
const { t } = useTranslation();

return (
<main className={styles.container}>
<ServiceMetrics />
<main className={styles.servicesTableContainer}>
<BillableServices />
</main>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@use '@carbon/colors';
@use '@carbon/layout';
@use '@carbon/type';

.container {
height: calc(100vh - 3rem);
}

.servicesTableContainer {
margin: 2rem 1rem;
}

.illo {
margin-top: layout.$spacing-05;
}

.content {
@include type.type-style('heading-compact-01');
color: colors.$gray-70;
margin-top: layout.$spacing-05;
}

.tile {
border: 1px solid colors.$gray-20;
padding: 1.5rem 0;
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useMemo } from 'react';
import Card from '../../metrics-cards/card.component';
import styles from '../../metrics-cards/metrics-cards.scss';
import { useTranslation } from 'react-i18next';
import { ErrorState } from '@openmrs/esm-patient-common-lib';
import { InlineLoading } from '@carbon/react';
import { useBillableServices } from '../billable-service.resource';

export default function ServiceMetrics() {
const { t } = useTranslation();
const { isLoading, error } = useBillableServices();

const cards = useMemo(
() => [
{ title: 'Cash Revenue', count: '--' },
{ title: 'Insurance Revenue', count: '--' },
{ title: 'Pending Claims', count: '--' },
],
[],
);

if (isLoading) {
return (
<section className={styles.container}>
<InlineLoading status="active" iconDescription="Loading" description="Loading service metrics..." />
</section>
);
}

if (error) {
return <ErrorState headerTitle={t('serviceMetrics', 'Service Metrics')} error={error} />;
}
return (
<section className={styles.container}>
{cards.map((card) => (
<Card key={card.title} title={card.title} count={card.count} />
))}
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import BillingHeader from '../billing-header/billing-header.component';
import MetricsCards from '../metrics-cards/metrics-cards.component';
import BillsTable from '../bills-table/bills-table.component';
import styles from './billing-dashboard.scss';

export function BillingDashboard() {
const { t } = useTranslation();

return (
<main className={styles.container}>
<BillingHeader title={t('home', 'Home')} />
<MetricsCards />
<main className={styles.billsTableContainer}>
<BillsTable defaultBillPaymentStatus="PENDING" />
</main>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@use '@carbon/colors';
@use '@carbon/layout';
@use '@carbon/type';

.container {
height: calc(100vh - 3rem);
}

.billsTableContainer {
margin: 2rem 1rem;
}

.illo {
margin-top: layout.$spacing-05;
}

.content {
@include type.type-style('heading-compact-01');
color: colors.$gray-70;
margin-top: layout.$spacing-05;
}

.tile {
border: 1px solid colors.$gray-20;
padding: 1.5rem 0;
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useState } from 'react';
import { InlineLoading, InlineNotification, FilterableMultiSelect } from '@carbon/react';
import { useTranslation } from 'react-i18next';
import { showSnackbar, useConfig } from '@openmrs/esm-framework';
import styles from './billing-checkin-form.scss';
import VisitAttributesForm from './visit-attributes/visit-attributes-form.component';
import { type BillingConfig } from '../config-schema';
import { hasPatientBeenExempted } from './helper';
import { EXEMPTED_PAYMENT_STATUS, PENDING_PAYMENT_STATUS } from '../constants';
import { type BillingService } from '../types';
import SHANumberValidity from './social-health-authority/sha-number-validity.component';
import { createPatientBill, useBillableItems, useCashPoint } from '../billing.resource';

type BillingCheckInFormProps = {
patientUuid: string;
setExtraVisitInfo: (state) => void;
};

const BillingCheckInForm: React.FC<BillingCheckInFormProps> = ({ patientUuid, setExtraVisitInfo }) => {
const { t } = useTranslation();
const {
visitAttributeTypes: { isPatientExempted },
} = useConfig<BillingConfig>();
const { cashPoints, isLoading: isLoadingCashPoints, error: cashError } = useCashPoint();
const { lineItems, isLoading: isLoadingLineItems, error: lineError } = useBillableItems();
const [attributes, setAttributes] = useState([]);
const [paymentMethod, setPaymentMethod] = useState<any>();
let lineList = [];

const handleCreateBill = useCallback((createBillPayload) => {
createPatientBill(createBillPayload).then(
() => {
showSnackbar({ title: 'Patient Bill', subtitle: 'Patient has been billed successfully', kind: 'success' });
},
(error) => {
const errorMessage = JSON.stringify(error?.responseBody?.error?.message?.replace(/\[/g, '').replace(/\]/g, ''));
showSnackbar({
title: 'Patient Bill Error',
subtitle: `An error has occurred while creating patient bill, Contact system administrator quoting this error ${errorMessage}`,
kind: 'error',
isLowContrast: true,
});
},
);
}, []);

const handleBillingService = (selectedItems: Array<BillingService>) => {
const cashPointUuid = cashPoints?.[0]?.uuid ?? '';
const billStatus = hasPatientBeenExempted(attributes, isPatientExempted)
? EXEMPTED_PAYMENT_STATUS
: PENDING_PAYMENT_STATUS;

const lineItems = selectedItems.map((item, index) => {
// // should default to first price if check returns empty. todo - update backend to return default price
const priceForPaymentMode =
item.servicePrices.find((p) => p.paymentMode?.uuid === paymentMethod) || item?.servicePrices[0];
return {
billableService: item?.uuid ?? '',
quantity: 1,
price: priceForPaymentMode ? priceForPaymentMode.price : '0.000',
priceName: 'Default',
priceUuid: priceForPaymentMode ? priceForPaymentMode.uuid : '',
lineItemOrder: index,
paymentStatus: billStatus,
};
});

const billPayload = {
lineItems: lineItems,
cashPoint: cashPointUuid,
patient: patientUuid,
status: billStatus,
payments: [],
};

setExtraVisitInfo({
handleCreateExtraVisitInfo: () => handleCreateBill(billPayload),
attributes,
});
};

useEffect(() => {
setExtraVisitInfo({
handleCreateExtraVisitInfo: () => {},
attributes,
});
}, []);

if (isLoadingLineItems || isLoadingCashPoints) {
return (
<InlineLoading
status="active"
iconDescription={t('loading', 'Loading')}
description={t('loadingBillingServices', 'Loading billing services...')}
/>
);
}

if (paymentMethod) {
lineList = [];
lineList = lineItems.filter((e) =>
e.servicePrices.some((p) => p.paymentMode && p.paymentMode.uuid === paymentMethod?.uuid),
);
}

if (cashError || lineError) {
return (
<InlineNotification
kind="error"
lowContrast
title={t('billErrorService', 'Bill service error')}
subtitle={t('errorLoadingBillServices', 'Error loading bill services')}
/>
);
}

return (
<>
<VisitAttributesForm setAttributes={setAttributes} setPaymentMethod={setPaymentMethod} />
<SHANumberValidity paymentMethod={paymentMethod} />
<section className={styles.sectionContainer}>
<div className={styles.sectionTitle}>{t('billing', 'Billing')}</div>
<div className={styles.sectionField}>
<FilterableMultiSelect
id="billing-service"
titleText={t('searchServices', 'Search services')}
items={lineItems ?? []}
itemToString={(item) => (item ? item?.name : '')}
onChange={({ selectedItems }) => handleBillingService(selectedItems)}
/>
</div>
</section>
</>
);
};

export default React.memo(BillingCheckInForm);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use '@carbon/layout';
@use '@carbon/type';
@use '@carbon/colors';

.sectionTitle {
@include type.type-style('heading-compact-02');
color: colors.$gray-70;
margin: 0 0 layout.$spacing-03 0;
flex-basis: 30%;
}

.sectionField {
flex-basis: 70%;
}
297 changes: 297 additions & 0 deletions packages/esm-billing-app/src/billing-form/billing-form.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import React, { useState } from 'react';
import {
ButtonSet,
Button,
RadioButtonGroup,
RadioButton,
Search,
Table,
TableHead,
TableBody,
TableHeader,
TableRow,
TableCell,
} from '@carbon/react';
import styles from './billing-form.scss';
import { useTranslation } from 'react-i18next';
import { useFetchSearchResults, processBillItems } from '../billing.resource';
import { getPatientUuidFromUrl } from '@openmrs/esm-patient-common-lib';
import { showSnackbar } from '@openmrs/esm-framework';
import { mutate } from 'swr';

type BillingFormProps = {
patientUuid: string;
closeWorkspace: () => void;
};

const BillingForm: React.FC<BillingFormProps> = ({ closeWorkspace }) => {
const { t } = useTranslation();
const patientUuid = getPatientUuidFromUrl();

const [GrandTotal, setGrandTotal] = useState(0);

const [searchOptions, setsearchOptions] = useState([]);
const [defaultSearchItems, setdefaultSearchItems] = useState([]);

const [BillItems, setBillItems] = useState([]);

const [searchVal, setsearchVal] = useState('');
const [category, setCategory] = useState('');

const toggleSearch = (choiceSelected) => {
(document.getElementById('searchField') as HTMLInputElement).disabled = false;

if (choiceSelected == 'Stock Item') {
setCategory('Stock Item');
} else {
setCategory('Service');
}
};

const calculateTotal = (event, itemName) => {
const Qnty = event.target.value;
const price = (document.getElementById(event.target.id + 'Price') as HTMLInputElement).innerHTML;
const total = parseInt(price) * Qnty;
(document.getElementById(event.target.id + 'Total') as HTMLInputElement).innerHTML = total.toString();

const updateItem = BillItems.filter((o) => o.Item.toLowerCase().includes(itemName.toLowerCase()));

updateItem.map((o) => (o.Qnty = Qnty));
updateItem.map((o) => (o.Total = total));

const totals = Array.from(document.querySelectorAll('[id$="Total"]'));

let addUpTotals = 0;
totals.forEach((tot) => {
let getTot = (tot as HTMLInputElement).innerHTML;
addUpTotals += parseInt(getTot);
});
setGrandTotal(addUpTotals);
};

const CalculateTotalAfteraddBillItem = () => {
let sum = 0;
BillItems.map((o) => (sum += o.Price));

setGrandTotal(sum);
};

const addItemToBill = (event, itemid, itemname, itemcategory, itemPrice) => {
BillItems.push({
uuid: itemid,
Item: itemname,
Qnty: 1,
Price: itemPrice,
Total: itemPrice,
category: itemcategory,
});
setBillItems(BillItems);
setsearchOptions([]);
CalculateTotalAfteraddBillItem();
(document.getElementById('searchField') as HTMLInputElement).value = '';
};

// filter items
const { data, error, isLoading, isValidating } = useFetchSearchResults(searchVal, category);

const filterItems = (val) => {
setsearchVal(val);

if (!isLoading) {
/* empty */
} else {
if (typeof data !== 'undefined') {
//set to null then repopulate
while (searchOptions.length > 0) {
searchOptions.pop();
}

const res = data as { results: any[] };

res.results.map((o) => {
if (o.commonName && (o.commonName != '' || o.commonName != null)) {
searchOptions.push({
uuid: o.uuid,
Item: o.commonName,
Qnty: 1,
Price: 10,
Total: 10,
category: 'StockItem',
});
} else {
if (o.name.toLowerCase().includes(searchVal.toLowerCase())) {
searchOptions.push({
uuid: o.uuid,
Item: o.name,
Qnty: 1,
Price: o.servicePrices[0].price,
Total: o.servicePrices[0].price,
category: 'Service',
});
}
}
setsearchOptions(searchOptions);
});
}
}
};

const postBillItems = () => {
const bill = {
cashPoint: '54065383-b4d4-42d2-af4d-d250a1fd2590',
cashier: 'f9badd80-ab76-11e2-9e96-0800200c9a66',
lineItems: [],
payments: [],
patient: patientUuid,
status: 'PENDING',
};

BillItems.map((o) => {
if (o.category == 'StockItem') {
bill.lineItems.push({
item: o.uuid,
quantity: parseInt(o.Qnty),
price: o.Price,
priceName: 'Default',
priceUuid: '7b9171ac-d3c1-49b4-beff-c9902aee5245',
lineItemOrder: 0,
paymentStatus: 'PENDING',
});
} else {
bill.lineItems.push({
billableService: o.uuid,
quantity: parseInt(o.Qnty),
price: o.Price,
priceName: 'Default',
priceUuid: '7b9171ac-d3c1-49b4-beff-c9902aee5245',
lineItemOrder: 0,
paymentStatus: 'PENDING',
});
}
});

const url = `/ws/rest/v1/cashier/bill`;
processBillItems(bill).then(
(resp) => {
closeWorkspace();
mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true });
showSnackbar({
title: t('billItems', 'Save Bill'),
subtitle: 'Bill processing has been successful',
kind: 'success',
timeoutInMs: 3000,
});
},
(error) => {
showSnackbar({ title: 'Bill processing error', kind: 'error', subtitle: error });
},
);
};

return (
<div className={styles.billingFormContainer}>
<RadioButtonGroup
legendText={t('selectCategory', 'Select category')}
name="radio-button-group"
defaultSelected="radio-1"
className={styles.billingItem}
onChange={toggleSearch}>
<RadioButton labelText={t('stockItem', 'Stock Item')} value="Stock Item" id="radio-1" />
<RadioButton labelText={t('service', 'Service')} value="Service" id="radio-2" />
</RadioButtonGroup>
<div></div>

<div>
<Search
id="searchField"
size="lg"
placeholder="Find your drugs here..."
labelText="Search"
disabled
closeButtonLabelText="Clear search input"
onChange={() => {}}
className={styles.billingItem}
onKeyUp={(e) => {
filterItems(e.target.value);
}}
/>

<ul className={styles.searchContent}>
{searchOptions.map((row) => (
<li className={styles.searchItem}>
<Button
id={row.uuid}
onClick={(e) => addItemToBill(e, row.uuid, row.Item, row.category, row.Price)}
style={{ background: 'inherit', color: 'black' }}>
{row.Item} Qnty.{row.Qnty} Ksh.{row.Price}
</Button>
</li>
))}
</ul>
</div>

{/* <NumberInput id="carbon-number" min={0} max={100} value={50} ref={numberRef}
onChange={(e)=> alert((numberRef.current as HTMLInputElement).value)}
className="testingNumberInput" label="NumberInput label" helperText="Optional helper text." invalidText="Number is not valid" /> */}

<Table aria-label="sample table" className={styles.billingItem}>
<TableHead>
<TableRow>
<TableHeader>Item</TableHeader>
<TableHeader>Quantity</TableHeader>
<TableHeader>Price</TableHeader>
<TableHeader>Total</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{BillItems && Array.isArray(BillItems) ? (
BillItems.map((row) => (
<TableRow>
<TableCell>{row.Item}</TableCell>
<TableCell>
<input
type="number"
className="form-control"
id={row.Item}
min={0}
max={100}
value={row.Qnty}
onChange={(e) => {
calculateTotal(e, row.Item);
row.Qnty = e.target.value;
}}
/>
{/* <NumberInput id={row.Item} min={0} max={100} value={row.Qnty} ref={numberRef}
onChange={(e)=> alert((numberRef.current as HTMLInputElement).value)} /> */}
</TableCell>
<TableCell id={row.Item + 'Price'}>{row.Price}</TableCell>
<TableCell id={row.Item + 'Total'} className="totalValue">
{row.Total}
</TableCell>
</TableRow>
))
) : (
<p>Loading...</p>
)}
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell style={{ fontWeight: 'bold' }}>Grand Total:</TableCell>
<TableCell id="GrandTotalSum">{GrandTotal}</TableCell>
</TableRow>
</TableBody>
</Table>

<ButtonSet className={styles.billingItem}>
<Button kind="secondary" onClick={closeWorkspace}>
Discard
</Button>
<Button kind="primary" onClick={postBillItems}>
Save & Close
</Button>
</ButtonSet>
</div>
);
};

export default BillingForm;
28 changes: 28 additions & 0 deletions packages/esm-billing-app/src/billing-form/billing-form.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@use '@carbon/layout';

.billingFormContainer {
padding: layout.$spacing-05;
}

.billingItem {
// padding: layout.$spacing-05;
margin-top: 30px;
}

.searchContent {
// display: none;
position: absolute;
background-color: #fff;
min-width: 230px;
overflow: auto;
border: 1px solid #ddd;
z-index: 1;
width: 92%;
}

.searchItem {
border-bottom: 0.1rem solid;
border-bottom-color: silver;
// padding: 10px;
cursor: pointer;
}
2 changes: 2 additions & 0 deletions packages/esm-billing-app/src/billing-form/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const hasPatientBeenExempted = (attributes: Array<any>, isPatientExempted: string): boolean =>
attributes.find(({ attributeType }) => attributeType === isPatientExempted)?.value === true;
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, TextInput, Button, InlineLoading, InlineNotification } from '@carbon/react';

type SHANumberValidityProps = {
paymentMethod: any;
};

const SHANumberValidity: React.FC<SHANumberValidityProps> = ({ paymentMethod }) => {
const { t } = useTranslation();
const [shaNumber, setNumber] = useState('');
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [validity, setValidity] = useState(false); // TODO: set validity based on api response
const isSHA = paymentMethod?.name === 'Social Health Insurance Fund (SHA)';

const handleValidateSHANumber = () => {
setIsLoading(true);
const randomNumber = Math.floor(Math.random() * 10) + 1;
// TODO call api to validate sha number
setTimeout(() => {
// TODO: set validity based on api response and update the expiry date based on the response as visit attribute
randomNumber % 2 === 0 ? setValidity(false) : setValidity(true);
setMessage(
randomNumber % 2 === 0
? t('invalidSHANumber', 'SHA number is invalid, advice patient to update payment or contact SHA')
: t('validSHANumber', 'SHA number is valid, proceed with care'),
);
setIsLoading(false);
}, 1500);
};

if (!isSHA) {
return null;
}

return (
<Form>
<TextInput
id="sha-number"
onChange={(e) => setNumber(e.target.value)}
labelText={t('shaNumber', 'SHA Number')}
placeholder={t('enterSHANumber', 'Enter SHA Number')}
/>
{isLoading ? (
<InlineLoading
style={{ minHeight: '3rem', marginTop: '0.625rem' }}
status="active"
iconDescription="Loading"
description={t('validatingSHANumber', 'Validating SHA Number')}
/>
) : (
<Button
disabled={shaNumber.length === 0}
kind="tertiary"
style={{ marginTop: '0.625rem' }}
onClick={handleValidateSHANumber}>
{t('checkValidity', 'Check Validity')}
</Button>
)}
{message !== '' && (
<p style={{ marginTop: '0.625rem' }}>
<InlineNotification
aria-label="closes notification"
kind={validity ? 'success' : 'error'}
statusIconDescription="notification"
subtitle={message}
title={validity ? t('valid', 'Valid SHA Number') : t('shaNotValid', 'Invalid SHA Number')}
lowContrast={true}
/>
</p>
)}
</Form>
);
};

export default SHANumberValidity;
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './visit-attributes-form.scss';
import { TextInput, InlineLoading, ComboBox, RadioButtonGroup, RadioButton } from '@carbon/react';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useConfig } from '@openmrs/esm-framework';
import { type BillingConfig } from '../../config-schema';
import { usePaymentModes } from '../../billing.resource';

type VisitAttributesFormProps = {
setAttributes: (state) => void;
setPaymentMethod?: (value: any) => void;
};

type VisitAttributesFormValue = {
isPatientExempted: string;
paymentMethods: { uuid: string; name: string };
insuranceScheme: string;
policyNumber: string;
exemptionCategory: string;
};

const visitAttributesFormSchema = z.object({
isPatientExempted: z.string(),
paymentMethods: z.object({ uuid: z.string(), name: z.string() }),
insuranceSchema: z.string(),
policyNumber: z.string(),
exemptionCategory: z.string(),
});

const VisitAttributesForm: React.FC<VisitAttributesFormProps> = ({ setAttributes, setPaymentMethod }) => {
const { t } = useTranslation();
const { visitAttributeTypes, patientExemptionCategories } = useConfig<BillingConfig>();
const { control, getValues, watch, setValue } = useForm<VisitAttributesFormValue>({
mode: 'all',
defaultValues: {},
resolver: zodResolver(visitAttributesFormSchema),
});
const [isPatientExempted, paymentMethods, insuranceSchema, policyNumber, exemptionCategory] = watch([
'isPatientExempted',
'paymentMethods',
'insuranceScheme',
'policyNumber',
'exemptionCategory',
]);

const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes();

const resetFormFieldsForNonExemptedPatients = useCallback(() => {
if ((isPatientExempted && paymentMethods !== null) || paymentMethods !== undefined) {
setValue('insuranceScheme', '');
setValue('policyNumber', '');
}
}, [isPatientExempted, paymentMethods, setValue]);

const createVisitAttributesPayload = useCallback(() => {
const { exemptionCategory, paymentMethods, policyNumber, isPatientExempted } = getValues();
setPaymentMethod(paymentMethods);
resetFormFieldsForNonExemptedPatients();
const formPayload = [
{ uuid: visitAttributeTypes.isPatientExempted, value: isPatientExempted },
{ uuid: visitAttributeTypes.paymentMethods, value: paymentMethods?.uuid },
{ uuid: visitAttributeTypes.policyNumber, value: policyNumber },
{ uuid: visitAttributeTypes.insuranceScheme, value: insuranceSchema },
{ uuid: visitAttributeTypes.exemptionCategory, value: exemptionCategory },
];
const visitAttributesPayload = formPayload.filter(
(item) => item.value !== undefined && item.value !== null && item.value !== '',
);
return Object.entries(visitAttributesPayload).map(([key, value]) => ({
attributeType: value.uuid,
value: value.value,
}));
}, [
visitAttributeTypes.insuranceScheme,
visitAttributeTypes.isPatientExempted,
visitAttributeTypes.exemptionCategory,
visitAttributeTypes.paymentMethods,
visitAttributeTypes.policyNumber,
getValues,
insuranceSchema,
resetFormFieldsForNonExemptedPatients,
setPaymentMethod,
]);

React.useEffect(() => {
setAttributes(createVisitAttributesPayload());
}, [paymentMethods, insuranceSchema, policyNumber, exemptionCategory, setAttributes, createVisitAttributesPayload]);

if (isLoadingPaymentModes) {
return (
<InlineLoading
status="active"
iconDescription={t('loadingDescription', 'Loading')}
description={t('loading', 'Loading data...')}
/>
);
}

return (
<section>
<div className={styles.sectionTitle}>{t('billing', 'Billing')}</div>
<div className={styles.sectionField}>
<div className={styles.sectionFieldLayer}>
<Controller
name="isPatientExempted"
control={control}
render={({ field }) => (
<RadioButtonGroup
onChange={(selected) => field.onChange(selected)}
orientation="horizontal"
legendText={t('isPatientExemptedLegend', 'Is patient exempted from payment?')}
name="patientExemption">
<RadioButton labelText="Yes" value={true} id="Yes" />
<RadioButton labelText="No" value={false} id="No" />
</RadioButtonGroup>
)}
/>
</div>
{isPatientExempted && (
<div className={styles.sectionFieldLayer}>
<Controller
control={control}
name="exemptionCategory"
render={({ field }) => (
<ComboBox
className={styles.sectionField}
onChange={({ selectedItem }) => field.onChange(selectedItem?.uuid)}
id="exemptionCategory"
items={patientExemptionCategories}
itemToString={(item) => (item ? item.label : '')}
titleText={t('exemptionCategory', 'Exemption category')}
placeholder={t('selectExemptionCategory', 'Select exemption category')}
/>
)}
/>
</div>
)}
<div className={styles.sectionFieldLayer}>
<Controller
control={control}
name="paymentMethods"
render={({ field }) => (
<ComboBox
className={styles.sectionField}
onChange={({ selectedItem }) => field.onChange(selectedItem)}
id="paymentMethods"
items={paymentModes}
itemToString={(item) => (item ? item.name : '')}
titleText={t('paymentMethodsTitle', 'Payment methods')}
placeholder={t('selectPaymentMethodPlaceholder', 'Select payment method')}
/>
)}
/>
</div>

{paymentMethods?.name?.toLocaleLowerCase() === 'insurance' && (
<>
<div className={styles.sectionFieldLayer}>
<Controller
control={control}
name="insuranceScheme"
render={({ field }) => (
<TextInput
className={styles.sectionField}
onChange={(e) => field.onChange(e.target.value)}
id="insurance-scheme"
type="text"
labelText={t('insuranceScheme', 'Insurance scheme')}
/>
)}
/>
</div>
<div className={styles.sectionFieldLayer}>
<Controller
control={control}
name="policyNumber"
render={({ field }) => (
<TextInput
className={styles.sectionField}
onChange={(e) => field.onChange(e.target.value)}
id="policy-number"
type="text"
labelText={t('policyNumber', 'Policy number')}
/>
)}
/>
</div>
</>
)}
</div>
</section>
);
};

export default VisitAttributesForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@use '@carbon/layout';
@use '@carbon/type';
@use '@carbon/colors';

.sectionTitle {
@include type.type-style('heading-compact-02');
color: colors.$gray-70;
margin: 0 0 layout.$spacing-03 0;
flex-basis: 30%;
}

.sectionField {
flex-basis: 70%;
}

.sectionFieldLayer {
margin-bottom: 0.5rem;
}
Loading

0 comments on commit 80e17ee

Please sign in to comment.