@openmrs/esm-billing-app 1.0.2-pre.88 → 1.0.2-pre.880

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 (208) 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/2372.js +1 -0
  13. package/dist/2372.js.map +1 -0
  14. package/dist/2524.js +1 -0
  15. package/dist/2524.js.map +1 -0
  16. package/dist/2690.js +1 -1
  17. package/dist/3099.js +1 -1
  18. package/dist/3584.js +1 -1
  19. package/dist/3717.js +2 -0
  20. package/dist/3717.js.map +1 -0
  21. package/dist/4055.js +1 -1
  22. package/dist/4132.js +1 -1
  23. package/dist/4300.js +1 -1
  24. package/dist/4335.js +1 -1
  25. package/dist/4618.js +1 -1
  26. package/dist/4652.js +1 -1
  27. package/dist/4724.js +1 -0
  28. package/dist/4724.js.map +1 -0
  29. package/dist/4739.js +1 -1
  30. package/dist/4739.js.map +1 -1
  31. package/dist/4944.js +1 -1
  32. package/dist/5173.js +1 -1
  33. package/dist/5241.js +1 -1
  34. package/dist/5442.js +1 -1
  35. package/dist/5661.js +1 -1
  36. package/dist/6022.js +1 -1
  37. package/dist/6468.js +1 -1
  38. package/dist/6540.js +1 -1
  39. package/dist/6540.js.map +1 -1
  40. package/dist/6679.js +1 -1
  41. package/dist/6840.js +1 -1
  42. package/dist/6859.js +1 -1
  43. package/dist/7097.js +1 -1
  44. package/dist/7159.js +1 -1
  45. package/dist/723.js +1 -1
  46. package/dist/7255.js +1 -1
  47. package/dist/7255.js.map +1 -1
  48. package/dist/7617.js +1 -1
  49. package/dist/795.js +1 -1
  50. package/dist/8163.js +1 -1
  51. package/dist/8349.js +1 -1
  52. package/dist/8618.js +1 -1
  53. package/dist/8708.js +2 -0
  54. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  55. package/dist/8708.js.map +1 -0
  56. package/dist/890.js +1 -1
  57. package/dist/9214.js +1 -1
  58. package/dist/9538.js +1 -1
  59. package/dist/9569.js +1 -1
  60. package/dist/961.js +1 -1
  61. package/dist/961.js.map +1 -1
  62. package/dist/986.js +1 -1
  63. package/dist/9879.js +1 -1
  64. package/dist/9895.js +1 -1
  65. package/dist/9900.js +1 -1
  66. package/dist/9913.js +1 -1
  67. package/dist/main.js +1 -1
  68. package/dist/main.js.map +1 -1
  69. package/dist/openmrs-esm-billing-app.js +1 -1
  70. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +263 -301
  71. package/dist/openmrs-esm-billing-app.js.map +1 -1
  72. package/dist/routes.json +1 -1
  73. package/e2e/README.md +19 -18
  74. package/e2e/core/test.ts +1 -1
  75. package/e2e/fixtures/api.ts +1 -1
  76. package/e2e/specs/sample-test.spec.ts +0 -1
  77. package/e2e/support/github/Dockerfile +1 -1
  78. package/package.json +18 -15
  79. package/src/bill-history/bill-history.component.tsx +20 -28
  80. package/src/bill-history/bill-history.scss +4 -94
  81. package/src/bill-history/bill-history.test.tsx +37 -78
  82. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  83. package/src/bill-item-actions/edit-bill-item.modal.tsx +225 -0
  84. package/src/bill-item-actions/edit-bill-item.test.tsx +214 -40
  85. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  86. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +28 -32
  87. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  88. package/src/billable-services/bill-waiver/utils.ts +13 -3
  89. package/src/billable-services/billable-service.resource.ts +42 -26
  90. package/src/billable-services/billable-services-home.component.tsx +12 -35
  91. package/src/billable-services/billable-services-left-panel-link.component.tsx +48 -0
  92. package/src/billable-services/billable-services-left-panel-menu.component.tsx +46 -0
  93. package/src/billable-services/billable-services.component.tsx +149 -148
  94. package/src/billable-services/billable-services.scss +29 -0
  95. package/src/billable-services/billable-services.test.tsx +6 -49
  96. package/src/billable-services/cash-point/add-cash-point.modal.tsx +168 -0
  97. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  98. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  99. package/src/billable-services/create-edit/add-billable-service.component.tsx +388 -301
  100. package/src/billable-services/create-edit/add-billable-service.scss +7 -68
  101. package/src/billable-services/create-edit/add-billable-service.test.tsx +720 -77
  102. package/src/billable-services/create-edit/edit-billable-service.modal.tsx +51 -0
  103. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  104. package/src/billable-services/payment-modes/add-payment-mode.modal.tsx +121 -0
  105. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +74 -0
  106. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +125 -0
  107. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  108. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  109. package/src/billing-form/billing-checkin-form.component.tsx +21 -17
  110. package/src/billing-form/billing-checkin-form.test.tsx +99 -26
  111. package/src/billing-form/billing-form.component.tsx +222 -292
  112. package/src/billing-form/billing-form.scss +143 -0
  113. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +1 -1
  114. package/src/billing.resource.ts +69 -74
  115. package/src/bills-table/bills-table.component.tsx +3 -3
  116. package/src/bills-table/bills-table.test.tsx +98 -54
  117. package/src/config-schema.ts +52 -24
  118. package/src/dashboard.meta.ts +4 -2
  119. package/src/helpers/functions.ts +5 -4
  120. package/src/index.ts +67 -9
  121. package/src/invoice/invoice-table.component.tsx +36 -70
  122. package/src/invoice/invoice-table.scss +8 -5
  123. package/src/invoice/invoice-table.test.tsx +273 -62
  124. package/src/invoice/invoice.component.tsx +39 -32
  125. package/src/invoice/invoice.scss +11 -4
  126. package/src/invoice/invoice.test.tsx +324 -120
  127. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  128. package/src/invoice/payments/payment-form/payment-form.component.tsx +43 -34
  129. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  130. package/src/invoice/payments/payment-form/payment-form.test.tsx +216 -66
  131. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  132. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  133. package/src/invoice/payments/payments.component.tsx +55 -67
  134. package/src/invoice/payments/payments.scss +4 -3
  135. package/src/invoice/payments/payments.test.tsx +282 -0
  136. package/src/invoice/payments/utils.ts +15 -27
  137. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -2
  138. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  139. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  140. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  141. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +12 -11
  142. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +16 -14
  143. package/src/invoice/printable-invoice/printable-invoice.component.tsx +20 -34
  144. package/src/left-panel-link.test.tsx +1 -4
  145. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  146. package/src/metrics-cards/metrics-cards.scss +4 -0
  147. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  148. package/src/modal/require-payment-modal.test.tsx +27 -22
  149. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  150. package/src/routes.json +39 -8
  151. package/src/types/index.ts +81 -23
  152. package/translations/am.json +127 -75
  153. package/translations/ar.json +128 -76
  154. package/translations/ar_SY.json +128 -76
  155. package/translations/bn.json +130 -78
  156. package/translations/de.json +128 -76
  157. package/translations/en.json +128 -76
  158. package/translations/en_US.json +128 -76
  159. package/translations/es.json +127 -75
  160. package/translations/es_MX.json +128 -76
  161. package/translations/fr.json +133 -81
  162. package/translations/he.json +127 -75
  163. package/translations/hi.json +128 -76
  164. package/translations/hi_IN.json +128 -76
  165. package/translations/id.json +128 -76
  166. package/translations/it.json +154 -102
  167. package/translations/ka.json +128 -76
  168. package/translations/km.json +127 -75
  169. package/translations/ku.json +128 -76
  170. package/translations/ky.json +128 -76
  171. package/translations/lg.json +128 -76
  172. package/translations/ne.json +128 -76
  173. package/translations/pl.json +128 -76
  174. package/translations/pt.json +128 -76
  175. package/translations/pt_BR.json +128 -76
  176. package/translations/qu.json +128 -76
  177. package/translations/ro_RO.json +217 -165
  178. package/translations/ru_RU.json +128 -76
  179. package/translations/si.json +128 -76
  180. package/translations/sw.json +128 -76
  181. package/translations/sw_KE.json +128 -76
  182. package/translations/tr.json +128 -76
  183. package/translations/tr_TR.json +128 -76
  184. package/translations/uk.json +128 -76
  185. package/translations/uz.json +128 -76
  186. package/translations/uz@Latn.json +128 -76
  187. package/translations/uz_UZ.json +128 -76
  188. package/translations/vi.json +128 -76
  189. package/translations/zh.json +128 -76
  190. package/translations/zh_CN.json +159 -107
  191. package/dist/1146.js.LICENSE.txt +0 -21
  192. package/dist/2352.js +0 -1
  193. package/dist/2352.js.map +0 -1
  194. package/dist/246.js +0 -1
  195. package/dist/246.js.map +0 -1
  196. package/dist/4689.js +0 -2
  197. package/dist/4689.js.map +0 -1
  198. package/dist/6557.js +0 -2
  199. package/dist/6557.js.map +0 -1
  200. package/dist/8638.js +0 -1
  201. package/dist/8638.js.map +0 -1
  202. package/dist/9968.js +0 -1
  203. package/dist/9968.js.map +0 -1
  204. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  205. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  206. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  207. package/src/invoice/payments/payments.component.test.tsx +0 -121
  208. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -1,26 +1,32 @@
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 { type FetchResponse, navigate } from '@openmrs/esm-framework';
4
+ import { navigate, type FetchResponse } from '@openmrs/esm-framework';
5
5
  import {
6
+ createBillableService,
7
+ updateBillableService,
6
8
  useBillableServices,
9
+ useConceptsSearch,
7
10
  usePaymentModes,
8
11
  useServiceTypes,
9
- createBillableSerice,
10
12
  } from '../billable-service.resource';
