@openmrs/esm-billing-app 1.1.1 → 1.1.2-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.turbo/cache/53e233a916ffe7d2-meta.json +1 -0
  2. package/.turbo/cache/53e233a916ffe7d2.tar.zst +0 -0
  3. package/.turbo/turbo-build.log +44 -0
  4. package/__mocks__/bills.mock.ts +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1197.js +1 -1
  7. package/dist/1435.js +1 -0
  8. package/dist/1435.js.map +1 -0
  9. package/dist/1807.js +1 -0
  10. package/dist/1807.js.map +1 -0
  11. package/dist/2146.js +1 -1
  12. package/dist/2177.js +1 -1
  13. package/dist/2177.js.map +1 -1
  14. package/dist/2690.js +1 -1
  15. package/dist/2704.js +1 -0
  16. package/dist/2704.js.map +1 -0
  17. package/dist/3002.js +1 -0
  18. package/dist/3002.js.map +1 -0
  19. package/dist/3041.js +1 -1
  20. package/dist/3041.js.map +1 -1
  21. package/dist/3099.js +1 -1
  22. package/dist/3184.js +1 -1
  23. package/dist/3184.js.map +1 -1
  24. package/dist/3584.js +1 -1
  25. package/dist/4055.js +1 -1
  26. package/dist/4132.js +1 -1
  27. package/dist/4225.js +1 -1
  28. package/dist/4225.js.map +1 -1
  29. package/dist/4300.js +1 -1
  30. package/dist/4335.js +1 -1
  31. package/dist/439.js +1 -1
  32. package/dist/4618.js +1 -1
  33. package/dist/4652.js +1 -1
  34. package/dist/4944.js +1 -1
  35. package/dist/5173.js +1 -1
  36. package/dist/5241.js +1 -1
  37. package/dist/5422.js +1 -1
  38. package/dist/5422.js.map +1 -1
  39. package/dist/5442.js +1 -1
  40. package/dist/5661.js +1 -1
  41. package/dist/6022.js +1 -1
  42. package/dist/6404.js +1 -0
  43. package/dist/6404.js.map +1 -0
  44. package/dist/6468.js +1 -1
  45. package/dist/6540.js +1 -1
  46. package/dist/6540.js.map +1 -1
  47. package/dist/6589.js +1 -1
  48. package/dist/6606.js +1 -1
  49. package/dist/6606.js.map +1 -1
  50. package/dist/6679.js +1 -1
  51. package/dist/6792.js +1 -0
  52. package/dist/6792.js.map +1 -0
  53. package/dist/6840.js +1 -1
  54. package/dist/6859.js +1 -1
  55. package/dist/7097.js +1 -1
  56. package/dist/7159.js +1 -1
  57. package/dist/723.js +1 -1
  58. package/dist/7255.js +1 -1
  59. package/dist/7255.js.map +1 -1
  60. package/dist/7617.js +1 -1
  61. package/dist/795.js +1 -1
  62. package/dist/8163.js +1 -1
  63. package/dist/8341.js +2 -0
  64. package/dist/{1907.js.LICENSE.txt → 8341.js.LICENSE.txt} +0 -15
  65. package/dist/8341.js.map +1 -0
  66. package/dist/8349.js +1 -1
  67. package/dist/8371.js +1 -1
  68. package/dist/8421.js +1 -0
  69. package/dist/8421.js.map +1 -0
  70. package/dist/8618.js +1 -1
  71. package/dist/890.js +1 -1
  72. package/dist/9214.js +1 -1
  73. package/dist/9538.js +1 -1
  74. package/dist/9569.js +1 -1
  75. package/dist/961.js +1 -1
  76. package/dist/961.js.map +1 -1
  77. package/dist/986.js +1 -1
  78. package/dist/9879.js +1 -1
  79. package/dist/9895.js +1 -1
  80. package/dist/9900.js +1 -1
  81. package/dist/9913.js +1 -1
  82. package/dist/main.js +1 -1
  83. package/dist/main.js.LICENSE.txt +0 -15
  84. package/dist/main.js.map +1 -1
  85. package/dist/openmrs-esm-billing-app.js +1 -1
  86. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +284 -259
  87. package/dist/openmrs-esm-billing-app.js.map +1 -1
  88. package/dist/routes.json +1 -1
  89. package/e2e/commands/patient-operations.ts +1 -1
  90. package/e2e/pages/billing-dashboard-page.ts +3 -1
  91. package/e2e/pages/billing-form-page.ts +5 -0
  92. package/e2e/pages/invoice-page.ts +10 -0
  93. package/e2e/specs/billing-dashboard.spec.ts +126 -3
  94. package/e2e/specs/billing-patient-chart.spec.ts +95 -9
  95. package/package.json +3 -6
  96. package/src/bill-history/bill-action-menu.component.tsx +41 -0
  97. package/src/bill-history/bill-action-menu.scss +3 -0
  98. package/src/bill-history/bill-history.component.tsx +15 -5
  99. package/src/bill-history/bill-history.scss +0 -1
  100. package/src/bill-history/bill-history.test.tsx +78 -1
  101. package/src/bill-item-actions/edit-bill-item.modal.tsx +1 -1
  102. package/src/bill-item-actions/edit-bill-item.test.tsx +40 -0
  103. package/src/billable-services/bill-waiver/bill-waiver.component.tsx +3 -1
  104. package/src/billing-dashboard/billing-dashboard.component.tsx +3 -16
  105. package/src/billing-form/billing-checkin-form.component.tsx +116 -57
  106. package/src/billing-form/billing-checkin-form.scss +26 -2
  107. package/src/billing-form/billing-checkin-form.test.tsx +51 -1
  108. package/src/billing-form/billing-form.resource.test.ts +87 -0
  109. package/src/billing-form/billing-form.resource.ts +33 -0
  110. package/src/billing-form/billing-form.scss +54 -7
  111. package/src/billing-form/billing-form.test.tsx +547 -0
  112. package/src/billing-form/billing-form.workspace.tsx +150 -45
  113. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +25 -2
  114. package/src/billing-form/visit-attributes/visit-attributes-form.scss +29 -0
  115. package/src/billing-header/billing-header.component.tsx +1 -34
  116. package/src/billing-header/billing-header.scss +0 -50
  117. package/src/billing.resource.test.ts +11 -11
  118. package/src/billing.resource.ts +42 -12
  119. package/src/bills-table/bills-table.component.tsx +16 -12
  120. package/src/bills-table/bills-table.test.tsx +84 -7
  121. package/src/index.ts +5 -0
  122. package/src/invoice/invoice.component.tsx +46 -16
  123. package/src/invoice/invoice.scss +9 -8
  124. package/src/invoice/invoice.test.tsx +128 -7
  125. package/src/invoice/line-item-action-menu.component.tsx +2 -2
  126. package/src/invoice/payments/payments.component.tsx +2 -2
  127. package/src/invoice/payments/payments.test.tsx +31 -2
  128. package/src/metrics-cards/metrics.resource.ts +3 -4
  129. package/src/modal/finalize-bill-confirmation.modal.test.tsx +209 -0
  130. package/src/modal/finalize-bill-confirmation.modal.tsx +86 -0
  131. package/src/modal/require-payment.modal.tsx +2 -1
  132. package/src/routes.json +4 -0
  133. package/src/types/index.ts +10 -1
  134. package/tools/setup-tests.ts +7 -6
  135. package/translations/am.json +28 -0
  136. package/translations/ar.json +28 -0
  137. package/translations/ar_SY.json +28 -0
  138. package/translations/bn.json +28 -0
  139. package/translations/cs.json +28 -0
  140. package/translations/de.json +266 -238
  141. package/translations/en.json +29 -0
  142. package/translations/en_US.json +28 -0
  143. package/translations/es.json +28 -0
  144. package/translations/es_MX.json +28 -0
  145. package/translations/fr.json +28 -0
  146. package/translations/he.json +28 -0
  147. package/translations/hi.json +28 -0
  148. package/translations/hi_IN.json +28 -0
  149. package/translations/id.json +28 -0
  150. package/translations/it.json +28 -0
  151. package/translations/ka.json +28 -0
  152. package/translations/km.json +28 -0
  153. package/translations/ku.json +28 -0
  154. package/translations/ky.json +28 -0
  155. package/translations/lg.json +28 -0
  156. package/translations/ne.json +28 -0
  157. package/translations/pl.json +28 -0
  158. package/translations/pt.json +28 -0
  159. package/translations/pt_BR.json +28 -0
  160. package/translations/qu.json +28 -0
  161. package/translations/ro_RO.json +28 -0
  162. package/translations/ru_RU.json +28 -0
  163. package/translations/si.json +28 -0
  164. package/translations/sq.json +28 -0
  165. package/translations/sw.json +28 -0
  166. package/translations/sw_KE.json +28 -0
  167. package/translations/tr.json +28 -0
  168. package/translations/tr_TR.json +28 -0
  169. package/translations/uk.json +28 -0
  170. package/translations/uz.json +28 -0
  171. package/translations/uz@Latn.json +28 -0
  172. package/translations/uz_UZ.json +28 -0
  173. package/translations/vi.json +28 -0
  174. package/translations/zh.json +268 -240
  175. package/translations/zh_CN.json +30 -2
  176. package/translations/zh_TW.json +28 -0
  177. package/turbo.json +29 -0
  178. package/dist/1537.js +0 -1
  179. package/dist/1537.js.map +0 -1
  180. package/dist/1907.js +0 -2
  181. package/dist/1907.js.map +0 -1
  182. package/dist/1981.js +0 -1
  183. package/dist/1981.js.map +0 -1
  184. package/dist/2820.js +0 -1
  185. package/dist/2820.js.map +0 -1
  186. package/dist/8025.js +0 -1
  187. package/dist/8025.js.map +0 -1
  188. package/dist/9727.js +0 -2
  189. package/dist/9727.js.LICENSE.txt +0 -14
  190. package/dist/9727.js.map +0 -1
  191. package/dist/9756.js +0 -1
  192. package/dist/9756.js.map +0 -1
  193. package/src/hooks/selectedDateContext.ts +0 -10
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import {
4
4
  Button,
@@ -25,6 +25,7 @@ import {
25
25
  EmptyCardIllustration,
26
26
  ErrorState,
27
27
  formatDate,
28
+ getCoreTranslation,
28
29
  launchWorkspace2,
29
30
  parseDate,
30
31
  useConfig,
@@ -33,7 +34,9 @@ import {
33
34
  } from '@openmrs/esm-framework';
34
35
  import { useBills } from '../billing.resource';
35
36
  import { convertToCurrency } from '../helpers';
37
+ import BillActionMenu from './bill-action-menu.component';
36
38
  import InvoiceTable from '../invoice/invoice-table.component';
39
+ import { BillStatus } from '../types';
37
40
  import styles from './bill-history.scss';
38
41
 
39
42
  interface BillHistoryProps {
@@ -47,6 +50,7 @@ const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
47
50
  const { pageSize, defaultCurrency } = useConfig();
48
51
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
49
52
  const { pageSizes } = usePaginationInfo(pageSize, bills?.length, currentPage, results?.length);
53
+ const billsByUuid = useMemo(() => new Map(bills?.map((bill) => [bill.uuid, bill])), [bills]);
50
54
 
51
55
  const headerData = [
52
56
  {
@@ -116,7 +120,7 @@ const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
116
120
  })
117
121
  }
118
122
  kind="ghost">
119
- {t('addBillItems', 'Add bill items')}
123
+ {t('createBill', 'Create bill')}
120
124
  </Button>
121
125
  </Tile>
122
126
  </Layer>
@@ -140,7 +144,7 @@ const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
140
144
  })
