@openmrs/esm-billing-app 1.0.2-pre.112 → 1.0.2-pre.1124

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 (241) 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/1537.js +1 -0
  10. package/dist/1537.js.map +1 -0
  11. package/dist/1856.js +1 -0
  12. package/dist/1856.js.map +1 -0
  13. package/dist/2146.js +1 -1
  14. package/dist/2177.js +1 -1
  15. package/dist/2177.js.map +1 -1
  16. package/dist/2524.js +1 -0
  17. package/dist/2524.js.map +1 -0
  18. package/dist/2690.js +1 -1
  19. package/dist/3099.js +1 -1
  20. package/dist/3584.js +1 -1
  21. package/dist/3717.js +2 -0
  22. package/dist/3717.js.map +1 -0
  23. package/dist/4055.js +1 -1
  24. package/dist/4132.js +1 -1
  25. package/dist/4300.js +1 -1
  26. package/dist/4335.js +1 -1
  27. package/dist/439.js +1 -0
  28. package/dist/4618.js +1 -1
  29. package/dist/4652.js +1 -1
  30. package/dist/4724.js +1 -0
  31. package/dist/4724.js.map +1 -0
  32. package/dist/4739.js +1 -1
  33. package/dist/4739.js.map +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/5442.js +1 -1
  38. package/dist/5661.js +1 -1
  39. package/dist/6022.js +1 -1
  40. package/dist/6468.js +1 -1
  41. package/dist/6540.js +1 -1
  42. package/dist/6540.js.map +1 -1
  43. package/dist/6589.js +1 -0
  44. package/dist/6679.js +1 -1
  45. package/dist/6840.js +1 -1
  46. package/dist/6859.js +1 -1
  47. package/dist/7097.js +1 -1
  48. package/dist/7159.js +1 -1
  49. package/dist/723.js +1 -1
  50. package/dist/7255.js +1 -1
  51. package/dist/7255.js.map +1 -1
  52. package/dist/7617.js +1 -1
  53. package/dist/795.js +1 -1
  54. package/dist/8163.js +1 -1
  55. package/dist/8349.js +1 -1
  56. package/dist/8371.js +1 -0
  57. package/dist/8572.js +1 -0
  58. package/dist/8572.js.map +1 -0
  59. package/dist/8618.js +1 -1
  60. package/dist/8708.js +2 -0
  61. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  62. package/dist/8708.js.map +1 -0
  63. package/dist/890.js +1 -1
  64. package/dist/9214.js +1 -1
  65. package/dist/9538.js +1 -1
  66. package/dist/9569.js +1 -1
  67. package/dist/961.js +1 -1
  68. package/dist/961.js.map +1 -1
  69. package/dist/986.js +1 -1
  70. package/dist/9879.js +1 -1
  71. package/dist/9895.js +1 -1
  72. package/dist/9900.js +1 -1
  73. package/dist/9913.js +1 -1
  74. package/dist/main.js +1 -1
  75. package/dist/main.js.map +1 -1
  76. package/dist/openmrs-esm-billing-app.js +1 -1
  77. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +321 -249
  78. package/dist/openmrs-esm-billing-app.js.map +1 -1
  79. package/dist/routes.json +1 -1
  80. package/e2e/README.md +19 -18
  81. package/e2e/commands/billing-operations.ts +96 -0
  82. package/e2e/commands/index.ts +3 -0
  83. package/e2e/commands/patient-operations.ts +82 -0
  84. package/e2e/commands/types.ts +73 -0
  85. package/e2e/core/test.ts +12 -1
  86. package/e2e/fixtures/api.ts +2 -3
  87. package/e2e/pages/billing-dashboard-page.ts +84 -0
  88. package/e2e/pages/billing-form-page.ts +114 -0
  89. package/e2e/pages/index.ts +4 -1
  90. package/e2e/pages/invoice-page.ts +113 -0
  91. package/e2e/pages/payment-page.ts +104 -0
  92. package/e2e/specs/billing-dashboard.spec.ts +203 -0
  93. package/e2e/specs/billing-patient-chart.spec.ts +738 -0
  94. package/e2e/support/github/Dockerfile +1 -1
  95. package/e2e/support/github/run-e2e-docker-env.sh +29 -32
  96. package/example.env +15 -1
  97. package/package.json +19 -16
  98. package/playwright.config.ts +4 -2
  99. package/src/bill-history/bill-history.component.tsx +15 -17
  100. package/src/bill-history/bill-history.scss +4 -94
  101. package/src/bill-history/bill-history.test.tsx +37 -77
  102. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  103. package/src/bill-item-actions/edit-bill-item.modal.tsx +227 -0
  104. package/src/bill-item-actions/edit-bill-item.test.tsx +233 -40
  105. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  106. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +37 -39
  107. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  108. package/src/billable-services/bill-waiver/utils.ts +13 -3
  109. package/src/billable-services/{create-edit/add-billable-service.scss → billable-service-form/billable-service-form.scss} +32 -64
  110. package/src/billable-services/billable-service-form/billable-service-form.test.tsx +1099 -0
  111. package/src/billable-services/billable-service-form/billable-service-form.workspace.tsx +516 -0
  112. package/src/billable-services/billable-service.resource.ts +71 -27
  113. package/src/billable-services/billable-services-home.component.tsx +13 -42
  114. package/src/billable-services/billable-services-left-panel-link.component.tsx +50 -0
  115. package/src/billable-services/billable-services-left-panel-menu.component.tsx +48 -0
  116. package/src/billable-services/billable-services-menu-item/item.component.tsx +5 -4
  117. package/src/billable-services/billable-services.component.tsx +156 -152
  118. package/src/billable-services/billable-services.scss +29 -0
  119. package/src/billable-services/billable-services.test.tsx +6 -49
  120. package/src/billable-services/cash-point/add-cash-point.modal.tsx +170 -0
  121. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  122. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  123. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  124. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +77 -0
  125. package/src/billable-services/payment-modes/payment-mode-form.modal.tsx +131 -0
  126. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +139 -0
  127. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  128. package/src/billable-services-admin-card-link.component.test.tsx +2 -2
  129. package/src/billable-services-admin-card-link.component.tsx +1 -1
  130. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  131. package/src/billing-form/billing-checkin-form.component.tsx +29 -29
  132. package/src/billing-form/billing-checkin-form.scss +0 -7
  133. package/src/billing-form/billing-checkin-form.test.tsx +100 -27
  134. package/src/billing-form/billing-form.component.tsx +226 -284
  135. package/src/billing-form/billing-form.scss +143 -0
  136. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +47 -41
  137. package/src/billing-form/visit-attributes/visit-attributes-form.scss +16 -3
  138. package/src/billing.resource.test.ts +503 -0
  139. package/src/billing.resource.ts +68 -76
  140. package/src/bills-table/bills-table.component.tsx +17 -24
  141. package/src/bills-table/bills-table.test.tsx +98 -54
  142. package/src/config-schema.ts +84 -31
  143. package/src/dashboard.meta.ts +5 -3
  144. package/src/helpers/functions.ts +5 -4
  145. package/src/index.ts +75 -13
  146. package/src/invoice/invoice-table.component.tsx +36 -70
  147. package/src/invoice/invoice-table.scss +8 -5
  148. package/src/invoice/invoice-table.test.tsx +273 -62
  149. package/src/invoice/invoice.component.tsx +44 -38
  150. package/src/invoice/invoice.scss +11 -4
  151. package/src/invoice/invoice.test.tsx +324 -120
  152. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  153. package/src/invoice/payments/payment-form/payment-form.component.tsx +45 -36
  154. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  155. package/src/invoice/payments/payment-form/payment-form.test.tsx +217 -67
  156. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  157. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  158. package/src/invoice/payments/payments.component.tsx +56 -68
  159. package/src/invoice/payments/payments.scss +4 -3
  160. package/src/invoice/payments/payments.test.tsx +282 -0
  161. package/src/invoice/payments/utils.ts +15 -27
  162. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -3
  163. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  164. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  165. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  166. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +20 -11
  167. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +95 -16
  168. package/src/invoice/printable-invoice/printable-invoice.component.tsx +21 -35
  169. package/src/left-panel-link.component.tsx +9 -8
  170. package/src/left-panel-link.test.tsx +1 -4
  171. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  172. package/src/metrics-cards/metrics-cards.scss +4 -0
  173. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  174. package/src/modal/require-payment-modal.test.tsx +27 -22
  175. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  176. package/src/routes.json +44 -20
  177. package/src/types/index.ts +87 -24
  178. package/translations/am.json +140 -80
  179. package/translations/ar.json +141 -81
  180. package/translations/ar_SY.json +141 -81
  181. package/translations/bn.json +143 -83
  182. package/translations/cs.json +230 -0
  183. package/translations/de.json +141 -81
  184. package/translations/en.json +142 -81
  185. package/translations/en_US.json +141 -81
  186. package/translations/es.json +140 -80
  187. package/translations/es_MX.json +141 -81
  188. package/translations/fr.json +224 -164
  189. package/translations/he.json +140 -80
  190. package/translations/hi.json +141 -81
  191. package/translations/hi_IN.json +141 -81
  192. package/translations/id.json +141 -81
  193. package/translations/it.json +166 -106
  194. package/translations/ka.json +141 -81
  195. package/translations/km.json +140 -80
  196. package/translations/ku.json +141 -81
  197. package/translations/ky.json +141 -81
  198. package/translations/lg.json +141 -81
  199. package/translations/ne.json +141 -81
  200. package/translations/pl.json +141 -81
  201. package/translations/pt.json +141 -81
  202. package/translations/pt_BR.json +141 -81
  203. package/translations/qu.json +141 -81
  204. package/translations/ro_RO.json +225 -165
  205. package/translations/ru_RU.json +141 -81
  206. package/translations/si.json +141 -81
  207. package/translations/sq.json +230 -0
  208. package/translations/sw.json +141 -81
  209. package/translations/sw_KE.json +141 -81
  210. package/translations/tr.json +141 -81
  211. package/translations/tr_TR.json +141 -81
  212. package/translations/uk.json +141 -81
  213. package/translations/uz.json +141 -81
  214. package/translations/uz@Latn.json +141 -81
  215. package/translations/uz_UZ.json +141 -81
  216. package/translations/vi.json +141 -81
  217. package/translations/zh.json +141 -81
  218. package/translations/zh_CN.json +171 -111
  219. package/translations/zh_TW.json +230 -0
  220. package/dist/1146.js.LICENSE.txt +0 -21
  221. package/dist/2352.js +0 -1
  222. package/dist/2352.js.map +0 -1
  223. package/dist/246.js +0 -1
  224. package/dist/246.js.map +0 -1
  225. package/dist/4689.js +0 -2
  226. package/dist/4689.js.map +0 -1
  227. package/dist/6557.js +0 -2
  228. package/dist/6557.js.map +0 -1
  229. package/dist/8638.js +0 -1
  230. package/dist/8638.js.map +0 -1
  231. package/dist/9968.js +0 -1
  232. package/dist/9968.js.map +0 -1
  233. package/e2e/pages/home-page.ts +0 -9
  234. package/e2e/specs/sample-test.spec.ts +0 -11
  235. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  236. package/src/billable-services/create-edit/add-billable-service.component.tsx +0 -401
  237. package/src/billable-services/create-edit/add-billable-service.test.tsx +0 -154
  238. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  239. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  240. package/src/invoice/payments/payments.component.test.tsx +0 -121
  241. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -0,0 +1,1099 @@
