@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,516 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Button,
4
+ ButtonSet,
5
+ ComboBox,
6
+ Dropdown,
7
+ Form,
8
+ FormGroup,
9
+ FormLabel,
10
+ InlineLoading,
11
+ Layer,
12
+ NumberInput,
13
+ Search,
14
+ Stack,
15
+ TextInput,
16
+ Tile,
17
+ } from '@carbon/react';
18
+ import { type TFunction } from 'i18next';
19
+ import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
20
+ import { useTranslation } from 'react-i18next';
21
+ import { Add, TrashCan } from '@carbon/react/icons';
22
+ import { z } from 'zod';
23
+ import { zodResolver } from '@hookform/resolvers/zod';
24
+ import {
25
+ getCoreTranslation,
26
+ ResponsiveWrapper,
27
+ showSnackbar,
28
+ useDebounce,
29
+ useLayoutType,
30
+ } from '@openmrs/esm-framework';
31
+ import type { BillableService, ServicePrice } from '../../types';
32
+ import {
33
+ createBillableService,
34
+ updateBillableService,
35
+ useConceptsSearch,
36
+ usePaymentModes,
37
+ useServiceTypes,
38
+ } from '../billable-service.resource';
39
+ import styles from './billable-service-form.scss';
40
+
41
+ interface BillableServiceFormWorkspaceProps {
42
+ serviceToEdit?: BillableService;
43
+ closeWorkspace: () => void;
44
+ closeWorkspaceWithSavedChanges?: () => void;
45
+ promptBeforeClosing?: (testFcn: () => boolean) => void;
46
+ onWorkspaceClose?: () => void;
47
+ }
48
+
49
+ interface BillableServiceFormData {
50
+ name: string;
51
+ payment: PaymentModeForm[];
52
+ serviceType: ServiceType | null;
53
+ concept?: { uuid: string; display: string } | null;
54
+ shortName?: string;
55
+ }
56
+
57
+ interface PaymentModeForm {
58
+ paymentMode: string;
59
+ price: string | number | undefined;
60
+ }
61
+
62
+ interface ServiceType {
63
+ uuid: string;
64
+ display: string;
65
+ }
66
+
67
+ const DEFAULT_PAYMENT_OPTION: PaymentModeForm = { paymentMode: '', price: '' };
68
+ const MAX_NAME_LENGTH = 255;
69
+
70
+ /**
71
+ * Transforms a BillableService into form data structure
72
+ * Centralizes the mapping logic to avoid duplication between defaultValues and reset()
73
+ * Exported for testing
74
+ */
75
+ export const transformServiceToFormData = (service?: BillableService): BillableServiceFormData => {
76
+ if (!service) {
77
+ return {
78
+ name: '',
79
+ shortName: '',
80
+ serviceType: null,
81
+ concept: null,
82
+ payment: [DEFAULT_PAYMENT_OPTION],
83
+ };
84
+ }
85
+
86
+ return {
87
+ name: service.name || '',
88
+ shortName: service.shortName || '',
89
+ serviceType: service.serviceType || null,
90
+ concept: service.concept ? { uuid: service.concept.uuid, display: service.concept.display } : null,
91
+ payment: service.servicePrices?.map((servicePrice: ServicePrice) => ({
92
+ paymentMode: servicePrice.paymentMode?.uuid || '',
93
+ price: servicePrice.price ?? '',
94
+ })) || [DEFAULT_PAYMENT_OPTION],
95
+ };
96
+ };
97
+
98
+ /**
99
+ * Normalizes price value from form (string | number | undefined) to number
100
+ * Handles Carbon NumberInput which can return either type
101
+ * Exported for testing
102
+ */
103
+ export const normalizePrice = (price: string | number | undefined): number => {
104
+ if (typeof price === 'number') {
105
+ return price;
106
+ }
107
+ return parseFloat(String(price));
108
+ };
109
+
110
+ export const getAvailablePaymentModes = <T extends { uuid: string }>(
111
+ allModes: T[],
112
+ allFields: PaymentModeForm[],
113
+ currentIndex: number,
114
+ currentValue: string,
115
+ ): T[] => {
116
+ const selectedUUIDs = allFields.map((f, i) => (i !== currentIndex ? f.paymentMode : null)).filter(Boolean);
117
+
118
+ return allModes.filter((mode) => !selectedUUIDs.includes(mode.uuid) || mode.uuid === currentValue);
119
+ };
120
+
121
+ const createBillableServiceSchema = (t: TFunction) => {
122
+ const servicePriceSchema = z.object({
123
+ paymentMode: z
124
+ .string({
125
+ required_error: t('paymentModeRequired', 'Payment mode is required'),
126
+ })
127
+ .trim()
128
+ .min(1, t('paymentModeRequired', 'Payment mode is required')),
129
+ price: z.union([z.number(), z.string(), z.undefined()]).superRefine((val, ctx) => {
130
+ if (val === undefined || val === null || val === '') {
131
+ ctx.addIssue({
132
+ code: z.ZodIssueCode.custom,
133
+ message: t('priceIsRequired', 'Price is required'),
134
+ });
135
+ return;
136
+ }
137
+
138
+ const numValue = typeof val === 'number' ? val : parseFloat(val);
139
+ if (isNaN(numValue) || numValue <= 0) {
140
+ ctx.addIssue({
141
+ code: z.ZodIssueCode.custom,
142
+ message: t('priceMustBePositive', 'Price must be greater than 0'),
143
+ });
144
+ }
145
+ }),
146
+ });
147
+
148
+ return z.object({
149
+ name: z
150
+ .string({
151
+ required_error: t('serviceNameRequired', 'Service name is required'),
152
+ })
153
+ .trim()
154
+ .min(1, t('serviceNameRequired', 'Service name is required'))
155
+ .max(
156
+ MAX_NAME_LENGTH,
157
+ t('serviceNameExceedsLimit', 'Service name cannot exceed {{MAX_NAME_LENGTH}} characters', {
158
+ MAX_NAME_LENGTH,
159
+ }),
160
+ ),
161
+ shortName: z
162
+ .string()
163
+ .trim()
164
+ .max(
165
+ MAX_NAME_LENGTH,
166
+ t('shortNameExceedsLimit', 'Short name cannot exceed {{MAX_NAME_LENGTH}} characters', { MAX_NAME_LENGTH }),
167
+ )
168
+ .optional(),
169
+ serviceType: z
170
+ .object({
171
+ uuid: z.string(),
172
+ display: z.string(),
173
+ })
174
+ .nullable()
175
+ .refine((val) => val !== null, t('serviceTypeRequired', 'Service type is required')),
176
+ concept: z
177
+ .object({
178
+ uuid: z.string(),
179
+ display: z.string(),
180
+ })
181
+ .nullable()
182
+ .optional(),
183
+ payment: z.array(servicePriceSchema).min(1, t('paymentOptionRequired', 'At least one payment option is required')),
184
+ });
185
+ };
186
+
187
+ const BillableServiceFormWorkspace: React.FC<BillableServiceFormWorkspaceProps> = ({
188
+ serviceToEdit,
189
+ closeWorkspace,
190
+ closeWorkspaceWithSavedChanges,
191
+ onWorkspaceClose,
192
+ }) => {
193
+ const { t } = useTranslation();
194
+ const layout = useLayoutType();
195
+ const isTablet = layout === 'tablet';
196
+ const { paymentModes, isLoadingPaymentModes } = usePaymentModes();
197
+ const { serviceTypes, isLoadingServiceTypes } = useServiceTypes();
198
+
199
+ const billableServiceSchema = useMemo(() => createBillableServiceSchema(t), [t]);
200
+
201
+ const {
202
+ control,
203
+ handleSubmit,
204
+ formState: { errors },
205
+ setValue,
206
+ reset,
207
+ } = useForm<BillableServiceFormData>({
208
+ mode: 'all',
209
+ defaultValues: transformServiceToFormData(serviceToEdit),
210
+ resolver: zodResolver(billableServiceSchema),
211
+ });
212
+ const { fields, remove, append } = useFieldArray({ name: 'payment', control });
213
+
214
+ const handleAppendPaymentMode = () => append(DEFAULT_PAYMENT_OPTION);
215
+ const handleRemovePaymentMode = (index: number) => remove(index);
216
+
217
+ const searchInputRef = useRef(null);
218
+ const [isSubmitting, setIsSubmitting] = useState(false);
219
+
220
+ const selectedConcept = useWatch({ control, name: 'concept' });
221
+ const [searchTerm, setSearchTerm] = useState('');
222
+ const debouncedSearchTerm = useDebounce(searchTerm.trim());
223
+ const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm);
224
+
225
+ // Re-initialize form when editing and dependencies load
226
+ // Needed because serviceTypes/paymentModes may not be available during initial render
227
+ useEffect(() => {
228
+ if (serviceToEdit && !isLoadingPaymentModes && !isLoadingServiceTypes) {
229
+ reset(transformServiceToFormData(serviceToEdit));
230
+ }
231
+ }, [serviceToEdit, isLoadingPaymentModes, isLoadingServiceTypes, reset]);
232
+
233
+ const onSubmit = async (data: BillableServiceFormData) => {
234
+ setIsSubmitting(true);
235
+
236
+ const payload = {
237
+ name: data.name,
238
+ shortName: data.shortName ?? '',
239
+ serviceType: data.serviceType!.uuid,
240
+ servicePrices: data.payment.map((payment) => {
241
+ const mode = paymentModes.find((m) => m.uuid === payment.paymentMode);
242
+ return {
243
+ paymentMode: payment.paymentMode,
244
+ name: mode?.name || 'Unknown',
245
+ price: normalizePrice(payment.price),
246
+ };
247
+ }),
248
+ serviceStatus: 'ENABLED',
249
+ concept: data.concept?.uuid,
250
+ };
251
+
252
+ try {
253
+ if (serviceToEdit) {
254
+ await updateBillableService(serviceToEdit.uuid, payload);
255
+ } else {
256
+ await createBillableService(payload);
257
+ }
258
+
259
+ showSnackbar({
260
+ title: serviceToEdit
261
+ ? t('billableServiceUpdated', 'Billable service updated')
262
+ : t('billableServiceCreated', 'Billable service created'),
263
+ subtitle: serviceToEdit
264
+ ? t('billableServiceUpdatedSuccessfully', 'Billable service updated successfully')
265
+ : t('billableServiceCreatedSuccessfully', 'Billable service created successfully'),
266
+ kind: 'success',
267
+ });
268
+
269
+ // Call onWorkspaceClose callback to refresh data in parent component
270
+ if (onWorkspaceClose) {
271
+ onWorkspaceClose();
272
+ }
273
+
274
+ // Close the workspace
275
+ if (closeWorkspaceWithSavedChanges) {
276
+ closeWorkspaceWithSavedChanges();
277
+ } else {
278
+ closeWorkspace();
279
+ }
280
+ } catch (error) {
281
+ showSnackbar({
282
+ title: t('billPaymentError', 'Bill payment error'),
283
+ kind: 'error',
284
+ subtitle: error instanceof Error ? error.message : String(error),
285
+ });
286
+ } finally {
287
+ setIsSubmitting(false);
288
+ }
289
+ };
290
+
291
+ const getPaymentErrorMessage = () => {
292
+ const paymentError = errors.payment;
293
+ if (paymentError && typeof paymentError.message === 'string') {
294
+ return paymentError.message;
295
+ }
296
+ return null;
297
+ };
298
+
299
+ if (isLoadingPaymentModes || isLoadingServiceTypes) {
300
+ return (
301
+ <InlineLoading
302
+ status="active"
303
+ iconDescription={t('loadingDescription', 'Loading')}
304
+ description={t('loading', 'Loading data') + '...'}
305
+ />
306
+ );
307
+ }
308
+
309
+ return (
310
+ <Form
311
+ aria-label={t('billableServiceForm', 'Billable service form')}
312
+ className={styles.form}
313
+ id="billable-service-form"
314
+ onSubmit={handleSubmit(onSubmit)}>
315
+ <Stack className={styles.stack} gap={5}>
316
+ <FormGroup className={styles.formGroup}>
317
+ {serviceToEdit ? (
318
+ <FormLabel className={styles.serviceNameLabel}>{serviceToEdit.name}</FormLabel>
319
+ ) : (
320
+ <Controller
321
+ name="name"
322
+ control={control}
323
+ render={({ field }) => (
324
+ <Layer>
325
+ <TextInput
326
+ {...field}
327
+ enableCounter
328
+ id="serviceName"
329
+ invalid={!!errors.name}
330
+ invalidText={errors.name?.message}
331
+ labelText={t('serviceName', 'Service name')}
332
+ maxCount={MAX_NAME_LENGTH}
333
+ placeholder={t('enterServiceName', 'Enter service name')}
334
+ type="text"
335
+ />
336
+ </Layer>
337
+ )}
338
+ />
339
+ )}
340
+ </FormGroup>
341
+ <FormGroup>
342
+ <Controller
343
+ name="shortName"
344
+ control={control}
345
+ render={({ field }) => (
346
+ <Layer>
347
+ <TextInput
348
+ {...field}
349
+ enableCounter
350
+ id="serviceShortName"
351
+ invalid={!!errors.shortName}
352
+ invalidText={errors.shortName?.message}
353
+ labelText={t('shortName', 'Short name')}
354
+ maxCount={MAX_NAME_LENGTH}
355
+ placeholder={t('enterServiceShortName', 'Enter service short name')}
356
+ type="text"
357
+ value={field.value || ''}
358
+ />
359
+ </Layer>
360
+ )}
361
+ />
362
+ </FormGroup>
363
+ <FormGroup>
364
+ <FormLabel className={styles.conceptLabel}>{t('associatedConcept', 'Associated concept')}</FormLabel>
365
+ <ResponsiveWrapper>
366
+ <Search
367
+ id="conceptsSearch"
368
+ labelText={t('associatedConcept', 'Associated concept')}
369
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
370
+ onClear={() => {
371
+ setSearchTerm('');
372
+ setValue('concept', null);
373
+ }}
374
+ placeholder={t('searchConcepts', 'Search associated concept')}
375
+ ref={searchInputRef}
376
+ value={selectedConcept?.display || searchTerm}
377
+ />
378
+ </ResponsiveWrapper>
379
+
380
+ {(() => {
381
+ if (!debouncedSearchTerm || selectedConcept) {
382
+ return null;
383
+ }
384
+ if (isSearching) {
385
+ return <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />;
386
+ }
387
+ if (searchResults && searchResults.length) {
388
+ return (
389
+ <ul className={styles.conceptsList}>
390
+ {searchResults?.map((searchResult) => (
391
+ <li
392
+ className={styles.service}
393
+ key={searchResult.concept.uuid}
394
+ onClick={() => {
395
+ setValue('concept', {
396
+ uuid: searchResult.concept.uuid,
397
+ display: searchResult.display,
398
+ });
399
+ setSearchTerm('');
400
+ }}
401
+ role="menuitem">
402
+ {searchResult.display}
403
+ </li>
404
+ ))}
405
+ </ul>
406
+ );
407
+ }
408
+ return (
409
+ <Layer>
410
+ <Tile className={styles.emptyResults}>
411
+ <span>{t('noResultsFor', 'No results for {{searchTerm}}', { searchTerm: debouncedSearchTerm })}</span>
412
+ </Tile>
413
+ </Layer>
414
+ );
415
+ })()}
416
+ </FormGroup>
417
+ <FormGroup>
418
+ <Controller
419
+ name="serviceType"
420
+ control={control}
421
+ render={({ field }) => (
422
+ <Layer>
423
+ <ComboBox
424
+ id="serviceType"
425
+ items={serviceTypes ?? []}
426
+ titleText={t('serviceType', 'Service type')}
427
+ itemToString={(item: ServiceType) => item?.display || ''}
428
+ selectedItem={field.value}
429
+ onChange={({ selectedItem }: { selectedItem: ServiceType | null }) => {
430
+ field.onChange(selectedItem);
431
+ }}
432
+ placeholder={t('selectServiceType', 'Select service type')}
433
+ invalid={!!errors.serviceType}
434
+ invalidText={errors.serviceType?.message}
435
+ />
436
+ </Layer>
437
+ )}
438
+ />
439
+ </FormGroup>
440
+ <section>
441
+ <div>
442
+ {fields.map((field, index) => (
443
+ <div key={field.id} className={styles.paymentMethodContainer}>
444
+ <Controller
445
+ control={control}
446
+ name={`payment.${index}.paymentMode`}
447
+ render={({ field }) => (
448
+ <Layer>
449
+ <Dropdown
450
+ id={`paymentMode-${index}`}
451
+ invalid={!!errors?.payment?.[index]?.paymentMode}
452
+ invalidText={errors?.payment?.[index]?.paymentMode?.message}
453
+ items={getAvailablePaymentModes(paymentModes, fields, index, field.value)}
454
+ itemToString={(item) => (item ? item.name : '')}
455
+ label={t('selectPaymentMode', 'Select payment mode')}
456
+ onChange={({ selectedItem }) => field.onChange(selectedItem.uuid)}
457
+ selectedItem={paymentModes.find((mode) => mode.uuid === field.value)}
458
+ titleText={t('paymentMode', 'Payment mode')}
459
+ />
460
+ </Layer>
461
+ )}
462
+ />
463
+ <Controller
464
+ control={control}
465
+ name={`payment.${index}.price`}
466
+ render={({ field }) => (
467
+ <Layer>
468
+ <NumberInput
469
+ allowEmpty
470
+ disableWheel
471
+ id={`price-${index}`}
472
+ invalid={!!errors?.payment?.[index]?.price}
473
+ invalidText={errors?.payment?.[index]?.price?.message}
474
+ label={t('sellingPrice', 'Selling price')}
475
+ min={0}
476
+ onChange={(_, { value }) => {
477
+ field.onChange(value === '' || value === undefined ? '' : value);
478
+ }}
479
+ placeholder={t('enterSellingPrice', 'Enter selling price')}
480
+ step={0.01}
481
+ value={field.value === undefined || field.value === null ? '' : field.value}
482
+ />
483
+ </Layer>
484
+ )}
485
+ />
486
+ <div className={styles.removeButtonContainer}>
487
+ <TrashCan onClick={() => handleRemovePaymentMode(index)} className={styles.removeButton} size={20} />
488
+ </div>
489
+ </div>
490
+ ))}
491
+ <Button
492
+ className={styles.paymentButtons}
493
+ iconDescription={t('add', 'Add')}
494
+ kind="tertiary"
495
+ onClick={handleAppendPaymentMode}
496
+ renderIcon={(props) => <Add size={24} {...props} />}
497
+ type="button">
498
+ {t('addPaymentOption', 'Add payment option')}
499
+ </Button>
500
+ {getPaymentErrorMessage() && <div className={styles.errorMessage}>{getPaymentErrorMessage()}</div>}
501
+ </div>
502
+ </section>
503
+ </Stack>
504
+ <ButtonSet className={isTablet ? styles.tablet : styles.desktop}>
505
+ <Button className={styles.button} kind="secondary" disabled={isSubmitting} onClick={closeWorkspace}>
506
+ {getCoreTranslation('cancel')}
507
+ </Button>
508
+ <Button className={styles.button} kind="primary" disabled={isSubmitting} type="submit">
509
+ {isSubmitting ? <InlineLoading description={t('saving', 'Saving') + '...'} /> : getCoreTranslation('save')}
510
+ </Button>
511
+ </ButtonSet>
512
+ </Form>
513
+ );
514
+ };
515
+
516
+ export default BillableServiceFormWorkspace;
@@ -1,16 +1,22 @@
1
1
  import useSWR from 'swr';