11
- import AddBillableService from './add-billable-service.component';
13
+ import AddBillableService, { transformServiceToFormData, normalizePrice } from './add-billable-service.component';
14
+ import type { BillableService } from '../../types';
12
15
 
13
- const mockUseBillableServices = useBillableServices as jest.MockedFunction<typeof useBillableServices>;
14
- const mockUsePaymentModes = usePaymentModes as jest.MockedFunction<typeof usePaymentModes>;
15
- const mockUseServiceTypes = useServiceTypes as jest.MockedFunction<typeof useServiceTypes>;
16
- const mockCreateBillableSerice = createBillableSerice as jest.MockedFunction<typeof createBillableSerice>;
17
- const mockNavigate = navigate as jest.MockedFunction<typeof navigate>;
16
+ const mockUseBillableServices = jest.mocked(useBillableServices);
17
+ const mockUsePaymentModes = jest.mocked(usePaymentModes);
18
+ const mockUseServiceTypes = jest.mocked(useServiceTypes);
19
+ const mockCreateBillableService = jest.mocked(createBillableService);
20
+ const mockUpdateBillableService = jest.mocked(updateBillableService);
21
+ const mockUseConceptsSearch = jest.mocked(useConceptsSearch);
18
22
 
19
23
  jest.mock('../billable-service.resource', () => ({
20
24
  useBillableServices: jest.fn(),
21
25
  usePaymentModes: jest.fn(),
22
26
  useServiceTypes: jest.fn(),
23
- createBillableSerice: jest.fn(),
27
+ createBillableService: jest.fn(),
28
+ updateBillableService: jest.fn(),
29
+ useConceptsSearch: jest.fn(),
24
30
  }));