1
+ import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { type FetchResponse } from '@openmrs/esm-framework';
5
+ import {
6
+ createBillableService,
7
+ updateBillableService,
8
+ useBillableServices,
9
+ useConceptsSearch,
10
+ usePaymentModes,
11
+ useServiceTypes,
12
+ } from '../billable-service.resource';
13
+ import BillableServiceFormWorkspace, {
14
+ transformServiceToFormData,
15
+ normalizePrice,
16
+ getAvailablePaymentModes,
17
+ } from './billable-service-form.workspace';
18
+ import type { BillableService } from '../../types';
19
+
20
+ const mockUseBillableServices = jest.mocked(useBillableServices);
21
+ const mockUsePaymentModes = jest.mocked(usePaymentModes);
22
+ const mockUseServiceTypes = jest.mocked(useServiceTypes);
23
+ const mockCreateBillableService = jest.mocked(createBillableService);
24
+ const mockUpdateBillableService = jest.mocked(updateBillableService);
25
+ const mockUseConceptsSearch = jest.mocked(useConceptsSearch);
26
+
27
+ jest.mock('../billable-service.resource', () => ({
28
+ useBillableServices: jest.fn(),
29
+ usePaymentModes: jest.fn(),
30
+ useServiceTypes: jest.fn(),
31
+ createBillableService: jest.fn(),
32
+ updateBillableService: jest.fn(),
33
+ useConceptsSearch: jest.fn(),
34
+ }));
35
+
36
+ const mockPaymentModes = [
37
+ { uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74', name: 'Cash', description: 'Cash Payment', retired: false },
38
+ {
39
+ uuid: 'beac329b-f1dc-4a33-9e7c-d95821a137a6',
40
+ name: 'Insurance',
41
+ description: 'Insurance method of payment',
42
+ retired: false,
43
+ },
44
+ {
45
+ uuid: '28989582-e8c3-46b0-96d0-c249cb06d5c6',
46
+ name: 'MPESA',
47
+ description: 'Mobile money method of payment',
48
+ retired: false,
49
+ },
50
+ ];
51
+
52
+ const mockServiceTypes = [
53
+ { uuid: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6', display: 'Lab service' },
54
+ { uuid: 'b75e466f-a6f5-4d5e-849a-84424d3c85cd', display: 'Pharmacy service' },
55
+ { uuid: 'ce914b2d-44f6-4b6c-933f-c57a3938e35b', display: 'Peer educator service' },
56
+ { uuid: 'c23d3224-2218-4007-8f22-e1f3d5a8e58a', display: 'Nutrition service' },
57
+ { uuid: '65487ff4-63b3-452a-8985-6a1f4a0cc08d', display: 'TB service' },
58
+ { uuid: '9db142d5-5cc4-4c05-9f83-06ed294caa67', display: 'Family planning service' },
59
+ { uuid: 'a487a743-62ce-4f93-a66b-c5154ee8987d', display: 'Adherence counselling service' },
60
+ ];
61
+
62
+ // Test helpers
63
+ const setupMocks = () => {
64
+ mockUseBillableServices.mockReturnValue({
65
+ billableServices: [],
66
+ isLoading: false,
67
+ error: null,
68
+ mutate: jest.fn(),
69
+ isValidating: false,
70
+ });
71
+ mockUsePaymentModes.mockReturnValue({
72
+ paymentModes: mockPaymentModes,
73
+ error: null,
74
+ isLoadingPaymentModes: false,
75
+ mutate: jest.fn(),
76
+ });
77
+ mockUseServiceTypes.mockReturnValue({ serviceTypes: mockServiceTypes, error: false, isLoadingServiceTypes: false });
78
+ mockUseConceptsSearch.mockReturnValue({ searchResults: [], isSearching: false, error: null });
79
+ };
80
+
81
+ const renderBillableServicesForm = (props = {}) => {
82
+ const defaultProps = {
83
+ closeWorkspace: jest.fn(),
84
+ closeWorkspaceWithSavedChanges: jest.fn(),
85
+ ...props,
86
+ };
87
+ setupMocks();
88
+ return render(<BillableServiceFormWorkspace {...defaultProps} />);
89
+ };
90
+
91
+ interface FillOptions {
92
+ serviceName?: string;
93
+ shortName?: string;
94
+ skipPrice?: boolean;
95
+ }
96
+
97
+ const fillRequiredFields = async (user: ReturnType<typeof userEvent.setup>, options: FillOptions = {}) => {
98
+ const { serviceName = 'Test Service Name', shortName = 'Test Short Name', skipPrice = false } = options;
99
+
100
+ if (serviceName) {
101
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), serviceName);
102
+ }
103
+ if (shortName) {
104
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), shortName);
105
+ }
106
+
107
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
108
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
109
+
110
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
111
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
112
+
113
+ if (!skipPrice) {
114
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
115
+ await user.type(priceInput, '100');
116
+ }
117
+ };
118
+
119
+ const submitForm = async () => {
120
+ const user = userEvent.setup();
121
+ const saveButton = screen.getByRole('button', { name: /save/i });
122
+ await user.click(saveButton);
123
+ };
124
+
125
+ describe('BillableServiceFormWorkspace', () => {
126
+ test('should render billable services form and generate correct payload', async () => {
127
+ const user = userEvent.setup();
128
+ const mockCloseWorkspace = jest.fn();
129
+ renderBillableServicesForm({ closeWorkspace: mockCloseWorkspace });
130
+
131
+ await fillRequiredFields(user);
132
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
133
+
134
+ await submitForm();
135
+
136
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
137
+ expect(mockCreateBillableService).toHaveBeenCalledWith({
138
+ name: 'Test Service Name',
139
+ shortName: 'Test Short Name',
140
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
141
+ servicePrices: [
142
+ {
143
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
144
+ price: 100,
145
+ name: 'Cash',
146
+ },
147
+ ],
148
+ serviceStatus: 'ENABLED',
149
+ concept: undefined,
150
+ });
151
+ });
152
+
153
+ describe('Workspace Interactions', () => {
154
+ test('should call closeWorkspace when Cancel button is clicked', async () => {
155
+ const user = userEvent.setup();
156
+ const mockCloseWorkspace = jest.fn();
157
+ renderBillableServicesForm({ closeWorkspace: mockCloseWorkspace });
158
+
159
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
160
+ await user.click(cancelButton);
161
+
162
+ expect(mockCloseWorkspace).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ test('should call closeWorkspaceWithSavedChanges after successful save', async () => {
166
+ const user = userEvent.setup();
167
+ const mockCloseWorkspaceWithSavedChanges = jest.fn();
168
+ renderBillableServicesForm({ closeWorkspaceWithSavedChanges: mockCloseWorkspaceWithSavedChanges });
169
+
170
+ await fillRequiredFields(user);
171
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
172
+ await submitForm();
173
+
174
+ // Wait for async submission
175
+ await new Promise((resolve) => setTimeout(resolve, 100));
176
+
177
+ expect(mockCloseWorkspaceWithSavedChanges).toHaveBeenCalledTimes(1);
178
+ });
179
+
180
+ test('should disable buttons during submission', async () => {
181
+ const user = userEvent.setup();
182
+ let resolveCreate: (value: any) => void;
183
+ const createPromise = new Promise((resolve) => {
184
+ resolveCreate = resolve;
185
+ });
186
+ mockCreateBillableService.mockReturnValue(createPromise as any);
187
+
188
+ renderBillableServicesForm();
189
+
190
+ await fillRequiredFields(user);
191
+ const saveButton = screen.getByRole('button', { name: /save/i });
192
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
193
+
194
+ // Click save to trigger submission
195
+ await user.click(saveButton);
196
+
197
+ // Buttons should be disabled during submission
198
+ expect(saveButton).toBeDisabled();
199
+ expect(cancelButton).toBeDisabled();
200
+
201
+ // Resolve the promise to complete submission
202
+ resolveCreate!({} as FetchResponse<any>);
203
+ await new Promise((resolve) => setTimeout(resolve, 100));
204
+ });
205
+
206
+ test('should show loading indicator in save button during submission', async () => {
207
+ const user = userEvent.setup();
208
+ let resolveCreate: (value: any) => void;
209
+ const createPromise = new Promise((resolve) => {
210
+ resolveCreate = resolve;
211
+ });
212
+ mockCreateBillableService.mockReturnValue(createPromise as any);
213
+
214
+ renderBillableServicesForm();
215
+
216
+ await fillRequiredFields(user);
217
+ const saveButton = screen.getByRole('button', { name: /save/i });
218
+
219
+ await user.click(saveButton);
220
+
221
+ // Should show loading indicator
222
+ expect(await screen.findByText(/saving/i)).toBeInTheDocument();
223
+
224
+ // Resolve the promise
225
+ resolveCreate!({} as FetchResponse<any>);
226
+ await new Promise((resolve) => setTimeout(resolve, 100));
227
+ });
228
+
229
+ test('should call onWorkspaceClose callback after successful edit', async () => {
230
+ const mockOnWorkspaceClose = jest.fn();
231
+ const mockServiceToEdit: BillableService = {
232
+ uuid: 'test-uuid',
233
+ name: 'Test Service',
234
+ shortName: 'TS',
235
+ serviceStatus: 'ENABLED',
236
+ serviceType: {
237
+ uuid: 'type-uuid',
238
+ display: 'Lab service',
239
+ },
240
+ concept: null,
241
+ servicePrices: [
242
+ {
243
+ uuid: 'price-uuid',
244
+ name: 'Cash',
245
+ price: 100,
246
+ paymentMode: {
247
+ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
248
+ name: 'Cash',
249
+ },
250
+ },
251
+ ],
252
+ };
253
+
254
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit, onWorkspaceClose: mockOnWorkspaceClose });
255
+
256
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
257
+ await submitForm();
258
+
259
+ // Wait for async submission
260
+ await new Promise((resolve) => setTimeout(resolve, 100));
261
+
262
+ expect(mockOnWorkspaceClose).toHaveBeenCalledTimes(1);
263
+ });
264
+ });
265
+
266
+ describe('Form Validation', () => {
267
+ test('should accept form submission without short name (short name is optional)', async () => {
268
+ const user = userEvent.setup();
269
+ renderBillableServicesForm();
270
+
271
+ // Fill required fields but skip short name
272
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Lab Test');
273
+
274
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
275
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
276
+
277
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
278
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
279
+
280
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
281
+ await user.type(priceInput, '50');
282
+
283
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
284
+
285
+ await submitForm();
286
+
287
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
288
+ expect.objectContaining({
289
+ name: 'Lab Test',
290
+ shortName: '', // Empty string is valid
291
+ }),
292
+ );
293
+ });
294
+
295
+ test('should trim leading and trailing whitespace from service name and short name', async () => {
296
+ const user = userEvent.setup();
297
+ renderBillableServicesForm();
298
+
299
+ // Add service name and short name with leading and trailing spaces
300
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), ' Lab Test ');
301
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), ' LT ');
302
+
303
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
304
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
305
+
306
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
307
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
308
+
309
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
310
+ await user.type(priceInput, '100');
311
+
312
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
313
+
314
+ await submitForm();
315
+
316
+ // Verify that the whitespace is trimmed before submission
317
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
318
+ expect.objectContaining({
319
+ name: 'Lab Test',
320
+ shortName: 'LT',
321
+ }),
322
+ );
323
+ });
324
+
325
+ test('should enforce 255 character limit on service name input', async () => {
326
+ const user = userEvent.setup();
327
+ renderBillableServicesForm();
328
+
329
+ const longName = 'A'.repeat(300); // Try to type 300 characters
330
+ const input = screen.getByRole('textbox', { name: /Service name/i });
331
+ await user.type(input, longName);
332
+
333
+ // Input should be truncated to 255 chars due to maxLength attribute
334
+ expect(input).toHaveValue('A'.repeat(255));
335
+ });
336
+
337
+ test('should enforce 255 character limit on short name input', async () => {
338
+ const user = userEvent.setup();
339
+ renderBillableServicesForm();
340
+
341
+ const longShortName = 'B'.repeat(300); // Try to type 300 characters
342
+ const input = screen.getByRole('textbox', { name: /Short name/i });
343
+ await user.type(input, longShortName);
344
+
345
+ // Input should be truncated to 255 chars due to maxLength attribute
346
+ expect(input).toHaveValue('B'.repeat(255));
347
+ });
348
+
349
+ test('should show "Price must be greater than 0" error for zero price', async () => {
350
+ const user = userEvent.setup();
351
+ renderBillableServicesForm();
352
+
353
+ await fillRequiredFields(user, { skipPrice: true });
354
+
355
+ const priceInput = screen.getByRole('spinbutton', { name: /selling price/i });
356
+ await user.type(priceInput, '0');
357
+
358
+ await submitForm();
359
+
360
+ expect(screen.getByText('Price must be greater than 0')).toBeInTheDocument();
361
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
362
+ });
363
+
364
+ test('should show "Price must be greater than 0" error for negative price', async () => {
365
+ const user = userEvent.setup();
366
+ renderBillableServicesForm();
367
+
368
+ await fillRequiredFields(user, { skipPrice: true });
369
+
370
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
371
+ await user.type(priceInput, '-10');
372
+
373
+ await submitForm();
374
+
375
+ expect(screen.getByText('Price must be greater than 0')).toBeInTheDocument();
376
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
377
+ });
378
+
379
+ test('should show "Service name is required" error when service name is empty', async () => {
380
+ const user = userEvent.setup();
381
+ renderBillableServicesForm();
382
+
383
+ // Fill all fields except service name
384
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
385
+
386
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
387
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
388
+
389
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
390
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
391
+
392
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
393
+ await user.type(priceInput, '100');
394
+
395
+ await submitForm();
396
+
397
+ expect(await screen.findByText('Service name is required')).toBeInTheDocument();
398
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
399
+ });
400
+
401
+ test('should accept valid decimal price values', async () => {
402
+ const user = userEvent.setup();
403
+ renderBillableServicesForm();
404
+
405
+ await fillRequiredFields(user, { skipPrice: true });
406
+
407
+ const priceInput = screen.getByRole('spinbutton', { name: /selling price/i });
408
+ await user.type(priceInput, '10.50');
409
+
410
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
411
+
412
+ await submitForm();
413
+
414
+ expect(screen.queryByText('Price is required')).not.toBeInTheDocument();
415
+ expect(screen.queryByText('Price must be greater than 0')).not.toBeInTheDocument();
416
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
417
+ expect(mockCreateBillableService).toHaveBeenCalledWith({
418
+ name: 'Test Service Name',
419
+ shortName: 'Test Short Name',
420
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
421
+ servicePrices: [
422
+ {
423
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
424
+ price: 10.5,
425
+ name: 'Cash',
426
+ },
427
+ ],
428
+ serviceStatus: 'ENABLED',
429
+ concept: undefined,
430
+ });
431
+ });
432
+
433
+ test('should show "Service type is required" error when not selected', async () => {
434
+ const user = userEvent.setup();
435
+ renderBillableServicesForm();
436
+
437
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
438
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
439
+
440
+ await user.click(screen.getByRole('combobox', { name: /Payment mode/i }));
441
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
442
+
443
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
444
+ await user.type(priceInput, '100');
445
+
446
+ await submitForm();
447
+
448
+ expect(await screen.findByText('Service type is required')).toBeInTheDocument();
449
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
450
+ });
451
+
452
+ test('should show "Payment mode is required" error when not selected', async () => {
453
+ const user = userEvent.setup();
454
+ renderBillableServicesForm();
455
+
456
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
457
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'Test Short Name');
458
+
459
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
460
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
461
+
462
+ const priceInput = screen.getByRole('spinbutton', { name: /Selling Price/i });
463
+ await user.type(priceInput, '100');
464
+
465
+ await submitForm();
466
+
467
+ expect(await screen.findByText('Payment mode is required')).toBeInTheDocument();
468
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
469
+ });
470
+
471
+ test('should show "Price is required" error when price field is empty', async () => {
472
+ const user = userEvent.setup();
473
+ renderBillableServicesForm();
474
+
475
+ await fillRequiredFields(user, { skipPrice: true });
476
+
477
+ await submitForm();
478
+
479
+ expect(await screen.findByText('Price is required')).toBeInTheDocument();
480
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
481
+ });
482
+ });
483
+
484
+ describe('Edit Mode', () => {
485
+ const mockServiceToEdit: BillableService = {
486
+ uuid: 'existing-service-uuid',
487
+ name: 'X-Ray Service',
488
+ shortName: 'XRay',
489
+ serviceStatus: 'ENABLED',
490
+ serviceType: {
491
+ uuid: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
492
+ display: 'Lab service',
493
+ },
494
+ concept: null,
495
+ servicePrices: [
496
+ {
497
+ uuid: 'price-uuid',
498
+ name: 'Cash',
499
+ price: 150,
500
+ paymentMode: {
501
+ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
502
+ name: 'Cash',
503
+ },
504
+ },
505
+ ],
506
+ };
507
+
508
+ test('should populate form with existing service data', () => {
509
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit });
510
+
511
+ expect(screen.getByText('X-Ray Service')).toBeInTheDocument(); // Service name shown as label
512
+ expect(screen.getByDisplayValue('XRay')).toBeInTheDocument(); // Short name
513
+ });
514
+
515
+ test('should call updateBillableService instead of createBillableService', async () => {
516
+ const user = userEvent.setup();
517
+ const mockCloseWorkspace = jest.fn();
518
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit, closeWorkspace: mockCloseWorkspace });
519
+
520
+ const shortNameInput = screen.getByDisplayValue('XRay');
521
+ await user.clear(shortNameInput);
522
+ await user.type(shortNameInput, 'X-RAY');
523
+
524
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
525
+
526
+ await submitForm();
527
+
528
+ expect(mockUpdateBillableService).toHaveBeenCalledTimes(1);
529
+ expect(mockUpdateBillableService).toHaveBeenCalledWith('existing-service-uuid', {
530
+ name: 'X-Ray Service',
531
+ shortName: 'X-RAY',
532
+ serviceType: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
533
+ servicePrices: [
534
+ {
535
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
536
+ price: 150,
537
+ name: 'Cash',
538
+ },
539
+ ],
540
+ serviceStatus: 'ENABLED',
541
+ concept: undefined,
542
+ });
543
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
544
+ });
545
+
546
+ test('should trim whitespace from short name when updating service', async () => {
547
+ const user = userEvent.setup();
548
+ const mockCloseWorkspace = jest.fn();
549
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit, closeWorkspace: mockCloseWorkspace });
550
+
551
+ const shortNameInput = screen.getByDisplayValue('XRay');
552
+ await user.clear(shortNameInput);
553
+ await user.type(shortNameInput, ' X-RAY ');
554
+
555
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
556
+
557
+ await submitForm();
558
+
559
+ expect(mockUpdateBillableService).toHaveBeenCalledWith(
560
+ 'existing-service-uuid',
561
+ expect.objectContaining({
562
+ name: 'X-Ray Service',
563
+ shortName: 'X-RAY', // Should be trimmed
564
+ }),
565
+ );
566
+ });
567
+
568
+ test('should call onWorkspaceClose callback after successful edit', async () => {
569
+ const mockOnWorkspaceClose = jest.fn();
570
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit, onWorkspaceClose: mockOnWorkspaceClose });
571
+
572
+ mockUpdateBillableService.mockResolvedValue({} as FetchResponse<any>);
573
+
574
+ await submitForm();
575
+
576
+ expect(mockOnWorkspaceClose).toHaveBeenCalledTimes(1);
577
+ });
578
+
579
+ test('should not allow editing service name in edit mode', () => {
580
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit });
581
+
582
+ // Service name should be displayed as a label, not an editable input
583
+ expect(screen.getByText('X-Ray Service')).toBeInTheDocument();
584
+ expect(screen.queryByRole('textbox', { name: /Service name/i })).not.toBeInTheDocument();
585
+ });
586
+
587
+ test('should handle asynchronous loading of dependencies and populate form correctly', async () => {
588
+ // Scenario: User opens edit form, but payment modes/service types haven't loaded yet
589
+ // The form should wait for dependencies to load, then populate correctly
590
+
591
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit });
592
+
593
+ // After dependencies load (handled by renderBillableServicesForm's setupMocks),
594
+ // form should display with populated data
595
+ expect(await screen.findByText('X-Ray Service')).toBeInTheDocument();
596
+ expect(screen.getByDisplayValue('XRay')).toBeInTheDocument();
597
+
598
+ // This test verifies the useEffect that calls reset() when dependencies load
599
+ // The behavior is: even if payment modes/types load after initial render,
600
+ // the form will update to show the service data
601
+ });
602
+ });
603
+
604
+ describe('Dynamic Payment Options', () => {
605
+ test('should add new payment option when clicking "Add payment option" button', async () => {
606
+ const user = userEvent.setup();
607
+ renderBillableServicesForm();
608
+
609
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
610
+ await user.click(addButton);
611
+
612
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
613
+ expect(paymentModeDropdowns).toHaveLength(2);
614
+ });
615
+
616
+ test('should be able to add multiple payment options', async () => {
617
+ const user = userEvent.setup();
618
+ renderBillableServicesForm();
619
+
620
+ // Add a second payment option
621
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
622
+ await user.click(addButton);
623
+
624
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
625
+ expect(paymentModeDropdowns).toHaveLength(2);
626
+ });
627
+
628
+ test('should allow adding multiple payment options with different payment modes', async () => {
629
+ const user = userEvent.setup();
630
+ renderBillableServicesForm();
631
+
632
+ // Add second payment option
633
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
634
+ await user.click(addButton);
635
+
636
+ // Fill in first payment option
637
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
638
+ await user.click(paymentModeDropdowns[0]);
639
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
640
+
641
+ const priceInputs = screen.getAllByRole('spinbutton', { name: /Selling Price/i });
642
+ await user.type(priceInputs[0], '100');
643
+
644
+ // Fill in second payment option
645
+ await user.click(paymentModeDropdowns[1]);
646
+ await user.click(screen.getByRole('option', { name: /Insurance/i }));
647
+ await user.type(priceInputs[1], '80');
648
+
649
+ // Fill other required fields
650
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Multi-price Service');
651
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'MPS');
652
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
653
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
654
+
655
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
656
+ await submitForm();
657
+
658
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
659
+ expect.objectContaining({
660
+ servicePrices: [
661
+ {
662
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
663
+ price: 100,
664
+ name: 'Cash',
665
+ },
666
+ {
667
+ paymentMode: 'beac329b-f1dc-4a33-9e7c-d95821a137a6',
668
+ price: 80,
669
+ name: 'Insurance',
670
+ },
671
+ ],
672
+ }),
673
+ );
674
+ });
675
+
676
+ test('should validate each payment option independently', async () => {
677
+ const user = userEvent.setup();
678
+ renderBillableServicesForm();
679
+
680
+ // Add second payment option
681
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
682
+ await user.click(addButton);
683
+
684
+ // Fill first payment option correctly
685
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
686
+ await user.click(paymentModeDropdowns[0]);
687
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
688
+
689
+ const priceInputs = screen.getAllByRole('spinbutton', { name: /Selling Price/i });
690
+ await user.type(priceInputs[0], '100');
691
+
692
+ // Leave second payment option incomplete (no price)
693
+ await user.click(paymentModeDropdowns[1]);
694
+ await user.click(screen.getByRole('option', { name: /Insurance/i }));
695
+
696
+ // Fill other required fields
697
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Test Service');
698
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'TS');
699
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
700
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
701
+
702
+ await submitForm();
703
+
704
+ // Should show error for the second payment option's missing price
705
+ const priceErrors = await screen.findAllByText('Price is required');
706
+ expect(priceErrors.length).toBeGreaterThan(0);
707
+ expect(mockCreateBillableService).not.toHaveBeenCalled();
708
+ });
709
+
710
+ test('should allow selecting different payment modes in multiple fields', async () => {
711
+ const user = userEvent.setup();
712
+ renderBillableServicesForm();
713
+
714
+ // Add second and third payment options
715
+ const addButton = screen.getByRole('button', { name: /Add payment option/i });
716
+ await user.click(addButton);
717
+ await user.click(addButton);
718
+
719
+ // Select different payment modes in each field
720
+ const paymentModeDropdowns = screen.getAllByRole('combobox', { name: /Payment mode/i });
721
+
722
+ await user.click(paymentModeDropdowns[0]);
723
+ await user.click(screen.getByRole('option', { name: /Cash/i }));
724
+
725
+ await user.click(paymentModeDropdowns[1]);
726
+ await user.click(screen.getByRole('option', { name: /Insurance/i }));
727
+
728
+ await user.click(paymentModeDropdowns[2]);
729
+ await user.click(screen.getByRole('option', { name: /MPESA/i }));
730
+
731
+ const priceInputs = screen.getAllByRole('spinbutton', { name: /Selling Price/i });
732
+ await user.type(priceInputs[0], '100');
733
+ await user.type(priceInputs[1], '80');
734
+ await user.type(priceInputs[2], '90');
735
+
736
+ // Fill other required fields
737
+ await user.type(screen.getByRole('textbox', { name: /Service name/i }), 'Multi-mode Service');
738
+ await user.type(screen.getByRole('textbox', { name: /Short name/i }), 'MMS');
739
+ await user.click(screen.getByRole('combobox', { name: /Service type/i }));
740
+ await user.click(screen.getByRole('option', { name: /Lab service/i }));
741
+
742
+ mockCreateBillableService.mockResolvedValue({} as FetchResponse<any>);
743
+ await submitForm();
744
+
745
+ // Verify all three payment modes were submitted
746
+ expect(mockCreateBillableService).toHaveBeenCalledWith(
747
+ expect.objectContaining({
748
+ servicePrices: [
749
+ {
750
+ paymentMode: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
751
+ price: 100,
752
+ name: 'Cash',
753
+ },
754
+ {
755
+ paymentMode: 'beac329b-f1dc-4a33-9e7c-d95821a137a6',
756
+ price: 80,
757
+ name: 'Insurance',
758
+ },
759
+ {
760
+ paymentMode: '28989582-e8c3-46b0-96d0-c249cb06d5c6',
761
+ price: 90,
762
+ name: 'MPESA',
763
+ },
764
+ ],
765
+ }),
766
+ );
767
+ });
768
+ });
769
+
770
+ describe('Error Handling', () => {
771
+ test('should display error snackbar when create API call fails', async () => {
772
+ const user = userEvent.setup();
773
+ renderBillableServicesForm();
774
+
775
+ await fillRequiredFields(user);
776
+
777
+ const errorMessage = 'Network error';
778
+ mockCreateBillableService.mockRejectedValue(new Error(errorMessage));
779
+
780
+ await submitForm();
781
+
782
+ // Wait for async operations to complete
783
+ await new Promise((resolve) => setTimeout(resolve, 100));
784
+
785
+ expect(mockCreateBillableService).toHaveBeenCalledTimes(1);
786
+ });
787
+
788
+ test('should display error snackbar when update API call fails', async () => {
789
+ const mockServiceToEdit: BillableService = {
790
+ uuid: 'service-uuid',
791
+ name: 'Test Service',
792
+ shortName: 'TS',
793
+ serviceStatus: 'ENABLED',
794
+ serviceType: {
795
+ uuid: 'c9604249-db0a-4d03-b074-fc6bc2fa13e6',
796
+ display: 'Lab service',
797
+ },
798
+ concept: null,
799
+ servicePrices: [
800
+ {
801
+ uuid: 'price-uuid',
802
+ name: 'Cash',
803
+ price: 100,
804
+ paymentMode: {
805
+ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
806
+ name: 'Cash',
807
+ },
808
+ },
809
+ ],
810
+ };
811
+
812
+ renderBillableServicesForm({ serviceToEdit: mockServiceToEdit });
813
+
814
+ const errorMessage = 'Update failed';
815
+ mockUpdateBillableService.mockRejectedValue(new Error(errorMessage));
816
+
817
+ await submitForm();
818
+
819
+ // Wait for async operations to complete
820
+ await new Promise((resolve) => setTimeout(resolve, 100));
821
+
822
+ expect(mockUpdateBillableService).toHaveBeenCalledTimes(1);
823
+ });
824
+ });
825
+ });
826
+
827
+ describe('Helper Functions', () => {
828
+ describe('transformServiceToFormData', () => {
829
+ test('should return default form data when no service is provided', () => {
830
+ const result = transformServiceToFormData();
831
+
832
+ expect(result).toEqual({
833
+ name: '',
834
+ shortName: '',
835
+ serviceType: null,
836
+ concept: null,
837
+ payment: [{ paymentMode: '', price: '' }],
838
+ });
839
+ });
840
+
841
+ test('should return default form data when undefined service is provided', () => {
842
+ const result = transformServiceToFormData(undefined);
843
+
844
+ expect(result).toEqual({
845
+ name: '',
846
+ shortName: '',
847
+ serviceType: null,
848
+ concept: null,
849
+ payment: [{ paymentMode: '', price: '' }],
850
+ });
851
+ });
852
+
853
+ test('should transform a complete service to form data', () => {
854
+ const service: BillableService = {
855
+ uuid: 'service-uuid',
856
+ name: 'X-Ray',
857
+ shortName: 'XRay',
858
+ serviceStatus: 'ENABLED',
859
+ serviceType: {
860
+ uuid: 'type-uuid',
861
+ display: 'Lab service',
862
+ },
863
+ concept: {
864
+ uuid: 'concept-search-result-uuid',
865
+ concept: {
866
+ uuid: 'concept-uuid',
867
+ display: 'Radiology',
868
+ },
869
+ display: 'Radiology',
870
+ },
871
+ servicePrices: [
872
+ {
873
+ uuid: 'price-uuid-1',
874
+ name: 'Cash',
875
+ price: 100,
876
+ paymentMode: {
877
+ uuid: 'payment-mode-uuid-1',
878
+ name: 'Cash',
879
+ },
880
+ },
881
+ {
882
+ uuid: 'price-uuid-2',
883
+ name: 'Insurance',
884
+ price: 80,
885
+ paymentMode: {
886
+ uuid: 'payment-mode-uuid-2',
887
+ name: 'Insurance',
888
+ },
889
+ },
890
+ ],
891
+ };
892
+
893
+ const result = transformServiceToFormData(service);
894
+
895
+ expect(result).toEqual({
896
+ name: 'X-Ray',
897
+ shortName: 'XRay',
898
+ serviceType: {
899
+ uuid: 'type-uuid',
900
+ display: 'Lab service',
901
+ },
902
+ concept: {
903
+ uuid: 'concept-search-result-uuid',
904
+ display: 'Radiology',
905
+ },
906
+ payment: [
907
+ {
908
+ paymentMode: 'payment-mode-uuid-1',
909
+ price: 100,
910
+ },
911
+ {
912
+ paymentMode: 'payment-mode-uuid-2',
913
+ price: 80,
914
+ },
915
+ ],
916
+ });
917
+ });
918
+
919
+ test('should handle service without concept', () => {
920
+ const service: BillableService = {
921
+ uuid: 'service-uuid',
922
+ name: 'Basic Service',
923
+ shortName: 'BS',
924
+ serviceStatus: 'ENABLED',
925
+ serviceType: {
926
+ uuid: 'type-uuid',
927
+ display: 'General',
928
+ },
929
+ concept: null,
930
+ servicePrices: [
931
+ {
932
+ uuid: 'price-uuid',
933
+ name: 'Cash',
934
+ price: 50,
935
+ paymentMode: {
936
+ uuid: 'payment-mode-uuid',
937
+ name: 'Cash',
938
+ },
939
+ },
940
+ ],
941
+ };
942
+
943
+ const result = transformServiceToFormData(service);
944
+
945
+ expect(result.concept).toBeNull();
946
+ });
947
+
948
+ test('should handle service with missing or empty price using nullish coalescing', () => {
949
+ const service: BillableService = {
950
+ uuid: 'service-uuid',
951
+ name: 'Test Service',
952
+ shortName: 'TS',
953
+ serviceStatus: 'ENABLED',
954
+ serviceType: {
955
+ uuid: 'type-uuid',
956
+ display: 'General',
957
+ },
958
+ concept: null,
959
+ servicePrices: [
960
+ {
961
+ uuid: 'price-uuid',
962
+ name: 'Cash',
963
+ price: 0, // Falsy but valid
964
+ paymentMode: {
965
+ uuid: 'payment-mode-uuid',
966
+ name: 'Cash',
967
+ },
968
+ },
969
+ ],
970
+ };
971
+
972
+ const result = transformServiceToFormData(service);
973
+
974
+ // Price 0 should be preserved (not converted to empty string)
975
+ expect(result.payment[0].price).toBe(0);
976
+ });
977
+ });
978
+
979
+ describe('normalizePrice', () => {
980
+ test('should return number as-is', () => {
981
+ expect(normalizePrice(100)).toBe(100);
982
+ expect(normalizePrice(10.5)).toBe(10.5);
983
+ expect(normalizePrice(0)).toBe(0);
984
+ });
985
+
986
+ test('should convert string to number', () => {
987
+ expect(normalizePrice('100')).toBe(100);
988
+ expect(normalizePrice('10.5')).toBe(10.5);
989
+ expect(normalizePrice('0')).toBe(0);
990
+ });
991
+
992
+ test('should handle decimal strings correctly', () => {
993
+ expect(normalizePrice('10.99')).toBe(10.99);
994
+ expect(normalizePrice('0.50')).toBe(0.5);
995
+ });
996
+
997
+ test('should handle undefined by converting to NaN', () => {
998
+ expect(normalizePrice(undefined)).toBeNaN();
999
+ });
1000
+
1001
+ test('should handle empty string by converting to NaN', () => {
1002
+ expect(normalizePrice('')).toBeNaN();
1003
+ });
1004
+
1005
+ test('should handle invalid string by converting to NaN', () => {
1006
+ expect(normalizePrice('invalid')).toBeNaN();
1007
+ });
1008
+ });
1009
+
1010
+ describe('getAvailablePaymentModes', () => {
1011
+ const allPaymentModes = [
1012
+ { uuid: 'cash-uuid', name: 'Cash' },
1013
+ { uuid: 'insurance-uuid', name: 'Insurance' },
1014
+ { uuid: 'mpesa-uuid', name: 'MPESA' },
1015
+ ];
1016
+
1017
+ test('should return all payment modes when no modes are selected', () => {
1018
+ const fields = [{ paymentMode: '', price: '' }];
1019
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 0, '');
1020
+
1021
+ expect(result).toEqual(allPaymentModes);
1022
+ });
1023
+
1024
+ test('should exclude already-selected payment modes from other fields', () => {
1025
+ const fields = [
1026
+ { paymentMode: 'cash-uuid', price: '100' },
1027
+ { paymentMode: '', price: '' },
1028
+ ];
1029
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 1, '');
1030
+
1031
+ expect(result).toEqual([
1032
+ { uuid: 'insurance-uuid', name: 'Insurance' },
1033
+ { uuid: 'mpesa-uuid', name: 'MPESA' },
1034
+ ]);
1035
+ expect(result).not.toContainEqual({ uuid: 'cash-uuid', name: 'Cash' });
1036
+ });
1037
+
1038
+ test('should keep current field selection visible even if selected elsewhere', () => {
1039
+ const fields = [
1040
+ { paymentMode: 'cash-uuid', price: '100' },
1041
+ { paymentMode: 'insurance-uuid', price: '80' },
1042
+ ];
1043
+ // Field 0 should still see "Cash" as an option
1044
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 0, 'cash-uuid');
1045
+
1046
+ expect(result).toContainEqual({ uuid: 'cash-uuid', name: 'Cash' });
1047
+ expect(result).not.toContainEqual({ uuid: 'insurance-uuid', name: 'Insurance' });
1048
+ expect(result).toContainEqual({ uuid: 'mpesa-uuid', name: 'MPESA' });
1049
+ });
1050
+
1051
+ test('should filter multiple selected payment modes', () => {
1052
+ const fields = [
1053
+ { paymentMode: 'cash-uuid', price: '100' },
1054
+ { paymentMode: 'insurance-uuid', price: '80' },
1055
+ { paymentMode: '', price: '' },
1056
+ ];
1057
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 2, '');
1058
+
1059
+ expect(result).toEqual([{ uuid: 'mpesa-uuid', name: 'MPESA' }]);
1060
+ });
1061
+
1062
+ test('should handle empty payment mode values correctly', () => {
1063
+ const fields = [
1064
+ { paymentMode: '', price: '' },
1065
+ { paymentMode: 'cash-uuid', price: '100' },
1066
+ { paymentMode: '', price: '' },
1067
+ ];
1068
+ // Field 0 should see all modes except Cash
1069
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 0, '');
1070
+
1071
+ expect(result).toEqual([
1072
+ { uuid: 'insurance-uuid', name: 'Insurance' },
1073
+ { uuid: 'mpesa-uuid', name: 'MPESA' },
1074
+ ]);
1075
+ });
1076
+
1077
+ test('should work with generic types having uuid property', () => {
1078
+ const customModes = [
1079
+ { uuid: 'a', customProp: 'value1' },
1080
+ { uuid: 'b', customProp: 'value2' },
1081
+ ];
1082
+ const fields = [
1083
+ { paymentMode: 'a', price: '50' },
1084
+ { paymentMode: '', price: '' },
1085
+ ];
1086
+
1087
+ const result = getAvailablePaymentModes(customModes, fields, 1, '');
1088
+
1089
+ expect(result).toEqual([{ uuid: 'b', customProp: 'value2' }]);
1090
+ });
1091
+
1092
+ test('should return all modes when only current field has a selection', () => {
1093
+ const fields = [{ paymentMode: 'cash-uuid', price: '100' }];
1094
+ const result = getAvailablePaymentModes(allPaymentModes, fields, 0, 'cash-uuid');
1095
+
1096
+ expect(result).toEqual(allPaymentModes);
1097
+ });
1098
+ });
1099
+ });