2
2
  import { type OpenmrsResource, openmrsFetch, restBaseUrl, useOpenmrsFetchAll, useConfig } from '@openmrs/esm-framework';
3
- import { type ServiceConcept } from '../types';
4
3
  import { apiBasePath } from '../constants';
5
- import { type BillableService } from '../types/index';
4
+ import type {
5
+ BillableService,
6
+ ConceptSearchResult,
7
+ CreateBillableServicePayload,
8
+ PaymentModePayload,
9
+ UpdateBillableServicePayload,
10
+ } from '../types';
11
+ import type { BillingConfig } from '../config-schema';
6
12
 
7
13
  type ResponseObject = {
8
14
  results: Array<OpenmrsResource>;
9
15
  };
10
16
 
11
17
  export const useBillableServices = () => {
12
- const url = `${apiBasePath}billableService?v=custom:(uuid,name,shortName,serviceStatus,concept:(uuid,display,name:(name)),serviceType:(display),servicePrices:(uuid,name,price,paymentMode:(uuid,name)))`;
13
- const { data, isLoading, isValidating, error, mutate } = useOpenmrsFetchAll<BillableService[]>(url);
18
+ const url = `${apiBasePath}billableService?v=custom:(uuid,name,shortName,serviceStatus,concept:(uuid,display,name:(name)),serviceType:(display,uuid),servicePrices:(uuid,name,price,paymentMode:(uuid,name)))`;
19
+ const { data, isLoading, isValidating, error, mutate } = useOpenmrsFetchAll<BillableService>(url);
14
20
 
15
21
  return {
16
22
  billableServices: data ?? [],
@@ -22,46 +28,51 @@ export const useBillableServices = () => {
22
28
  };
23
29
 
24
30
  export function useServiceTypes() {
25
- const config = useConfig();
26
- const serviceConceptUuid = config.serviceTypes.billableService;
31
+ const { serviceTypes } = useConfig<BillingConfig>();
32
+ const serviceConceptUuid = serviceTypes.billableService;
27
33
  const url = `${restBaseUrl}/concept/${serviceConceptUuid}?v=custom:(setMembers:(uuid,display))`;
28
34
 
29
- const { data, error, isLoading } = useSWR<{ data }>(url, openmrsFetch);
35
+ const { data, error, isLoading } = useSWR<{ data: { setMembers: Array<{ uuid: string; display: string }> } }>(
36
+ url,
37
+ openmrsFetch,
38
+ );
39
+
40
+ const sortedServiceTypes = data?.data.setMembers
41
+ ? [...data.data.setMembers].sort((a, b) => a.display.localeCompare(b.display))
42
+ : [];
30
43
 
31
44
  return {
32
- serviceTypes: data?.data.setMembers ?? [],
45
+ serviceTypes: sortedServiceTypes,
33
46
  error,
34
- isLoading,
47
+ isLoadingServiceTypes: isLoading,
35
48
  };
36
49
  }
37
50
 
51
+ export interface PaymentMode extends OpenmrsResource {
52
+ name: string;
53
+ description: string;
54
+ }
55
+
38
56
  export const usePaymentModes = () => {
39
57
  const url = `${apiBasePath}paymentMode`;
40
58
 
41
- const { data, error, isLoading } = useSWR<{ data: ResponseObject }>(url, openmrsFetch);
59
+ const { data, error, isLoading, mutate } = useSWR<{ data: { results: PaymentMode[] } }>(url, openmrsFetch);
60
+ const sortedPaymentModes = data?.data.results
61
+ ? [...data.data.results].sort((a, b) => a.name.localeCompare(b.name))
62
+ : [];
42
63
 
43
64
  return {
44
- paymentModes: data?.data.results ?? [],
65
+ paymentModes: sortedPaymentModes as PaymentMode[],
45
66
  error,
46
- isLoading,
67
+ isLoadingPaymentModes: isLoading,
68
+ mutate,
47
69
  };
48
70
  };
49
71
 
50
- export const createBillableSerice = (payload: any) => {
51
- const url = `${apiBasePath}api/billable-service`;
52
- return openmrsFetch(url, {
53
- method: 'POST',
54
- body: payload,
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- },
58
- });
59
- };
60
-
61
72
  export function useConceptsSearch(conceptToLookup: string) {
62
73
  const conditionsSearchUrl = `${restBaseUrl}/conceptsearch?q=${conceptToLookup}`;
63
74
 
64
- const { data, error, isLoading } = useSWR<{ data: { results: Array<ServiceConcept> } }, Error>(
75
+ const { data, error, isLoading } = useSWR<{ data: { results: Array<ConceptSearchResult> } }, Error>(
65
76
  conceptToLookup ? conditionsSearchUrl : null,
66
77
  openmrsFetch,
67
78
  );
@@ -73,11 +84,44 @@ export function useConceptsSearch(conceptToLookup: string) {
73
84
  };
74
85
  }
75
86
 
76
- export const updateBillableService = (uuid: string, payload: any) => {
77
- const url = `${apiBasePath}/billableService/${uuid}`;
87
+ export const createBillableService = (payload: CreateBillableServicePayload) => {
88
+ const url = `${apiBasePath}api/billable-service`;
78
89
  return openmrsFetch(url, {
79
90
  method: 'POST',
80
- body: JSON.stringify(payload),
91
+ body: payload,
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ },
95
+ });
96
+ };
97
+
98
+ export const updateBillableService = (uuid: string, payload: UpdateBillableServicePayload) => {
99
+ const url = `${apiBasePath}billableService/${uuid}`;
100
+ return openmrsFetch(url, {
101
+ method: 'POST',
102
+ body: payload,
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ },
106
+ });
107
+ };
108
+
109
+ export const createPaymentMode = (payload: PaymentModePayload) => {
110
+ const url = `${restBaseUrl}/billing/paymentMode`;
111
+ return openmrsFetch(url, {
112
+ method: 'POST',
113
+ body: payload,
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ });
118
+ };
119
+
120
+ export const updatePaymentMode = (uuid: string, payload: PaymentModePayload) => {
121
+ const url = `${restBaseUrl}/billing/paymentMode/${uuid}`;
122
+ return openmrsFetch(url, {
123
+ method: 'POST',
124
+ body: payload,
81
125
  headers: {
82
126
  'Content-Type': 'application/json',
83
127
  },