141
145
  }
142
146
  renderIcon={Add}>
143
- {t('addBillItems', 'Add bill items')}
147
+ {t('createBill', 'Create bill')}
144
148
  </Button>
145
149
  </CardHeader>
146
150
  <div className={styles.billHistoryContainer}>
@@ -168,11 +172,12 @@ const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
168
172
  {header.header}
169
173
  </TableHeader>
170
174
  ))}
175
+ <TableHeader aria-label={getCoreTranslation('actions')} />
171
176
  </TableRow>
172
177
  </TableHead>
173
178
  <TableBody>
174
179
  {rows.map((row, i) => {
175
- const currentBill = bills?.find((bill) => bill.uuid === row.id);
180
+ const currentBill = billsByUuid.get(row.id);
176
181
 
177
182
  return (
178
183
  <React.Fragment key={row.id}>
@@ -182,9 +187,14 @@ const BillHistory: React.FC<BillHistoryProps> = ({ patientUuid }) => {
182
187
  {cell.value}
183
188
  </TableCell>
184
189
  ))}
190
+ <TableCell className="cds--table-column-menu">
191
+ {currentBill?.status === BillStatus.PENDING && (
192
+ <BillActionMenu bill={currentBill} patientUuid={patientUuid} onMutate={mutate} />
193
+ )}
194
+ </TableCell>
185
195
  </TableExpandRow>
186
196
  {row.isExpanded ? (
187
- <TableExpandedRow colSpan={headers.length + 1}>
197
+ <TableExpandedRow colSpan={headers.length + 2}>
188
198
  <div className={styles.container} key={i}>
189
199
  <InvoiceTable bill={currentBill} onMutate={mutate} isLoadingBill={isValidating} />
190
200
  </div>
@@ -55,7 +55,6 @@
55
55
 
56
56
  .table {
57
57
  width: 100%;
58
- table-layout: fixed; // This helps with uniform column sizing
59
58
  border-collapse: collapse;
60
59
 
61
60
  td:has(:global(.billingTable)) {
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { render, screen } from '@testing-library/react';
4
- import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework';
4
+ import { getDefaultsFromConfigSchema, launchWorkspace2, useConfig } from '@openmrs/esm-framework';
5
5
  import { configSchema, type BillingConfig } from '../config-schema';
6
6
  import { useBills } from '../billing.resource';
7
7
  import BillHistory from './bill-history.component';
@@ -120,4 +120,81 @@ describe('BillHistory', () => {
120
120
  const emptyState = screen.getByText(/There are no bills to display./);
121
121
  expect(emptyState).toBeInTheDocument();
122
122
  });
123
+
124
+ test('should show overflow menu with "Add items to bill" for PENDING bills', async () => {
125
+ const user = userEvent.setup();
126
+ const pendingBill = {
127
+ ...mockBillData[0],
128
+ status: 'PENDING',
129
+ dateCreated: '2024-01-01',
130
+ receiptNumber: 'REC-001',
131
+ lineItems: [{ uuid: 'item-1', item: 'Test', quantity: 1, price: 100, paymentStatus: 'PENDING' }],
132
+ };
133
+ mockUseBills.mockReturnValueOnce({
134
+ isLoading: false,
135
+ isValidating: false,
136
+ error: null,
137
+ bills: [pendingBill] as any,
138
+ mutate: jest.fn(),
139
+ });
140
+ render(<BillHistory {...testProps} />);
141
+
142
+ const overflowMenu = screen.getByTestId('action-menu-1');
143
+ await user.click(overflowMenu);
144
+
145
+ expect(screen.getByText(/add items to bill/i)).toBeInTheDocument();
146
+ });
147
+
148
+ test('should not show overflow menu for PAID bills', () => {
149
+ const paidBill = {
150
+ ...mockBillData[0],
151
+ status: 'PAID',
152
+ dateCreated: '2024-01-01',
153
+ receiptNumber: 'REC-001',
154
+ lineItems: [{ uuid: 'item-1', item: 'Test', quantity: 1, price: 100, paymentStatus: 'PAID' }],
155
+ };
156
+ mockUseBills.mockReturnValueOnce({
157
+ isLoading: false,
158
+ isValidating: false,
159
+ error: null,
160
+ bills: [paidBill] as any,
161
+ mutate: jest.fn(),
162
+ });
163
+ render(<BillHistory {...testProps} />);
164
+
165
+ expect(screen.queryByTestId('action-menu-1')).not.toBeInTheDocument();
166
+ });
167
+
168
+ test('should launch workspace with billUuid when "Add items to bill" is clicked', async () => {
169
+ const user = userEvent.setup();
170
+ const mockMutate = jest.fn();
171
+ const mockLaunchWorkspace2 = jest.mocked(launchWorkspace2);
172
+ const pendingBill = {
173
+ ...mockBillData[0],
174
+ status: 'PENDING',
175
+ dateCreated: '2024-01-01',
176
+ receiptNumber: 'REC-001',
177
+ lineItems: [{ uuid: 'item-1', item: 'Test', quantity: 1, price: 100, paymentStatus: 'PENDING' }],
178
+ };
179
+ mockUseBills.mockReturnValueOnce({
180
+ isLoading: false,
181
+ isValidating: false,
182
+ error: null,
183
+ bills: [pendingBill] as any,
184
+ mutate: mockMutate,
185
+ });
186
+ render(<BillHistory {...testProps} />);
187
+
188
+ const overflowMenu = screen.getByTestId('action-menu-1');
189
+ await user.click(overflowMenu);
190
+
191
+ const addItemsMenuItem = screen.getByText(/add items to bill/i);
192
+ await user.click(addItemsMenuItem);
193
+
194
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith('billing-form-workspace', {
195
+ patientUuid: 'some-uuid',
196
+ billUuid: '1',
197
+ onMutate: mockMutate,
198
+ });
199
+ });
123
200
  });
@@ -55,7 +55,7 @@ const EditBillLineItemModal: React.FC<EditBillLineItemModalProps> = ({ bill, clo
55
55
  required_error: t('priceIsRequired', 'Price is required'),
56
56
  invalid_type_error: t('priceMustBeNumber', 'Price must be a valid number'),
57
57
  })
58
- .positive(t('priceMustBePositive', 'Price must be greater than 0')),
58
+ .min(0, t('priceMustBeNonNegative', 'Price must be 0 or greater')),
59
59
  }),
60
60
  [t],
61
61
  );
@@ -334,6 +334,46 @@ describe('EditBillItem', () => {
334
334
  });
335
335
  });
