@openmrs/esm-billing-app 1.0.2-pre.86 → 1.0.2-pre.863

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 (214) hide show
  1. package/.eslintrc +16 -2
  2. package/README.md +54 -9
  3. package/__mocks__/bills.mock.ts +12 -0
  4. package/__mocks__/react-i18next.js +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1146.js +1 -2
  7. package/dist/1146.js.map +1 -1
  8. package/dist/1197.js +1 -1
  9. package/dist/1856.js +1 -0
  10. package/dist/1856.js.map +1 -0
  11. package/dist/2146.js +1 -1
  12. package/dist/2177.js +2 -0
  13. package/dist/2177.js.LICENSE.txt +9 -0
  14. package/dist/2177.js.map +1 -0
  15. package/dist/2524.js +1 -0
  16. package/dist/2524.js.map +1 -0
  17. package/dist/2690.js +1 -1
  18. package/dist/3041.js +1 -0
  19. package/dist/3041.js.map +1 -0
  20. package/dist/3099.js +1 -1
  21. package/dist/3584.js +1 -1
  22. package/dist/3717.js +2 -0
  23. package/dist/3717.js.map +1 -0
  24. package/dist/4055.js +1 -1
  25. package/dist/4132.js +1 -1
  26. package/dist/4225.js +1 -0
  27. package/dist/4225.js.map +1 -0
  28. package/dist/4300.js +1 -1
  29. package/dist/4335.js +1 -1
  30. package/dist/4344.js +1 -0
  31. package/dist/4344.js.map +1 -0
  32. package/dist/4618.js +1 -1
  33. package/dist/4652.js +1 -1
  34. package/dist/4724.js +1 -0
  35. package/dist/4724.js.map +1 -0
  36. package/dist/4739.js +1 -1
  37. package/dist/4739.js.map +1 -1
  38. package/dist/4944.js +1 -1
  39. package/dist/5173.js +1 -1
  40. package/dist/5241.js +1 -1
  41. package/dist/5422.js +1 -0
  42. package/dist/5422.js.map +1 -0
  43. package/dist/5442.js +1 -1
  44. package/dist/5661.js +1 -1
  45. package/dist/6022.js +1 -1
  46. package/dist/6295.js +2 -0
  47. package/dist/{6525.js.LICENSE.txt → 6295.js.LICENSE.txt} +16 -4
  48. package/dist/6295.js.map +1 -0
  49. package/dist/6468.js +1 -1
  50. package/dist/6540.js +1 -1
  51. package/dist/6540.js.map +1 -1
  52. package/dist/6606.js +1 -0
  53. package/dist/6606.js.map +1 -0
  54. package/dist/6679.js +1 -1
  55. package/dist/6840.js +1 -1
  56. package/dist/6859.js +1 -1
  57. package/dist/7097.js +1 -1
  58. package/dist/7159.js +1 -1
  59. package/dist/723.js +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/8349.js +1 -1
  64. package/dist/8618.js +1 -1
  65. package/dist/890.js +1 -1
  66. package/dist/9214.js +1 -1
  67. package/dist/9538.js +1 -1
  68. package/dist/9569.js +1 -1
  69. package/dist/961.js +1 -1
  70. package/dist/961.js.map +1 -1
  71. package/dist/986.js +1 -1
  72. package/dist/9879.js +1 -1
  73. package/dist/9895.js +1 -1
  74. package/dist/9900.js +1 -1
  75. package/dist/9913.js +1 -1
  76. package/dist/main.js +1 -1
  77. package/dist/main.js.map +1 -1
  78. package/dist/openmrs-esm-billing-app.js +1 -1
  79. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +388 -282
  80. package/dist/openmrs-esm-billing-app.js.map +1 -1
  81. package/dist/routes.json +1 -1
  82. package/e2e/README.md +19 -18
  83. package/e2e/core/test.ts +1 -1
  84. package/e2e/fixtures/api.ts +1 -1
  85. package/e2e/specs/sample-test.spec.ts +0 -1
  86. package/e2e/support/github/Dockerfile +1 -1
  87. package/package.json +13 -10
  88. package/src/bill-history/bill-history.component.tsx +20 -28
  89. package/src/bill-history/bill-history.scss +4 -94
  90. package/src/bill-history/bill-history.test.tsx +37 -78
  91. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  92. package/src/bill-item-actions/edit-bill-item.modal.tsx +225 -0
  93. package/src/bill-item-actions/edit-bill-item.test.tsx +214 -40
  94. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  95. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +28 -32
  96. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  97. package/src/billable-services/bill-waiver/utils.ts +13 -3
  98. package/src/billable-services/billable-service.resource.ts +42 -26
  99. package/src/billable-services/billable-services-home.component.tsx +4 -4
  100. package/src/billable-services/billable-services.component.tsx +149 -148
  101. package/src/billable-services/billable-services.scss +3 -0
  102. package/src/billable-services/billable-services.test.tsx +6 -49
  103. package/src/billable-services/cash-point/add-cash-point.modal.tsx +168 -0
  104. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  105. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  106. package/src/billable-services/create-edit/add-billable-service.component.tsx +365 -299
  107. package/src/billable-services/create-edit/add-billable-service.scss +7 -68
  108. package/src/billable-services/create-edit/add-billable-service.test.tsx +167 -81
  109. package/src/billable-services/create-edit/edit-billable-service.modal.tsx +51 -0
  110. package/src/billable-services/dashboard/service-metrics.component.tsx +11 -3
  111. package/src/billable-services/payment-modes/add-payment-mode.modal.tsx +121 -0
  112. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +74 -0
  113. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +125 -0
  114. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  115. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  116. package/src/billing-form/billing-checkin-form.component.tsx +21 -17
  117. package/src/billing-form/billing-checkin-form.test.tsx +99 -26
  118. package/src/billing-form/billing-form.component.tsx +222 -292
  119. package/src/billing-form/billing-form.scss +143 -0
  120. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +1 -1
  121. package/src/billing.resource.ts +69 -74
  122. package/src/bills-table/bills-table.component.tsx +3 -3
  123. package/src/bills-table/bills-table.test.tsx +98 -54
  124. package/src/config-schema.ts +52 -24
  125. package/src/dashboard.meta.ts +4 -2
  126. package/src/helpers/functions.ts +5 -4
  127. package/src/index.ts +17 -6
  128. package/src/invoice/invoice-table.component.tsx +36 -70
  129. package/src/invoice/invoice-table.scss +8 -5
  130. package/src/invoice/invoice-table.test.tsx +273 -62
  131. package/src/invoice/invoice.component.tsx +39 -32
  132. package/src/invoice/invoice.scss +11 -4
  133. package/src/invoice/invoice.test.tsx +324 -120
  134. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  135. package/src/invoice/payments/payment-form/payment-form.component.tsx +43 -34
  136. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  137. package/src/invoice/payments/payment-form/payment-form.test.tsx +216 -66
  138. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  139. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  140. package/src/invoice/payments/payments.component.tsx +55 -67
  141. package/src/invoice/payments/payments.scss +4 -3
  142. package/src/invoice/payments/payments.test.tsx +282 -0
  143. package/src/invoice/payments/utils.ts +15 -27
  144. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -2
  145. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  146. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  147. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  148. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +12 -11
  149. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +16 -14
  150. package/src/invoice/printable-invoice/printable-invoice.component.tsx +20 -34
  151. package/src/left-panel-link.test.tsx +1 -4
  152. package/src/metrics-cards/metrics-cards.component.tsx +12 -2
  153. package/src/metrics-cards/metrics-cards.scss +4 -0
  154. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  155. package/src/modal/require-payment-modal.test.tsx +27 -22
  156. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  157. package/src/routes.json +25 -7
  158. package/src/types/index.ts +81 -23
  159. package/translations/am.json +125 -74
  160. package/translations/ar.json +126 -75
  161. package/translations/ar_SY.json +126 -75
  162. package/translations/bn.json +128 -77
  163. package/translations/de.json +126 -75
  164. package/translations/en.json +126 -75
  165. package/translations/en_US.json +126 -75
  166. package/translations/es.json +125 -74
  167. package/translations/es_MX.json +126 -75
  168. package/translations/fr.json +131 -80
  169. package/translations/he.json +125 -74
  170. package/translations/hi.json +126 -75
  171. package/translations/hi_IN.json +126 -75
  172. package/translations/id.json +126 -75
  173. package/translations/it.json +152 -101
  174. package/translations/ka.json +126 -75
  175. package/translations/km.json +125 -74
  176. package/translations/ku.json +126 -75
  177. package/translations/ky.json +126 -75
  178. package/translations/lg.json +126 -75
  179. package/translations/ne.json +126 -75
  180. package/translations/pl.json +126 -75
  181. package/translations/pt.json +126 -75
  182. package/translations/pt_BR.json +126 -75
  183. package/translations/qu.json +126 -75
  184. package/translations/ro_RO.json +216 -165
  185. package/translations/ru_RU.json +126 -75
  186. package/translations/si.json +126 -75
  187. package/translations/sw.json +126 -75
  188. package/translations/sw_KE.json +126 -75
  189. package/translations/tr.json +126 -75
  190. package/translations/tr_TR.json +126 -75
  191. package/translations/uk.json +126 -75
  192. package/translations/uz.json +126 -75
  193. package/translations/uz@Latn.json +126 -75
  194. package/translations/uz_UZ.json +126 -75
  195. package/translations/vi.json +126 -75
  196. package/translations/zh.json +126 -75
  197. package/translations/zh_CN.json +158 -107
  198. package/dist/1146.js.LICENSE.txt +0 -21
  199. package/dist/2352.js +0 -1
  200. package/dist/2352.js.map +0 -1
  201. package/dist/246.js +0 -1
  202. package/dist/246.js.map +0 -1
  203. package/dist/6525.js +0 -2
  204. package/dist/6525.js.map +0 -1
  205. package/dist/8556.js +0 -2
  206. package/dist/8556.js.map +0 -1
  207. package/dist/8638.js +0 -1
  208. package/dist/8638.js.map +0 -1
  209. package/dist/9968.js +0 -1
  210. package/dist/9968.js.map +0 -1
  211. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  212. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  213. package/src/invoice/payments/payments.component.test.tsx +0 -121
  214. /package/dist/{8556.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -1,22 +1,29 @@
1
1
  import React from 'react';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import { showSnackbar } from '@openmrs/esm-framework';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import { type FetchResponse, getDefaultsFromConfigSchema, showSnackbar, useConfig } from '@openmrs/esm-framework';
5
+ import { configSchema, type BillingConfig } from '../config-schema';
4
6
  import { type MappedBill } from '../types';
5
7
  import { updateBillItems } from '../billing.resource';
6
- import ChangeStatus from './edit-bill-item.component';
8
+ import EditBillLineItemModal from './edit-bill-item.modal';
7
9
 
8
- // Mock external dependencies
9
- jest.mock('../billing.resource', () => ({
10
- updateBillItems: jest.fn(() => Promise.resolve({})),
11
- }));
10
+ const mockUpdateBillItems = jest.mocked(updateBillItems);
11
+ const mockShowSnackbar = jest.mocked(showSnackbar);
12
+ const mockUseConfig = jest.mocked(useConfig<BillingConfig>);
12
13
 
13
- jest.mock('@openmrs/esm-framework', () => ({
14
- showSnackbar: jest.fn(),
14
+ const mockBillableServices = [
15
+ { name: 'X-Ray Service', uuid: 'xray-uuid-123' },
16
+ { name: 'Lab Test Service', uuid: 'lab-uuid-456' },
17
+ { name: 'Consultation Service', uuid: 'consult-uuid-789' },
18
+ ];
19
+
20
+ jest.mock('../billing.resource', () => ({
21
+ updateBillItems: jest.fn().mockResolvedValue({}),
15
22
  }));
16
23
 
17
24
  jest.mock('../billable-services/billable-service.resource', () => ({
18
25
  useBillableServices: jest.fn(() => ({
19
- billableServices: [],
26
+ billableServices: mockBillableServices,
20
27
  })),
21
28
  }));
22
29
 
@@ -41,7 +48,7 @@ const mockBill: MappedBill = {
41
48
  voided: false,
42
49
  voidReason: null,
43
50
  priceName: 'Service Price',
44
- billableService: 'service-uuid',
51
+ billableService: 'X-Ray Service',
45
52
  priceUuid: 'price-uuid',
46
53
  lineItemOrder: 1,
47
54
  resourceVersion: '1.0',
@@ -62,7 +69,7 @@ const mockItem = {
62
69
  uuid: 'item-uuid',
63
70
  quantity: 2,
64
71
  price: 100,
65
- billableService: 'service-uuid',
72
+ billableService: 'X-Ray Service',
66
73
  paymentStatus: 'UNPAID',
67
74
  item: 'Test Service',
68
75
  display: 'Test Service',
@@ -74,64 +81,231 @@ const mockItem = {
74
81
  resourceVersion: '1.0',
75
82
  };
76
83
 
77
- describe('ChangeStatus component', () => {
78
- const closeModalMock = jest.fn();
79
-
84
+ describe('EditBillItem', () => {
80
85
  beforeEach(() => {
81
- jest.clearAllMocks();
86
+ mockUseConfig.mockReturnValue({
87
+ ...getDefaultsFromConfigSchema(configSchema),
88
+ defaultCurrency: 'USD',
89
+ });
82
90
  });
83
91
 
92
+ const mockCloseModal = jest.fn();
93
+
84
94
  test('renders the form with correct fields and default values', () => {
85
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
95
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
86
96
 
87
- expect(screen.getByText('Edit bill line item?')).toBeInTheDocument();
88
- expect(screen.getByText('John Doe · Main Cashpoint · 123456')).toBeInTheDocument();
89
- expect(screen.getByRole('spinbutton', { name: /Quantity/ })).toHaveValue(2);
90
- expect(screen.getByLabelText(/Unit Price/)).toHaveValue('100');
91
- expect(screen.getByText(/Total/)).toHaveTextContent('200');
97
+ expect(screen.getByText(/edit bill line item/i)).toBeInTheDocument();
98
+ expect(screen.getByText(/John Doe/)).toBeInTheDocument();
99
+ expect(screen.getByText(/Main Cashpoint/)).toBeInTheDocument();
100
+ expect(screen.getByText(/123456/)).toBeInTheDocument();
101
+ expect(screen.getByRole('spinbutton', { name: /quantity/i })).toHaveValue(2);
102
+ expect(screen.getByLabelText(/unit price/i)).toHaveValue('100');
103
+ expect(screen.getByText(/total/i)).toHaveTextContent(/200/);
92
104
  });
93
105
 
94
- test('updates total when quantity is changed', () => {
95
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
106
+ test('updates total when quantity is changed', async () => {
107
+ const user = userEvent.setup();
108
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
96
109
 
97
- const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
98
- fireEvent.change(quantityInput, { target: { value: 3 } });
110
+ const quantityInput = screen.getByRole('spinbutton', { name: /quantity/i });
111
+ await user.clear(quantityInput);
112
+ await user.type(quantityInput, '3');
99
113
 
100
- expect(screen.getByText(/Total/)).toHaveTextContent('300');
114
+ expect(screen.getByText(/total/i)).toHaveTextContent(/300/);
101
115
  });
102
116
 
103
117
  test('submits the form and shows a success notification', async () => {
104
- (updateBillItems as jest.Mock).mockResolvedValueOnce({});
118
+ const user = userEvent.setup();
119
+ mockUpdateBillItems.mockResolvedValueOnce({} as FetchResponse<any>);
105
120
 
106
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
121
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
107
122
 
108
- fireEvent.click(screen.getByText(/Save/));
123
+ await user.click(screen.getByText(/save/i));
109
124
 
110
125
  await waitFor(() => {
111
- expect(updateBillItems).toHaveBeenCalled();
126
+ expect(mockUpdateBillItems).toHaveBeenCalled();
112
127
  expect(showSnackbar).toHaveBeenCalledWith({
113
- title: 'Save Bill',
114
- subtitle: 'Bill processing has been successful',
128
+ title: 'Line item updated',
129
+ subtitle: 'The bill line item has been updated successfully',
115
130
  kind: 'success',
116
- timeoutInMs: 3000,
117
131
  });
118
- expect(closeModalMock).toHaveBeenCalled();
132
+ expect(mockCloseModal).toHaveBeenCalled();
119
133
  });
120
134
  });
121
135
 
122
136
  test('shows error notification when submission fails', async () => {
123
- (updateBillItems as jest.Mock).mockRejectedValueOnce({ message: 'Error occurred' });
137
+ const user = userEvent.setup();
138
+ mockUpdateBillItems.mockRejectedValueOnce({ message: 'Error occurred' });
124
139
 
125
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
140
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
126
141
 
127
- fireEvent.click(screen.getByText(/Save/));
142
+ await user.click(screen.getByText(/Save/));
128
143
 
129
144
  await waitFor(() => {
130
- expect(showSnackbar).toHaveBeenCalledWith({
131
- title: 'Bill processing error',
145
+ expect(mockShowSnackbar).toHaveBeenCalledWith({
146
+ title: 'Failed to update line item',
132
147
  kind: 'error',
133
148
  subtitle: 'Error occurred',
134
149
  });
135
150
  });
136
151
  });
152
+
153
+ test('preserves billable service UUIDs for other line items when editing', async () => {
154
+ const user = userEvent.setup();
155
+ mockUpdateBillItems.mockResolvedValueOnce({} as FetchResponse<any>);
156
+
157
+ // Bill with multiple line items with different billable services
158
+ const billWithMultipleItems: MappedBill = {
159
+ ...mockBill,
160
+ lineItems: [
161
+ {
162
+ uuid: 'item-1',
163
+ quantity: 1,
164
+ price: 100,
165
+ billableService: 'X-Ray Service',
166
+ paymentStatus: 'PENDING',
167
+ item: 'X-Ray',
168
+ display: 'X-Ray',
169
+ voided: false,
170
+ voidReason: null,
171
+ priceName: 'X-Ray Price',
172
+ priceUuid: 'xray-price-uuid',
173
+ lineItemOrder: 1,
174
+ resourceVersion: '1.0',
175
+ },
176
+ {
177
+ uuid: 'item-2',
178
+ quantity: 2,
179
+ price: 50,
180
+ billableService: 'Lab Test Service',
181
+ paymentStatus: 'PENDING',
182
+ item: 'Lab Test',
183
+ display: 'Lab Test',
184
+ voided: false,
185
+ voidReason: null,
186
+ priceName: 'Lab Price',
187
+ priceUuid: 'lab-price-uuid',
188
+ lineItemOrder: 2,
189
+ resourceVersion: '1.0',
190
+ },
191
+ {
192
+ uuid: 'item-3',
193
+ quantity: 1,
194
+ price: 200,
195
+ billableService: 'Consultation Service',
196
+ paymentStatus: 'PENDING',
197
+ item: 'Consultation',
198
+ display: 'Consultation',
199
+ voided: false,
200
+ voidReason: null,
201
+ priceName: 'Consult Price',
202
+ priceUuid: 'consult-price-uuid',
203
+ lineItemOrder: 3,
204
+ resourceVersion: '1.0',
205
+ },
206
+ ],
207
+ };
208
+
209
+ // Editing the Lab Test item (item-2)
210
+ const itemToEdit = billWithMultipleItems.lineItems[1];
211
+
212
+ render(<EditBillLineItemModal bill={billWithMultipleItems} item={itemToEdit} closeModal={mockCloseModal} />);
213
+
214
+ await user.click(screen.getByText(/Save/));
215
+
216
+ await waitFor(() => {
217
+ expect(mockUpdateBillItems).toHaveBeenCalled();
218
+ const payload = mockUpdateBillItems.mock.calls[0][0];
219
+
220
+ // Verify that each line item has the correct billable service UUID
221
+ const xrayItem = payload.lineItems.find((li) => li.uuid === 'item-1');
222
+ const consultItem = payload.lineItems.find((li) => li.uuid === 'item-3');
223
+
224
+ // These should NOT have the Lab Test UUID (lab-uuid-456)
225
+ // They should keep their original UUIDs
226
+ expect(xrayItem?.billableService).toBe('xray-uuid-123');
227
+ expect(consultItem?.billableService).toBe('consult-uuid-789');
228
+
229
+ // The edited item should have the Lab Test UUID
230
+ const labItem = payload.lineItems.find((li) => li.uuid === 'item-2');
231
+ expect(labItem?.billableService).toBe('lab-uuid-456');
232
+ });
233
+ });
234
+
235
+ test('shows validation error for quantity less than 1', async () => {
236
+ const user = userEvent.setup();
237
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
238
+
239
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
240
+ await user.clear(quantityInput);
241
+ await user.type(quantityInput, '0');
242
+
243
+ // Try to submit
244
+ await user.click(screen.getByText(/Save/));
245
+
246
+ // Should show validation error
247
+ await waitFor(() => {
248
+ expect(screen.getByText(/Quantity must be at least 1/)).toBeInTheDocument();
249
+ });
250
+
251
+ // Should NOT call the update function
252
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
253
+ });
254
+
255
+ test('shows validation error for quantity greater than 100', async () => {
256
+ const user = userEvent.setup();
257
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
258
+
259
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
260
+ await user.clear(quantityInput);
261
+ await user.type(quantityInput, '101');
262
+
263
+ await user.click(screen.getByText(/Save/));
264
+
265
+ await waitFor(() => {
266
+ expect(screen.getByText(/Quantity cannot exceed 100/)).toBeInTheDocument();
267
+ });
268
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
269
+ });
270
+
271
+ test('shows validation error for non-integer quantity', async () => {
272
+ const user = userEvent.setup();
273
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
274
+
275
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
276
+ await user.clear(quantityInput);
277
+ await user.type(quantityInput, '2.5');
278
+
279
+ await user.click(screen.getByText(/Save/));
280
+
281
+ await waitFor(() => {
282
+ expect(screen.getByText(/Quantity must be a whole number/)).toBeInTheDocument();
283
+ });
284
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
285
+ });
286
+
287
+ test('clears validation error when valid quantity is entered', async () => {
288
+ const user = userEvent.setup();
289
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
290
+
291
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
292
+
293
+ // Enter invalid value
294
+ await user.clear(quantityInput);
295
+ await user.type(quantityInput, '0');
296
+ await user.click(screen.getByText(/Save/));
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByText(/Quantity must be at least 1/)).toBeInTheDocument();
300
+ });
301
+
302
+ // Fix it
303
+ await user.clear(quantityInput);
304
+ await user.type(quantityInput, '5');
305
+
306
+ // Error should disappear
307
+ await waitFor(() => {
308
+ expect(screen.queryByText(/Quantity must be at least 1/)).not.toBeInTheDocument();
309
+ });
310
+ });
137
311
  });
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import {
3
3
  Checkbox,
4
4
  Layer,
@@ -9,7 +9,7 @@ import {
9
9
  StructuredListWrapper,
10
10
  } from '@carbon/react';
11
11
  import { useTranslation } from 'react-i18next';
12
- import { useConfig } from '@openmrs/esm-framework';
12
+ import { getCoreTranslation, useConfig } from '@openmrs/esm-framework';
13
13
  import { convertToCurrency } from '../../helpers';
14
14
  import { type MappedBill, type LineItem } from '../../types';
15
15
  import BillWaiverForm from './bill-waiver-form.component';
@@ -20,7 +20,7 @@ const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (pat
20
20
  setPatientUuid,
21
21
  }) => {
22
22
  const { t } = useTranslation();
23
- const [selectedBills, setSelectedBills] = React.useState<Array<LineItem>>([]);
23
+ const [selectedBills, setSelectedBills] = useState<Array<LineItem>>([]);
24
24
  const { defaultCurrency } = useConfig();
25
25
 
26
26
  const checkBoxLabel = (lineItem) => {
@@ -42,9 +42,9 @@ const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (pat
42
42
  <StructuredListRow head>
43
43
  <StructuredListCell head>{t('billItem', 'Bill item')}</StructuredListCell>
44
44
  <StructuredListCell head>{t('quantity', 'Quantity')}</StructuredListCell>
45
- <StructuredListCell head>{t('unitPrice', 'Unit Price')}</StructuredListCell>
45
+ <StructuredListCell head>{t('unitPrice', 'Unit price')}</StructuredListCell>
46
46
  <StructuredListCell head>{t('total', 'Total')}</StructuredListCell>
47
- <StructuredListCell head>{t('actions', 'Actions')}</StructuredListCell>
47
+ <StructuredListCell head>{getCoreTranslation('actions')}</StructuredListCell>
48
48
  </StructuredListRow>
49
49
  </StructuredListHead>
50
50
  <StructuredListBody>
@@ -1,11 +1,11 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import { Form, Stack, FormGroup, Layer, Button, NumberInput } from '@carbon/react';
3
3
  import { TaskAdd } from '@carbon/react/icons';
4
4
  import { mutate } from 'swr';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { showSnackbar, useConfig } from '@openmrs/esm-framework';
7
7
  import { createBillWaiverPayload } from './utils';
8
- import { convertToCurrency } from '../../helpers';
8
+ import { calculateTotalAmount, convertToCurrency } from '../../helpers';
9
9
  import { processBillPayment } from '../../billing.resource';
10
10
  import { useBillableItems } from '../../billing-form/billing-form.resource';
11
11
  import type { LineItem, MappedBill } from '../../types';
@@ -20,16 +20,16 @@ type BillWaiverFormProps = {
20
20
 
21
21
  const BillWaiverForm: React.FC<BillWaiverFormProps> = ({ bill, lineItems, setPatientUuid }) => {
22
22
  const { t } = useTranslation();
23
- const [waiverAmount, setWaiverAmount] = React.useState(0);
24
- const { lineItems: billableLineItems, isLoading: isLoadingLineItems, error: lineError } = useBillableItems();
25
- const totalAmount = lineItems.reduce((acc, curr) => acc + curr.price * curr.quantity, 0);
23
+ const [waiverAmount, setWaiverAmount] = useState(0);
24
+ const { lineItems: billableLineItems } = useBillableItems();
25
+ const totalAmount = calculateTotalAmount(lineItems);
26
26
  const { defaultCurrency } = useConfig();
27
27
 
28
28
  if (lineItems?.length === 0) {
29
29
  return null;
30
30
  }
31
31
 
32
- const handleProcessPayment = (event) => {
32
+ const handleProcessPayment = async () => {
33
33
  const waiverEndPointPayload = createBillWaiverPayload(
34
34
  bill,
35
35
  waiverAmount,
@@ -38,30 +38,26 @@ const BillWaiverForm: React.FC<BillWaiverFormProps> = ({ bill, lineItems, setPat
38
38
  billableLineItems,
39
39
  );
40
40
 
41
- processBillPayment(waiverEndPointPayload, bill.uuid).then(
42
- (resp) => {
43
- showSnackbar({
44
- title: t('billWaiver', 'Bill waiver'),
45
- subtitle: t('billWaiverSuccess', 'Bill waiver successful'),
46
- kind: 'success',
47
- timeoutInMs: 3500,
48
- isLowContrast: true,
49
- });
50
- setPatientUuid('');
51
- mutate((key) => typeof key === 'string' && key.startsWith(`${apiBasePath}bill?v=full`), undefined, {
52
- revalidate: true,
53
- });
54
- },
55
- (err) => {
56
- showSnackbar({
57
- title: t('billWaiver', 'Bill waiver'),
58
- subtitle: t('billWaiverError', 'Bill waiver failed {{error}}', { error: err.message }),
59
- kind: 'error',
60
- timeoutInMs: 3500,
61
- isLowContrast: true,
62
- });
63
- },
64
- );
41
+ try {
42
+ await processBillPayment(waiverEndPointPayload, bill.uuid);
43
+ showSnackbar({
44
+ title: t('billWaiver', 'Bill waiver'),
45
+ subtitle: t('billWaiverSuccess', 'Bill waiver successful'),
46
+ kind: 'success',
47
+ isLowContrast: true,
48
+ });
49
+ setPatientUuid('');
50
+ mutate((key) => typeof key === 'string' && key.startsWith(`${apiBasePath}bill?v=full`), undefined, {
51
+ revalidate: true,
52
+ });
53
+ } catch (error) {
54
+ showSnackbar({
55
+ title: t('billWaiver', 'Bill waiver'),
56
+ subtitle: t('billWaiverError', 'Bill waiver failed {{error}}', { error: error?.message }),
57
+ kind: 'error',
58
+ isLowContrast: true,
59
+ });
60
+ }
65
61
  };
66
62
 
67
63
  return (
@@ -72,7 +68,7 @@ const BillWaiverForm: React.FC<BillWaiverFormProps> = ({ bill, lineItems, setPat
72
68
  <section className={styles.billWaiverDescription}>
73
69
  <label className={styles.label}>{t('billItems', 'Bill Items')}</label>
74
70
  <p className={styles.value}>
75
- {t('billName', ' {{billName}} ', {
71
+ {t('billName', '{{billName}}', {
76
72
  billName: lineItems.map((item) => item.item || item.billableService).join(', ') ?? '--',
77
73
  })}
78
74
  </p>
@@ -84,7 +80,7 @@ const BillWaiverForm: React.FC<BillWaiverFormProps> = ({ bill, lineItems, setPat
84
80
 
85
81
  <Layer className={styles.formControlLayer}>
86
82
  <NumberInput
87
- label={t('amountToWaiveLabel', 'Amount to Waive')}
83
+ label={t('amountToWaiveLabel', 'Amount to waive')}
88
84
  helperText={t('amountToWaiveHelper', 'Specify the amount to be deducted from the bill')}
89
85
  aria-label={t('amountToWaiveAriaLabel', 'Enter amount to waive')}
90
86
  hideSteppers
@@ -37,9 +37,9 @@ const PatientBills: React.FC<PatientBillsProps> = ({ patientUuid, bills, setPati
37
37
  }
38
38
 
39
39
  const tableHeaders = [
40
- { header: 'Date', key: 'date' },
41
- { header: 'Billable Service', key: 'billableService' },
42
- { header: 'Total Amount', key: 'totalAmount' },
40
+ { header: t('date', 'Date'), key: 'date' },
41
+ { header: t('billableService', 'Billable service'), key: 'billableService' },
42
+ { header: t('totalAmount', 'Total amount'), key: 'totalAmount' },
43
43
  ];
44
44
 
45
45
  const tableRows = bills.map((bill) => ({
@@ -58,7 +58,7 @@ const PatientBills: React.FC<PatientBillsProps> = ({ patientUuid, bills, setPati
58
58
  <div className={styles.illo}>
59
59
  <EmptyDataIllustration />
60
60
  </div>
61
- <p className={styles.content}>{t('noBilltoDisplay', 'There are no bills to display for this patient')}</p>
61
+ <p className={styles.content}>{t('noBillToDisplay', 'There are no bills to display for this patient')}</p>
62
62
  </Tile>
63
63
  </Layer>
64
64
  </div>
@@ -84,13 +84,13 @@ const PatientBills: React.FC<PatientBillsProps> = ({ patientUuid, bills, setPati
84
84
  getTableContainerProps,
85
85
  }) => (
86
86
  <TableContainer
87
- title={t('patientBills', 'Patient bill')}
87
+ title={t('patientBills', 'Patient bills')}
88
88
  description={t('patientBillsDescription', 'List of patient bills')}
89
89
  {...getTableContainerProps()}>
90
- <Table {...getTableProps()} aria-label="sample table">
90
+ <Table {...getTableProps()} aria-label={t('billsTable', 'Bills table')}>
91
91
  <TableHead>
92
92
  <TableRow>
93
- <TableExpandHeader enableToggle={true} {...getExpandHeaderProps()} />
93
+ <TableExpandHeader enableToggle {...getExpandHeaderProps()} />
94
94
  {headers.map((header, i) => (
95
95
  <TableHeader
96
96
  key={i}
@@ -1,6 +1,7 @@
1
1
  import { type OpenmrsResource } from '@openmrs/esm-framework';
2
- import type { LineItem, MappedBill } from '../../types';
2
+ import type { LineItem, MappedBill, PaymentPayload } from '../../types';
3
3
 
4
+ // TODO: Move this UUID to the config schema
4
5
  const WAIVER_UUID = 'eb6173cb-9678-4614-bbe1-0ccf7ed9d1d4';
5
6
 
6
7
  export const createBillWaiverPayload = (
@@ -12,7 +13,7 @@ export const createBillWaiverPayload = (
12
13
  ) => {
13
14
  const { cashier } = bill;
14
15
 
15
- const billPayment = {
16
+ const billPayment: PaymentPayload = {
16
17
  amount: parseFloat(totalAmount.toFixed(2)),
17
18
  amountTendered: parseFloat(Number(amountWaived).toFixed(2)),
18
19
  attributes: [],
@@ -25,11 +26,20 @@ export const createBillWaiverPayload = (
25
26
  paymentStatus: 'PAID',
26
27
  }));
27
28
 
29
+ // Transform existing payments to PaymentPayload format
30
+ const existingPayments: PaymentPayload[] = bill.payments.map((payment) => ({
31
+ amount: payment.amount,
32
+ amountTendered: payment.amountTendered,
33
+ attributes: payment.attributes,
34
+ instanceType: payment.instanceType.uuid,
35
+ dateCreated: payment.dateCreated,
36
+ }));
37
+
28
38
  const processedPayment = {
29
39
  cashPoint: bill.cashPointUuid,
30
40
  cashier: cashier.uuid,
31
41
  lineItems: processedLineItems,
32
- payments: [...bill.payments, billPayment],
42
+ payments: [...existingPayments, billPayment],
33
43
  patient: bill.patientUuid,
34
44
  };
35
45
 
@@ -1,16 +1,21 @@
1
1
  import useSWR from 'swr';
2
2
  import { type OpenmrsResource, openmrsFetch, restBaseUrl, useOpenmrsFetchAll, useConfig } from '@openmrs/esm-framework';
3
- import { type ServiceConcept } from '../types';
4
3
  import { apiBasePath } from '../constants';
5
- import { type BillableService } from '../types/index';
4
+ import type {
5
+ BillableService,
6
+ ConceptSearchResult,
7
+ CreateBillableServicePayload,
8
+ UpdateBillableServicePayload,
9
+ } from '../types';
10
+ import type { BillingConfig } from '../config-schema';
6
11
 
7
12
  type ResponseObject = {
8
13
  results: Array<OpenmrsResource>;
9
14
  };
10
15
 
11
16
  export const useBillableServices = () => {
12
- const url = `${apiBasePath}billableService?v=custom:(uuid,name,shortName,serviceStatus,concept:(uuid,display,name:(name)),serviceType:(display),servicePrices:(uuid,name,price,paymentMode:(uuid,name)))`;
13
- const { data, isLoading, isValidating, error, mutate } = useOpenmrsFetchAll<BillableService[]>(url);
17
+ const url = `${apiBasePath}billableService?v=custom:(uuid,name,shortName,serviceStatus,concept:(uuid,display,name:(name)),serviceType:(display,uuid),servicePrices:(uuid,name,price,paymentMode:(uuid,name)))`;
18
+ const { data, isLoading, isValidating, error, mutate } = useOpenmrsFetchAll<BillableService>(url);
14
19
 
15
20
  return {
16
21
  billableServices: data ?? [],
@@ -22,16 +27,23 @@ export const useBillableServices = () => {
22
27
  };
23
28
 
24
29
  export function useServiceTypes() {
25
- const config = useConfig();
26
- const serviceConceptUuid = config.serviceTypes.billableService;
30
+ const { serviceTypes } = useConfig<BillingConfig>();
31
+ const serviceConceptUuid = serviceTypes.billableService;
27
32
  const url = `${restBaseUrl}/concept/${serviceConceptUuid}?v=custom:(setMembers:(uuid,display))`;
28
33
 
29
- const { data, error, isLoading } = useSWR<{ data }>(url, openmrsFetch);
34
+ const { data, error, isLoading } = useSWR<{ data: { setMembers: Array<{ uuid: string; display: string }> } }>(
35
+ url,
36
+ openmrsFetch,
37
+ );
38
+
39
+ const sortedServiceTypes = data?.data.setMembers
40
+ ? [...data.data.setMembers].sort((a, b) => a.display.localeCompare(b.display))
41
+ : [];
30
42
 
31
43
  return {
32
- serviceTypes: data?.data.setMembers ?? [],
44
+ serviceTypes: sortedServiceTypes,
33
45
  error,
34
- isLoading,
46
+ isLoadingServiceTypes: isLoading,
35
47
  };
36
48
  }
37
49
 
@@ -40,28 +52,21 @@ export const usePaymentModes = () => {
40
52
 
41
53
  const { data, error, isLoading } = useSWR<{ data: ResponseObject }>(url, openmrsFetch);
42
54
 
55
+ const sortedPaymentModes = data?.data.results
56
+ ? [...data.data.results].sort((a, b) => a.name.localeCompare(b.name))
57
+ : [];
58
+
43
59
  return {
44
- paymentModes: data?.data.results ?? [],
60
+ paymentModes: sortedPaymentModes,
45
61
  error,
46
- isLoading,
62
+ isLoadingPaymentModes: isLoading,
47
63
  };
48
64
  };
49
65
 
50
- export const createBillableSerice = (payload: any) => {
51
- const url = `${apiBasePath}api/billable-service`;
52
- return openmrsFetch(url, {
53
- method: 'POST',
54
- body: payload,
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- },
58
- });
59
- };
60
-
61
66
  export function useConceptsSearch(conceptToLookup: string) {
62
67
  const conditionsSearchUrl = `${restBaseUrl}/conceptsearch?q=${conceptToLookup}`;
63
68
 
64
- const { data, error, isLoading } = useSWR<{ data: { results: Array<ServiceConcept> } }, Error>(
69
+ const { data, error, isLoading } = useSWR<{ data: { results: Array<ConceptSearchResult> } }, Error>(
65
70
  conceptToLookup ? conditionsSearchUrl : null,
66
71
  openmrsFetch,
67
72
  );
@@ -73,11 +78,22 @@ export function useConceptsSearch(conceptToLookup: string) {
73
78
  };
74
79
  }
75
80
 
76
- export const updateBillableService = (uuid: string, payload: any) => {
77
- const url = `${apiBasePath}/billableService/${uuid}`;
81
+ export const createBillableService = (payload: CreateBillableServicePayload) => {
82
+ const url = `${apiBasePath}api/billable-service`;
78
83
  return openmrsFetch(url, {
79
84
  method: 'POST',
80
- body: JSON.stringify(payload),
85
+ body: payload,
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ },
89
+ });
90
+ };
91
+
92
+ export const updateBillableService = (uuid: string, payload: UpdateBillableServicePayload) => {
93
+ const url = `${apiBasePath}billableService/${uuid}`;
94
+ return openmrsFetch(url, {
95
+ method: 'POST',
96
+ body: payload,
81
97
  headers: {
82
98
  'Content-Type': 'application/json',
83
99
  },