25
31
 
26
32
  const mockPaymentModes = [
@@ -49,106 +55,743 @@ const mockServiceTypes = [
49
55
  { uuid: 'a487a743-62ce-4f93-a66b-c5154ee8987d', display: 'Adherence counselling service' },
50
56
  ];
51
57
 
52
- xdescribe('AddBillableService', () => {
53
- beforeEach(() => {
54
- jest.resetAllMocks();
58
+ // Test helpers (canonical pattern)
59
+ const setupMocks = () => {
60
+ mockUseBillableServices.mockReturnValue({
61
+ billableServices: [],
62
+ isLoading: false,
63
+ error: null,
64
+ mutate: jest.fn(),
65
+ isValidating: false,
55
66
  });
67
+ mockUsePaymentModes.mockReturnValue({ paymentModes: mockPaymentModes, error: null, isLoadingPaymentModes: false });
68
+ mockUseServiceTypes.mockReturnValue({ serviceTypes: mockServiceTypes, error: false, isLoadingServiceTypes: false });
69
+ mockUseConceptsSearch.mockReturnValue({ searchResults: [], isSearching: false, error: null });
70
+ };
56
71
 
57
- test('should render billable services form and generate correct payload', async () => {
58
- const user = userEvent.setup();
59
- const mockOnClose = jest.fn();
60
- mockUseBillableServices.mockReturnValue({
61
- billableServices: [],
62
- isLoading: false,
63
- error: null,
64
- mutate: jest.fn(),
65
- isValidating: false,
66
- });
67
- mockUsePaymentModes.mockReturnValue({ paymentModes: mockPaymentModes, error: null, isLoading: false });
68
- mockUseServiceTypes.mockReturnValue({ serviceTypes: mockServiceTypes, error: false, isLoading: false });
69
-
70
- render(<AddBillableService onClose={mockOnClose} />);
72
+ const renderAddBillableService = (props = {}) => {
73
+ const defaultProps = {
74
+ onClose: jest.fn(),
75
+ ...props,
76
+ };
77
+ setupMocks();
78
+ return render(<AddBillableService {...defaultProps} />);
79
+ };
71
80
 
72
- const formTitle = screen.getByRole('heading', { name: /Add Billable Services/i });
73
- expect(formTitle).toBeInTheDocument();
81
+ interface FillOptions {
82
+ serviceName?: string;
83
+ shortName?: string;
84
+ skipPrice?: boolean;
85
+ }
74
86
 
75
- const serviceNameTextInp = screen.getByRole('textbox', { name: /Service Name/i });
76
- expect(serviceNameTextInp).toBeInTheDocument();
87
+ const fillRequiredFields = async (user, options: FillOptions = {}) => {
88
+ const { serviceName = 'Test Service Name', shortName = 'Test Short Name', skipPrice = false } = options;
77
89
 
78
- const serviceShortNameTextInp = screen.getByRole('textbox', { name: /Short Name/i });
79
- expect(serviceShortNameTextInp).toBeInTheDocument();
90
+ if (serviceName) {
91
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), serviceName);
92
+ }
93
+ if (shortName) {
94
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), shortName);
95
+ }
80
96
 
81
- await user.type(serviceNameTextInp, 'Test Service Name');
82
- await user.type(serviceShortNameTextInp, 'Test Short Name');
97
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
98
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
83
99
 
84
- expect(serviceNameTextInp).toHaveValue('Test Service Name');
85
- expect(serviceShortNameTextInp).toHaveValue('Test Short Name');
100
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
101
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
86
102
 
87
- const serviceTypeComboBox = screen.getByRole('combobox', { name: /Service Type/i });
88
- expect(serviceTypeComboBox).toBeInTheDocument();
89
- await user.click(serviceTypeComboBox);
90
- const serviceTypeOptions = screen.getByRole('option', { name: /Lab service/i });
91
- expect(serviceTypeOptions).toBeInTheDocument();
92
- await user.click(serviceTypeOptions);
103
+ if (!skipPrice) {
104
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
105
+ await user.type(priceInput, '100');
106
+ }
107
+ };
93
108
 
94
- const addPaymentMethodBtn = screen.getByRole('button', { name: /Add payment option/i });
95
- expect(addPaymentMethodBtn).toBeInTheDocument();
109
+ const submitForm = async (user) => {
110
+ const saveBtn = screen.getByRole('button', { name: /save/i });
111
+ await user.click(saveBtn);
112
+ };
96
113
 
97
- await user.click(addPaymentMethodBtn);
114
+ describe('AddBillableService', () => {
115
+ test('should render billable services form and generate correct payload', async () => {
116
+ const user = userEvent.setup();
117
+ const mockOnClose = jest.fn();
118
+ renderAddBillableService({ onClose: mockOnClose });
98
119
 
99
- const paymentMethodComboBox = screen.getByRole('combobox', { name: /Payment Mode/i });
100
- expect(paymentMethodComboBox).toBeInTheDocument();
101
- await user.click(paymentMethodComboBox);
102
- const paymentMethodOptions = screen.getByRole('option', { name: /Cash/i });
103
- expect(paymentMethodOptions).toBeInTheDocument();
104
- await user.click(paymentMethodOptions);
120
+ const formTitle = screen.getByRole('heading', { name: /Add billable service/i });
121
+ expect(formTitle).toBeInTheDocument();
105
122
 
106
- const priceTextInp = screen.getByRole('textbox', { name: /Price/i });
107
- expect(priceTextInp).toBeInTheDocument();
108
- await user.type(priceTextInp, '1000');
123
+ await fillRequiredFields(user);
124
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
109
125
 
110
- mockCreateBillableSerice.mockReturnValue(Promise.resolve({} as FetchResponse<any>));
111
- const saveBtn = screen.getByRole('button', { name: /Save/i });
112
- expect(saveBtn).toBeInTheDocument();
113
- await user.click(saveBtn);
126
+ await submitForm(user);
114
127
 
115
- expect(mockCreateBillableSerice).toHaveBeenCalledTimes(1);
116
- expect(mockCreateBillableSerice).toHaveBeenCalledWith({
128
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
129
+ expect(mockCreateBillableService).toHaveBeenCalledWith({
117
130
  name: 'Test Service Name',
118
131
  shortName: 'Test Short Name',
119
- serviceType: undefined,
132
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
120
133
  servicePrices: [
121
134
  {
122
135
  paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
123
- price: '01000',
136
+ price: 100,
124
137
  name: 'Cash',
125
138
  },
126
139
  ],
127
140
  serviceStatus: 'ENABLED',
141
+ concept: undefined,
128
142
  });
129
- expect(mockNavigate).toHaveBeenCalledTimes(1);
130
- expect(mockNavigate).toHaveBeenCalledWith({ to: '/openmrs/spa/billable-services' });
143
+ expect(navigate).toHaveBeenCalledTimes(1);
144
+ expect(navigate).toHaveBeenCalledWith({ to: '/openmrs/spa/billable-services' });
131
145
  });
132
146
 
133
147
  test("should navigate back to billable services dashboard when 'Cancel' button is clicked", async () => {
134
148
  const user = userEvent.setup();
135
149
  const mockOnClose = jest.fn();
136
- mockUseBillableServices.mockReturnValue({
137
- billableServices: [],
138
- isLoading: false,
139
- error: null,
140
- mutate: jest.fn(),
141
- isValidating: false,
142
- });
143
- mockUsePaymentModes.mockReturnValue({ paymentModes: mockPaymentModes, error: null, isLoading: false });
144
- mockUseServiceTypes.mockReturnValue({ serviceTypes: mockServiceTypes, error: false, isLoading: false });
145
-
146
- render(<AddBillableService onClose={mockOnClose} />);
150
+ renderAddBillableService({ onClose: mockOnClose });
147
151
 
148
- const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
149
- expect(cancelBtn).toBeInTheDocument();
152
+ const cancelBtn = screen.getByRole('button', { name: /cancel/i });
150
153
  await user.click(cancelBtn);
151
154
 
152
155
  expect(mockOnClose).toHaveBeenCalledTimes(1);
153
156
  });
157
+
158
+ describe('Form Validation', () => {
159
+ test('should accept form submission without short name (short name is optional)', async () => {
160
+ const user = userEvent.setup();
161
+ renderAddBillableService();
162
+
163
+ // Fill required fields but skip short name
164
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Lab Test');
165
+
166
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
167
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
168
+
169
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
170
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
171
+
172
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
173
+ await user.type(priceInput, '50');
174
+
175
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
176
+
177
+ await submitForm(user);
178
+
179
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
180
+ expect.objectContaining({
181
+ name: 'Lab Test',
182
+ shortName: '', // Empty string is valid
183
+ }),
184
+ );
185
+ });
186
+
187
+ test('should enforce 255 character limit on service name input', async () => {
188
+ const user = userEvent.setup();
189
+ renderAddBillableService();
190
+
191
+ const longName = 'A'.repeat(300); // Try to type 300 characters
192
+ const input = screen.getByRole('textbox', { name: /Service name/i });
193
+ await user.type(input, longName);
194
+
195
+ // Input should be truncated to 255 chars due to maxLength attribute
196
+ expect(input).toHaveValue('A'.repeat(255));
197
+ });
198
+
199
+ test('should enforce 255 character limit on short name input', async () => {
200
+ const user = userEvent.setup();
201
+ renderAddBillableService();
202
+
203
+ const longShortName = 'B'.repeat(300); // Try to type 300 characters
204
+ const input = screen.getByRole('textbox', { name: /Short name/i });
205
+ await user.type(input, longShortName);
206
+
207
+ // Input should be truncated to 255 chars due to maxLength attribute
208
+ expect(input).toHaveValue('B'.repeat(255));
209
+ });
210
+
211
+ test('should show "Price must be greater than 0" error for zero price', async () => {
212
+ const user = userEvent.setup();
213
+ renderAddBillableService();
214
+
215
+ await fillRequiredFields(user, { skipPrice: true });
216
+
217
+ const priceInput = screen.getByRole('spinbutton', { name: /selling price/i });
218
+ await user.type(priceInput, '0');
219
+
220
+ await submitForm(user);
221
+
222
+ expect(screen.getByText('Price must be greater than 0')).toBeInTheDocument();
223
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
224
+ });
225
+
226
+ test('should show "Price must be greater than 0" error for negative price', async () => {
227
+ const user = userEvent.setup();
228
+ renderAddBillableService();
229
+
230
+ await fillRequiredFields(user, { skipPrice: true });
231
+
232
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
233
+ await user.type(priceInput, '-10');
234
+
235
+ await submitForm(user);
236
+
237
+ expect(screen.getByText('Price must be greater than 0')).toBeInTheDocument();
238
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
239
+ });
240
+
241
+ test('should show "Service name is required" error when service name is empty', async () => {
242
+ const user = userEvent.setup();
243
+ renderAddBillableService();
244
+
245
+ // Fill all fields except service name
246
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
247
+
248
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
249
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
250
+
251
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
252
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
253
+
254
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
255
+ await user.type(priceInput, '100');
256
+
257
+ await submitForm(user);
258
+
259
+ expect(screen.getByText('Service name is required')).toBeInTheDocument();
260
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
261
+ });
262
+
263
+ test('should accept valid decimal price values', async () => {
264
+ const user = userEvent.setup();
265
+ renderAddBillableService();
266
+
267
+ await fillRequiredFields(user, { skipPrice: true });
268
+
269
+ const priceInput = screen.getByRole('spinbutton', { name: /selling price/i });
270
+ await user.type(priceInput, '10.50');
271
+
272
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
273
+
274
+ await submitForm(user);
275
+
276
+ expect(screen.queryByText('Price is required')).not.toBeInTheDocument();
277
+ expect(screen.queryByText('Price must be greater than 0')).not.toBeInTheDocument();
278
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
279
+ expect(mockCreateBillableService).toHaveBeenCalledWith({
280
+ name: 'Test Service Name',
281
+ shortName: 'Test Short Name',
282
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
283
+ servicePrices: [
284
+ {
285
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
286
+ price: 10.5,
287
+ name: 'Cash',
288
+ },
289
+ ],
290
+ serviceStatus: 'ENABLED',
291
+ concept: undefined,
292
+ });
293
+ });
294
+
295
+ test('should show "Service type is required" error when not selected', async () => {
296
+ const user = userEvent.setup();
297
+ renderAddBillableService();
298
+
299
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
300
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
301
+
302
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
303
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
304
+
305
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
306
+ await user.type(priceInput, '100');
307
+
308
+ await submitForm(user);
309
+
310
+ expect(screen.getByText('Service type is required')).toBeInTheDocument();
311
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
312
+ });
313
+
314
+ test('should show "Payment mode is required" error when not selected', async () => {
315
+ const user = userEvent.setup();
316
+ renderAddBillableService();
317
+
318
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
319
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
320
+
321
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
322
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
323
+
324
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
325
+ await user.type(priceInput, '100');
326
+
327
+ await submitForm(user);
328
+
329
+ expect(screen.getByText('Payment mode is required')).toBeInTheDocument();
330
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
331
+ });
332
+
333
+ test('should show "Price is required" error when price field is empty', async () => {
334
+ const user = userEvent.setup();
335
+ renderAddBillableService();
336
+
337
+ await fillRequiredFields(user, { skipPrice: true });
338
+
339
+ await submitForm(user);
340
+
341
+ expect(screen.getByText('Price is required')).toBeInTheDocument();
342
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
343
+ });
344
+ });
345
+
346
+ describe('Edit Mode', () => {
347
+ const mockServiceToEdit: BillableService = {
348
+ uuid: 'existing-service-uuid',
349
+ name: 'X-Ray Service',
350
+ shortName: 'XRay',
351
+ serviceStatus: 'ENABLED',
352
+ serviceType: {
353
+ uuid: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
354
+ display: 'Lab service',
355
+ },
356
+ concept: null,
357
+ servicePrices: [
358
+ {
359
+ uuid: 'price-uuid',
360
+ name: 'Cash',
361
+ price: 150,
362
+ paymentMode: {
363
+ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
364
+ name: 'Cash',
365
+ },
366
+ },
367
+ ],
368
+ };
369
+
370
+ test('should populate form with existing service data', () => {
371
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit });
372
+
373
+ expect(screen.getByText('Edit billable service')).toBeInTheDocument();
374
+ expect(screen.getByText('X-Ray Service')).toBeInTheDocument(); // Service name shown as label
375
+ expect(screen.getByDisplayValue('XRay')).toBeInTheDocument(); // Short name
376
+ });
377
+
378
+ test('should call updateBillableService instead of createBillableService', async () => {
379
+ const user = userEvent.setup();
380
+ const mockOnClose = jest.fn();
381
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit, onClose: mockOnClose });
382
+
383
+ const shortNameInput = screen.getByDisplayValue('XRay');
384
+ await user.clear(shortNameInput);
385
+ await user.type(shortNameInput, 'X-RAY');
386
+
387
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
388
+
389
+ await submitForm(user);
390
+
391
+ expect(mockUpdateBillableService).toHaveBeenCalledTimes(1);
392
+ expect(mockUpdateBillableService).toHaveBeenCalledWith('existing-service-uuid', {
393
+ name: 'X-Ray Service',
394
+ shortName: 'X-RAY',
395
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
396
+ servicePrices: [
397
+ {
398
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
399
+ price: 150,
400
+ name: 'Cash',
401
+ },
402
+ ],
403
+ serviceStatus: 'ENABLED',
404
+ concept: undefined,
405
+ });
406
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
407
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
408
+ });
409
+
410
+ test('should call onServiceUpdated callback after successful edit', async () => {
411
+ const user = userEvent.setup();
412
+ const mockOnServiceUpdated = jest.fn();
413
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit, onServiceUpdated: mockOnServiceUpdated });
414
+
415
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
416
+
417
+ await submitForm(user);
418
+
419
+ expect(mockOnServiceUpdated).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ test('should not allow editing service name in edit mode', () => {
423
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit });
424
+
425
+ // Service name should be displayed as a label, not an editable input
426
+ expect(screen.getByText('X-Ray Service')).toBeInTheDocument();
427
+ expect(screen.queryByRole('textbox', { name: /Service name/i })).not.toBeInTheDocument();
428
+ });
429
+
430
+ test('should handle asynchronous loading of dependencies and populate form correctly', async () => {
431
+ // Scenario: User opens edit form, but payment modes/service types haven't loaded yet
432
+ // The form should wait for dependencies to load, then populate correctly
433
+
434
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit });
435
+
436
+ // After dependencies load (handled by renderAddBillableService's setupMocks),
437
+ // form should display with populated data
438
+ await screen.findByText('Edit billable service');
439
+ expect(screen.getByText('X-Ray Service')).toBeInTheDocument();
440
+ expect(screen.getByDisplayValue('XRay')).toBeInTheDocument();
441
+
442
+ // This test verifies the useEffect that calls reset() when dependencies load
443
+ // The behavior is: even if payment modes/types load after initial render,
444
+ // the form will update to show the service data
445
+ });
446
+ });
447
+
448
+ describe('Dynamic Payment Options', () => {
449
+ test('should add new payment option when clicking "Add payment option" button', async () => {
450
+ const user = userEvent.setup();
451
+ renderAddBillableService();
452
+
453
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
454
+ await user.click(addButton);
455
+
456
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
457
+ expect(paymentModeDropdowns).toHaveLength(2);
458
+ });
459
+
460
+ test('should be able to add multiple payment options', async () => {
461
+ const user = userEvent.setup();
462
+ renderAddBillableService();
463
+
464
+ // Add a second payment option
465
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
466
+ await user.click(addButton);
467
+
468
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
469
+ expect(paymentModeDropdowns).toHaveLength(2);
470
+ });
471
+
472
+ test('should allow adding multiple payment options with different payment modes', async () => {
473
+ const user = userEvent.setup();
474
+ renderAddBillableService();
475
+
476
+ // Add second payment option
477
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
478
+ await user.click(addButton);
479
+
480
+ // Fill in first payment option
481
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
482
+ await user.click(paymentModeDropdowns[0]);
483
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
484
+
485
+ const priceInputs = screen.getAllByRole('spinbutton', { name: /Selling Price/i });
486
+ await user.type(priceInputs[0], '100');
487
+
488
+ // Fill in second payment option
489
+ await user.click(paymentModeDropdowns[1]);
490
+ await user.click(screen.getByRole('option', { name: /Insurance/i }));
491
+ await user.type(priceInputs[1], '80');
492
+
493
+ // Fill other required fields
494
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Multi-price Service');
495
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'MPS');
496
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
497
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
498
+
499
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
500
+ await submitForm(user);
501
+
502
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
503
+ expect.objectContaining({
504
+ servicePrices: [
505
+ {
506
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
507
+ price: 100,
508
+ name: 'Cash',
509
+ },
510
+ {
511
+ paymentMode: 'beac329b-f1dc-4a33-9e7c-d95821a137a6',
512
+ price: 80,
513
+ name: 'Insurance',
514
+ },
515
+ ],
516
+ }),
517
+ );
518
+ });
519
+
520
+ test('should validate each payment option independently', async () => {
521
+ const user = userEvent.setup();
522
+ renderAddBillableService();
523
+
524
+ // Add second payment option
525
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
526
+ await user.click(addButton);
527
+
528
+ // Fill first payment option correctly
529
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
530
+ await user.click(paymentModeDropdowns[0]);
531
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
532
+
533
+ const priceInputs = screen.getAllByRole('spinbutton', { name: /Selling Price/i });
534
+ await user.type(priceInputs[0], '100');
535
+
536
+ // Leave second payment option incomplete (no price)
537
+ await user.click(paymentModeDropdowns[1]);
538
+ await user.click(screen.getByRole('option', { name: /Insurance/i }));
539
+
540
+ // Fill other required fields
541
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
542
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'TS');
543
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
544
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
545
+
546
+ await submitForm(user);
547
+
548
+ // Should show error for the second payment option's missing price
549
+ const priceErrors = screen.getAllByText('Price is required');
550
+ expect(priceErrors.length).toBeGreaterThan(0);
551
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
552
+ });
553
+ });
554
+
555
+ describe('Error Handling', () => {
556
+ test('should display error snackbar when create API call fails', async () => {
557
+ const user = userEvent.setup();
558
+ renderAddBillableService();
559
+
560
+ await fillRequiredFields(user);
561
+
562
+ const errorMessage = 'Network error';
563
+ mockCreateBillableService.mockRejectedValue(new Error(errorMessage));
564
+
565
+ await submitForm(user);
566
+
567
+ // Wait for async operations
568
+ await screen.findByRole('button', { name: /save/i });
569
+
570
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
571
+ expect(navigate).not.toHaveBeenCalled();
572
+ });
573
+
574
+ test('should display error snackbar when update API call fails', async () => {
575
+ const user = userEvent.setup();
576
+ const mockServiceToEdit: BillableService = {
577
+ uuid: 'service-uuid',
578
+ name: 'Test Service',
579
+ shortName: 'TS',
580
+ serviceStatus: 'ENABLED',
581
+ serviceType: {
582
+ uuid: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
583
+ display: 'Lab service',
584
+ },
585
+ concept: null,
586
+ servicePrices: [
587
+ {
588
+ uuid: 'price-uuid',
589
+ name: 'Cash',
590
+ price: 100,
591
+ paymentMode: {
592
+ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
593
+ name: 'Cash',
594
+ },
595
+ },
596
+ ],
597
+ };
598
+
599
+ renderAddBillableService({ serviceToEdit: mockServiceToEdit });
600
+
601
+ const errorMessage = 'Update failed';
602
+ mockUpdateBillableService.mockRejectedValue(new Error(errorMessage));
603
+
604
+ await submitForm(user);
605
+
606
+ // Wait for async operations
607
+ await screen.findByRole('button', { name: /save/i });
608
+
609
+ expect(mockUpdateBillableService).toHaveBeenCalledTimes(1);
610
+ expect(navigate).not.toHaveBeenCalled();
611
+ });
612
+ });
613
+ });
614
+
615
+ describe('Helper Functions', () => {
616
+ describe('transformServiceToFormData', () => {
617
+ test('should return default form data when no service is provided', () => {
618
+ const result = transformServiceToFormData();
619
+
620
+ expect(result).toEqual({
621
+ name: '',
622
+ shortName: '',
623
+ serviceType: null,
624
+ concept: null,
625
+ payment: [{ paymentMode: '', price: '' }],
626
+ });
627
+ });
628
+
629
+ test('should return default form data when undefined service is provided', () => {
630
+ const result = transformServiceToFormData(undefined);
631
+
632
+ expect(result).toEqual({
633
+ name: '',
634
+ shortName: '',
635
+ serviceType: null,
636
+ concept: null,
637
+ payment: [{ paymentMode: '', price: '' }],
638
+ });
639
+ });
640
+
641
+ test('should transform a complete service to form data', () => {
642
+ const service: BillableService = {
643
+ uuid: 'service-uuid',
644
+ name: 'X-Ray',
645
+ shortName: 'XRay',
646
+ serviceStatus: 'ENABLED',
647
+ serviceType: {
648
+ uuid: 'type-uuid',
649
+ display: 'Lab service',
650
+ },
651
+ concept: {
652
+ uuid: 'concept-search-result-uuid',
653
+ concept: {
654
+ uuid: 'concept-uuid',
655
+ display: 'Radiology',
656
+ },
657
+ display: 'Radiology',
658
+ },
659
+ servicePrices: [
660
+ {
661
+ uuid: 'price-uuid-1',
662
+ name: 'Cash',
663
+ price: 100,
664
+ paymentMode: {
665
+ uuid: 'payment-mode-uuid-1',
666
+ name: 'Cash',
667
+ },
668
+ },
669
+ {
670
+ uuid: 'price-uuid-2',
671
+ name: 'Insurance',
672
+ price: 80,
673
+ paymentMode: {
674
+ uuid: 'payment-mode-uuid-2',
675
+ name: 'Insurance',
676
+ },
677
+ },
678
+ ],
679
+ };
680
+
681
+ const result = transformServiceToFormData(service);
682
+
683
+ expect(result).toEqual({
684
+ name: 'X-Ray',
685
+ shortName: 'XRay',
686
+ serviceType: {
687
+ uuid: 'type-uuid',
688
+ display: 'Lab service',
689
+ },
690
+ concept: {
691
+ uuid: 'concept-search-result-uuid',
692
+ display: 'Radiology',
693
+ },
694
+ payment: [
695
+ {
696
+ paymentMode: 'payment-mode-uuid-1',
697
+ price: 100,
698
+ },
699
+ {
700
+ paymentMode: 'payment-mode-uuid-2',
701
+ price: 80,
702
+ },
703
+ ],
704
+ });
705
+ });
706
+
707
+ test('should handle service without concept', () => {
708
+ const service: BillableService = {
709
+ uuid: 'service-uuid',
710
+ name: 'Basic Service',
711
+ shortName: 'BS',
712
+ serviceStatus: 'ENABLED',
713
+ serviceType: {
714
+ uuid: 'type-uuid',
715
+ display: 'General',
716
+ },
717
+ concept: null,
718
+ servicePrices: [
719
+ {
720
+ uuid: 'price-uuid',
721
+ name: 'Cash',
722
+ price: 50,
723
+ paymentMode: {
724
+ uuid: 'payment-mode-uuid',
725
+ name: 'Cash',
726
+ },
727
+ },
728
+ ],
729
+ };
730
+
731
+ const result = transformServiceToFormData(service);
732
+
733
+ expect(result.concept).toBeNull();
734
+ });
735
+
736
+ test('should handle service with missing or empty price using nullish coalescing', () => {
737
+ const service: BillableService = {
738
+ uuid: 'service-uuid',
739
+ name: 'Test Service',
740
+ shortName: 'TS',
741
+ serviceStatus: 'ENABLED',
742
+ serviceType: {
743
+ uuid: 'type-uuid',
744
+ display: 'General',
745
+ },
746
+ concept: null,
747
+ servicePrices: [
748
+ {
749
+ uuid: 'price-uuid',
750
+ name: 'Cash',
751
+ price: 0, // Falsy but valid
752
+ paymentMode: {
753
+ uuid: 'payment-mode-uuid',
754
+ name: 'Cash',
755
+ },
756
+ },
757
+ ],
758
+ };
759
+
760
+ const result = transformServiceToFormData(service);
761
+
762
+ // Price 0 should be preserved (not converted to empty string)
763
+ expect(result.payment[0].price).toBe(0);
764
+ });
765
+ });
766
+
767
+ describe('normalizePrice', () => {
768
+ test('should return number as-is', () => {
769
+ expect(normalizePrice(100)).toBe(100);
770
+ expect(normalizePrice(10.5)).toBe(10.5);
771
+ expect(normalizePrice(0)).toBe(0);
772
+ });
773
+
774
+ test('should convert string to number', () => {
775
+ expect(normalizePrice('100')).toBe(100);
776
+ expect(normalizePrice('10.5')).toBe(10.5);
777
+ expect(normalizePrice('0')).toBe(0);
778
+ });
779
+
780
+ test('should handle decimal strings correctly', () => {
781
+ expect(normalizePrice('10.99')).toBe(10.99);
782
+ expect(normalizePrice('0.50')).toBe(0.5);
783
+ });
784
+
785
+ test('should handle undefined by converting to NaN', () => {
786
+ expect(normalizePrice(undefined)).toBeNaN();
787
+ });
788
+
789
+ test('should handle empty string by converting to NaN', () => {
790
+ expect(normalizePrice('')).toBeNaN();
791
+ });
792
+
793
+ test('should handle invalid string by converting to NaN', () => {
794
+ expect(normalizePrice('invalid')).toBeNaN();
795
+ });
796
+ });
154
797
  });