336
336
 
337
+ test('allows updating the quantity of a zero-price (free) service while keeping price at zero', async () => {
338
+ const user = userEvent.setup();
339
+ mockUpdateBillItems.mockResolvedValueOnce({} as FetchResponse<any>);
340
+
341
+ const freeServiceItem = { ...mockItem, price: 0 };
342
+ const billWithFreeItem: MappedBill = {
343
+ ...mockBill,
344
+ lineItems: [{ ...mockBill.lineItems[0], price: 0 }],
345
+ };
346
+
347
+ render(
348
+ <EditBillLineItemModal
349
+ bill={billWithFreeItem}
350
+ item={freeServiceItem}
351
+ closeModal={mockCloseModal}
352
+ onMutate={mockOnMutate}
353
+ />,
354
+ );
355
+
356
+ expect(screen.getByLabelText(/unit price/i)).toHaveValue('0');
357
+
358
+ const quantityInput = screen.getByRole('spinbutton', { name: /quantity/i });
359
+ await user.clear(quantityInput);
360
+ await user.type(quantityInput, '5');
361
+
362
+ expect(screen.getByText(/total/i)).toHaveTextContent(/0/);
363
+
364
+ await user.click(screen.getByText(/save/i));
365
+
366
+ await waitFor(() => {
367
+ expect(mockUpdateBillItems).toHaveBeenCalled();
368
+ const payload = mockUpdateBillItems.mock.calls[0][0];
369
+ const updatedItem = payload.lineItems.find((li) => li.uuid === freeServiceItem.uuid);
370
+ expect(updatedItem?.quantity).toBe(5);
371
+ expect(updatedItem?.price).toBe(0);
372
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
373
+ expect(mockCloseModal).toHaveBeenCalled();
374
+ });
375
+ });
376
+
337
377
  test('shows validation error when quantity field is left empty', async () => {
338
378
  const user = userEvent.setup();
339
379
  render(
@@ -2,13 +2,15 @@ import React, { useState } from 'react';
2
2
  import { ExtensionSlot, UserHasAccess } from '@openmrs/esm-framework';
3
3
  import { useBills } from '../../billing.resource';
4
4
  import PatientBills from './patient-bills.component';
5
+ import { BillStatus } from '../../types';
5
6
  import styles from './bill-waiver.scss';
6
7
 
7
8
  const BillWaiver: React.FC = () => {
8
9
  const [patientUuid, setPatientUuid] = useState<string>('');
9
10
  const { bills } = useBills(patientUuid);
10
11
 
11
- const filterBills = bills.filter((bill) => bill?.status !== 'PAID' && patientUuid === bill.patientUuid) ?? [];
12
+ const filterBills =
13
+ bills.filter((bill) => bill?.status !== BillStatus.PAID && patientUuid === bill.patientUuid) ?? [];
12
14
 
13
15
  return (
14
16
  <UserHasAccess privilege="coreapps.systemAdministration">
@@ -1,27 +1,14 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { useParams } from 'react-router-dom';
4
- import dayjs from 'dayjs';
5
3
  import BillingHeader from '../billing-header/billing-header.component';
6
4
  import BillsTable from '../bills-table/bills-table.component';
7
- import SelectedDateContext from '../hooks/selectedDateContext';
8
- import { omrsDateFormat } from '../constants';
9
5
  import styles from './billing-dashboard.scss';
10
6
 
11
7
  export function BillingDashboard() {
12
8
  const { t } = useTranslation();
13
- const [selectedDate, setSelectedDate] = useState<string>(dayjs().startOf('day').format(omrsDateFormat));
14
-
15
- const params = useParams();
16
-
17
- useEffect(() => {
18
- if (params.date) {
19
- setSelectedDate(dayjs(params.date).startOf('day').format(omrsDateFormat));
20
- }
21
- }, [params.date]);
22
9
 
23
10
  return (
24
- <SelectedDateContext.Provider value={{ selectedDate, setSelectedDate }}>
11
+ <>
25
12
  <BillingHeader title={t('home', 'Home')} />
26
13
  {/**
27
14
  *
@@ -32,6 +19,6 @@ export function BillingDashboard() {
32
19
  <section className={styles.billsTableContainer}>
33
20
  <BillsTable />
34
21
  </section>
35
- </SelectedDateContext.Provider>
22
+ </>
36
23
  );
37
24
  }
@@ -1,13 +1,12 @@
1
- import React, { useCallback, useState } from 'react';
2
- import { Dropdown, InlineLoading, InlineNotification } from '@carbon/react';
1
+ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react';
3
2
  import { useTranslation } from 'react-i18next';
4
- import { showSnackbar, getCoreTranslation } from '@openmrs/esm-framework';
5
- import { useCashPoint, useBillableItems, createPatientBill } from './billing-form.resource';
3
+ import { Dropdown, InlineLoading, InlineNotification } from '@carbon/react';
4
+ import { showSnackbar, getCoreTranslation, useConfig } from '@openmrs/esm-framework';
5
+ import { useCashPoint, useBillableItems, createPatientBill, useLastVisitInfo } from './billing-form.resource';
6
6
  import VisitAttributesForm from './visit-attributes/visit-attributes-form.component';
7
+ import { BillStatus } from '../types';
7
8
  import styles from './billing-checkin-form.scss';
8
9
 
9
- const PENDING_PAYMENT_STATUS = 'PENDING';
10
-
11
10
  type BillingCheckInFormProps = {
12
11
  patientUuid: string;
13
12
  setExtraVisitInfo: (state) => void;
@@ -15,11 +14,53 @@ type BillingCheckInFormProps = {
15
14
 
16
15
  const BillingCheckInForm: React.FC<BillingCheckInFormProps> = ({ patientUuid, setExtraVisitInfo }) => {
17
16
  const { t } = useTranslation();
17
+ const { categoryConcepts } = useConfig();
18
+
19
+ const { lastVisitInfo, isLoading: isLoadingLastVisitInfo, error: lastVisitError } = useLastVisitInfo(patientUuid);
20
+
21
+ useEffect(() => {
22
+ if (lastVisitError) {
23
+ showSnackbar({
24
+ title: t('lastVisitError', 'Last visit error'),
25
+ subtitle: t('errorLoadingLastVisit', 'An error occurred while loading the last visit'),
26
+ kind: 'error',
27
+ });
28
+ }
29
+ }, [lastVisitError, t]);
30
+
18
31
  const { cashPoints, isLoading: isLoadingCashPoints, error: cashError } = useCashPoint();
19
32
  const { lineItems, isLoading: isLoadingLineItems, error: lineError } = useBillableItems();
33
+
20
34
  const [attributes, setAttributes] = useState([]);
21
- const [paymentMethod, setPaymentMethod] = useState<any>();
22
- let lineList = [];
35
+ const [paymentMethod, setPaymentMethod] = useState<string | null>(null);
36
+ const [selectedBillableItem, setSelectedBillableItem] = useState<any | null>(null);
37
+
38
+ const attributesRef = useRef(attributes);
39
+ useEffect(() => {
40
+ attributesRef.current = attributes;
41
+ }, [attributes]);
42
+
43
+ const isNonPaying = useMemo(() => {
44
+ if (!categoryConcepts?.nonPayingDetails || !attributes?.length) return false;
45
+ return attributes.some((attr) => attr.value === categoryConcepts.nonPayingDetails);
46
+ }, [attributes, categoryConcepts]);
47
+
48
+ const lineList = useMemo(() => {
49
+ if (isNonPaying) return [];
50
+ if (!paymentMethod || !lineItems?.length) return [];
51
+
52
+ return lineItems.filter((e) => e.servicePrices.some((p) => p.paymentMode?.uuid === paymentMethod));
53
+ }, [lineItems, paymentMethod, isNonPaying]);
54
+
55
+ // reset bill and selection when payment changes or on switching to non-paying
56
+ useEffect(() => {
57
+ setExtraVisitInfo({
58
+ createBillPayload: null,
59
+ handleCreateExtraVisitInfo: null,
60
+ attributes: attributesRef.current,
61
+ });
62
+ setSelectedBillableItem(null);
63
+ }, [paymentMethod, isNonPaying, setExtraVisitInfo]);
23
64
 
24
65
  const handleCreateExtraVisitInfo = useCallback(
25
66
  async (createBillPayload) => {
@@ -41,38 +82,42 @@ const BillingCheckInForm: React.FC<BillingCheckInFormProps> = ({ patientUuid, se
41
82
  [t],
42
83
  );
43
84
 
44
- const handleBillingService = ({ selectedItem }) => {
45
- const cashPointUuid = cashPoints?.[0]?.uuid ?? '';
46
- const itemUuid = selectedItem?.uuid ?? '';
47
-
48
- // should default to first price if check returns empty. todo - update backend to return default price
49
- const priceForPaymentMode =
50
- selectedItem.servicePrices.find((p) => p.paymentMode?.uuid === paymentMethod) || selectedItem?.servicePrices[0];
51
-
52
- const createBillPayload = {
53
- lineItems: [
54
- {
55
- billableService: itemUuid,
56
- quantity: 1,
57
- price: priceForPaymentMode ? priceForPaymentMode.price : '0.000',
58
- priceName: 'Default',
59
- priceUuid: priceForPaymentMode ? priceForPaymentMode.uuid : '',
60
- lineItemOrder: 0,
61
- paymentStatus: PENDING_PAYMENT_STATUS,
62
- },
63
- ],
64
- cashPoint: cashPointUuid,
65
- patient: patientUuid,
66
- status: PENDING_PAYMENT_STATUS,
67
- payments: [],
68
- };
85
+ const handleBillingService = useCallback(
86
+ ({ selectedItem }: { selectedItem }) => {
87
+ setSelectedBillableItem(selectedItem);
69
88
 
70
- setExtraVisitInfo({
71
- createBillPayload,
72
- handleCreateExtraVisitInfo: () => handleCreateExtraVisitInfo(createBillPayload),
73
- attributes,
74
- });
75
- };
89
+ const cashPointUuid = cashPoints?.[0]?.uuid ?? '';
90
+ const itemUuid = selectedItem?.uuid ?? '';
91
+
92
+ // should default to first price if check returns empty. todo - update backend to return default price
93
+ const priceForPaymentMode =
94
+ selectedItem.servicePrices.find((p) => p.paymentMode?.uuid === paymentMethod) || selectedItem?.servicePrices[0];
95
+
96
+ const createBillPayload = {
97
+ lineItems: [
98
+ {
99
+ billableService: itemUuid,
100
+ quantity: 1,
101
+ price: priceForPaymentMode ? priceForPaymentMode.price : '0.000',
102
+ priceName: 'Default',
103
+ priceUuid: priceForPaymentMode ? priceForPaymentMode.uuid : '',
104
+ lineItemOrder: 0,
105
+ paymentStatus: BillStatus.PENDING,
106
+ },
107
+ ],
108
+ cashPoint: cashPointUuid,
109
+ patient: patientUuid,
110
+ status: BillStatus.PENDING,
111
+ payments: [],
112
+ };
113
+ setExtraVisitInfo({
114
+ createBillPayload,
115
+ handleCreateExtraVisitInfo: () => handleCreateExtraVisitInfo(createBillPayload),
116
+ attributes,
117
+ });
118
+ },
119
+ [attributes, cashPoints, handleCreateExtraVisitInfo, paymentMethod, patientUuid, setExtraVisitInfo],
120
+ );
76
121
 
77
122
  if (isLoadingLineItems || isLoadingCashPoints) {
78
123
  return (
@@ -84,13 +129,6 @@ const BillingCheckInForm: React.FC<BillingCheckInFormProps> = ({ patientUuid, se
84
129
  );
85
130
  }
86
131
 
87
- if (paymentMethod) {
88
- lineList = [];
89
- lineList = lineItems.filter((e) =>
90
- e.servicePrices.some((p) => p.paymentMode && p.paymentMode.uuid === paymentMethod),
91
- );
92
- }
93
-
94
132
  const setServicePrice = (prices) => {
95
133
  const matchingPrice = prices.find((p) => p.paymentMode?.uuid === paymentMethod);
96
134
  return matchingPrice ? `(${matchingPrice.name}: ${matchingPrice.price})` : '';
@@ -109,17 +147,38 @@ const BillingCheckInForm: React.FC<BillingCheckInFormProps> = ({ patientUuid, se
109
147
 
110
148
  return (
111
149
  <section className={styles.sectionContainer}>
112
- <VisitAttributesForm setAttributes={setAttributes} setPaymentMethod={setPaymentMethod} />
113
- {
114
- <Dropdown
115
- id="billable-items"
116
- items={lineList}
117
- itemToString={(item) => (item ? `${item.name} ${setServicePrice(item.servicePrices)}` : '')}
118
- label={t('selectBillableService', 'Select a billable service')}
119
- onChange={handleBillingService}
120
- titleText={t('billableService', 'Billable service')}
121
- />
122
- }
150
+ <h1 className={styles.sectionLabel}>{t('billingDetails', 'Billing details')}</h1>
151
+ <div className={styles.sectionField}>
152
+ {!isLoadingLastVisitInfo && !lastVisitError && lastVisitInfo && (
153
+ <div className={styles.lastVisitBanner}>
154
+ <InlineNotification
155
+ hideCloseButton
156
+ kind="info"
157
+ title={t('lastVisitInfo', 'Last Visit Information')}
158
+ subtitle={t('lastVisitMsg', 'The last visit was a {{type}} {{count}} days ago at {{location}}', {
159
+ count: lastVisitInfo.diffDays,
160
+ type: lastVisitInfo.type,
161
+ location: lastVisitInfo.location,
162
+ })}
163
+ lowContrast
164
+ />
165
+ </div>
166
+ )}
167
+ <VisitAttributesForm setAttributes={setAttributes} setPaymentMethod={setPaymentMethod} />
168
+
169
+ {lineList.length > 0 && (
170
+ <Dropdown
171
+ key={`billable-${paymentMethod}`}
172
+ id="billable-items"
173
+ items={lineList}
174
+ itemToString={(item) => (item ? `${item.name} ${setServicePrice(item.servicePrices)}` : '')}
175
+ label={t('selectBillableService', 'Select a billable service')}
176
+ onChange={handleBillingService}
177
+ selectedItem={selectedBillableItem}
178
+ titleText={t('billableService', 'Billable service')}
179
+ />
180
+ )}
181
+ </div>
123
182
  </section>
124
183
  );
125
184
  };
@@ -1,6 +1,30 @@
1
1
  @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
2
4
 
3
- .sectionContainer {
4
- margin: 0 layout.$spacing-03;
5
+ .sectionLabel {
6
+ @include type.type-style('heading-compact-02');
7
+ color: colors.$gray-70;
8
+ margin: 0 0 layout.$spacing-03 0;
9
+ min-width: 8rem;
5
10
  }
6
11
 
12
+ .lastVisitBanner {
13
+ margin-bottom: layout.$spacing-05;
14
+ }
15
+
16
+ :global(.omrs-breakpoint-lt-desktop) {
17
+ .sectionContainer {
18
+ display: flex;
19
+
20
+ .sectionLabel {
21
+ flex-basis: 30%;
22
+ min-width: 8rem;
23
+ text-align: left;
24
+ }
25
+
26
+ .sectionField {
27
+ flex-basis: 70%;
28
+ }
29
+ }
30
+ }
@@ -3,13 +3,14 @@ import userEvent from '@testing-library/user-event';
3
3
  import { screen, render } from '@testing-library/react';
4
4
  import { useConfig } from '@openmrs/esm-framework';
5
5
  import { type BillingConfig } from '../config-schema';
6
- import { useBillableItems, useCashPoint, usePaymentMethods } from './billing-form.resource';
6
+ import { useBillableItems, useCashPoint, usePaymentMethods, useLastVisitInfo } from './billing-form.resource';
7
7
  import BillingCheckInForm from './billing-checkin-form.component';
8
8
 
9
9
  const mockUseConfig = jest.mocked(useConfig<BillingConfig>);
10
10
  const mockUseCashPoint = jest.mocked(useCashPoint);
11
11
  const mockUseBillableItems = jest.mocked(useBillableItems);
12
12
  const mockUsePaymentMethods = jest.mocked(usePaymentMethods);
13
+ const mockUseLastVisitInfo = jest.mocked(useLastVisitInfo);
13
14
 
14
15
  const mockCashPoints = [
15
16
  {
@@ -89,6 +90,7 @@ jest.mock('./billing-form.resource', () => ({
89
90
  useCashPoint: jest.fn(),
90
91
  createPatientBill: jest.fn(),
91
92
  usePaymentMethods: jest.fn(),
93
+ useLastVisitInfo: jest.fn(),
92
94
  }));
93
95
 
94
96
  const testProps = { patientUuid: 'some-patient-uuid', setExtraVisitInfo: jest.fn() };
@@ -116,6 +118,7 @@ describe('BillingCheckInForm', () => {
116
118
  },
117
119
  } as BillingConfig);
118
120
  mockUsePaymentMethods.mockReturnValue({ paymentModes: mockPaymentMethods, isLoading: false, error: null });
121
+ mockUseLastVisitInfo.mockReturnValue({ lastVisitInfo: null, isLoading: false, error: null });
119
122
  });
120
123
 
121
124
  test('should show the loading spinner while retrieving data', () => {
@@ -136,6 +139,53 @@ describe('BillingCheckInForm', () => {
136
139
  expect(screen.getByText(/error loading bill services/i)).toBeInTheDocument();
137
140
  });
138
141
 
142
+ test('should show the last visit banner when last visit info is available', () => {
143
+ mockUseBillableItems.mockReturnValue({ lineItems: [], isLoading: false, error: null });
144
+ mockUseCashPoint.mockReturnValue({ cashPoints: [], isLoading: false, error: null });
145
+ mockUseLastVisitInfo.mockReturnValue({
146
+ lastVisitInfo: { diffDays: 3, type: 'Outpatient', location: 'Main Clinic' },
147
+ isLoading: false,
148
+ error: null,
149
+ });
150
+ renderBillingCheckinForm();
151
+
152
+ expect(screen.getByText(/Last Visit Information/i)).toBeInTheDocument();
153
+ expect(screen.getByText(/3 days ago/i)).toBeInTheDocument();
154
+ });
155
+
156
+ test('should not show the last visit banner when there is no recent visit', () => {
157
+ mockUseBillableItems.mockReturnValue({ lineItems: [], isLoading: false, error: null });
158
+ mockUseCashPoint.mockReturnValue({ cashPoints: [], isLoading: false, error: null });
159
+ mockUseLastVisitInfo.mockReturnValue({ lastVisitInfo: null, isLoading: false, error: null });
160
+ renderBillingCheckinForm();
161
+
162
+ expect(screen.queryByText(/Last Visit Information/i)).not.toBeInTheDocument();
163
+ });
164
+
165
+ test('should show billable service dropdown when a paying method is selected', async () => {
166
+ const user = userEvent.setup();
167
+ mockUseCashPoint.mockReturnValue({ cashPoints: mockCashPoints, isLoading: false, error: null });
168
+ mockUseBillableItems.mockReturnValue({ lineItems: mockBillableItems, isLoading: false, error: null });
169
+ renderBillingCheckinForm();
170
+
171
+ await user.click(screen.getByRole('radio', { name: 'Paying' }));
172
+ const paymentMethodDropdown = await screen.findByRole('combobox', { name: /payment method/i });
173
+ await user.click(paymentMethodDropdown);
174
+ await user.click(await screen.findByText('Insurance'));
175
+
176
+ expect(screen.getByRole('combobox', { name: /billable service/i })).toBeInTheDocument();
177
+ });
178
+
179
+ test('should hide billable service dropdown when switched to non-paying', async () => {
180
+ const user = userEvent.setup();
181
+ mockUseCashPoint.mockReturnValue({ cashPoints: mockCashPoints, isLoading: false, error: null });
182
+ mockUseBillableItems.mockReturnValue({ lineItems: mockBillableItems, isLoading: false, error: null });
183
+ renderBillingCheckinForm();
184
+
185
+ await user.click(screen.getByRole('radio', { name: /non paying/i }));
186
+ expect(screen.queryByRole('combobox', { name: /billable service/i })).not.toBeInTheDocument();
187
+ });
188
+
139
189
  test('should render the form correctly and generate the required payload', async () => {
140
190
  const user = userEvent.setup();
141
191
  mockUseCashPoint.mockReturnValue({ cashPoints: mockCashPoints, isLoading: false, error: null });