@openmrs/esm-billing-app 1.0.2-pre.96 → 1.0.2-pre.966

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/.eslintrc +16 -2
  2. package/README.md +54 -9
  3. package/__mocks__/bills.mock.ts +12 -0
  4. package/__mocks__/react-i18next.js +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1146.js +1 -2
  7. package/dist/1146.js.map +1 -1
  8. package/dist/1197.js +1 -1
  9. package/dist/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/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/8572.js +1 -0
  53. package/dist/8572.js.map +1 -0
  54. package/dist/8618.js +1 -1
  55. package/dist/8708.js +2 -0
  56. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  57. package/dist/8708.js.map +1 -0
  58. package/dist/890.js +1 -1
  59. package/dist/9214.js +1 -1
  60. package/dist/9538.js +1 -1
  61. package/dist/9569.js +1 -1
  62. package/dist/961.js +1 -1
  63. package/dist/961.js.map +1 -1
  64. package/dist/986.js +1 -1
  65. package/dist/9879.js +1 -1
  66. package/dist/9895.js +1 -1
  67. package/dist/9900.js +1 -1
  68. package/dist/9913.js +1 -1
  69. package/dist/main.js +1 -1
  70. package/dist/main.js.map +1 -1
  71. package/dist/openmrs-esm-billing-app.js +1 -1
  72. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +271 -285
  73. package/dist/openmrs-esm-billing-app.js.map +1 -1
  74. package/dist/routes.json +1 -1
  75. package/e2e/README.md +19 -18
  76. package/e2e/core/test.ts +1 -1
  77. package/e2e/fixtures/api.ts +1 -1
  78. package/e2e/specs/sample-test.spec.ts +0 -1
  79. package/e2e/support/github/Dockerfile +1 -1
  80. package/package.json +18 -15
  81. package/src/bill-history/bill-history.component.tsx +20 -28
  82. package/src/bill-history/bill-history.scss +4 -94
  83. package/src/bill-history/bill-history.test.tsx +37 -78
  84. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  85. package/src/bill-item-actions/edit-bill-item.modal.tsx +226 -0
  86. package/src/bill-item-actions/edit-bill-item.test.tsx +233 -40
  87. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  88. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +34 -37
  89. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  90. package/src/billable-services/bill-waiver/utils.ts +13 -3
  91. package/src/billable-services/{create-edit/add-billable-service.scss → billable-service-form/billable-service-form.scss} +32 -64
  92. package/src/billable-services/billable-service-form/billable-service-form.test.tsx +1048 -0
  93. package/src/billable-services/billable-service-form/billable-service-form.workspace.tsx +515 -0
  94. package/src/billable-services/billable-service.resource.ts +71 -27
  95. package/src/billable-services/billable-services-home.component.tsx +13 -42
  96. package/src/billable-services/billable-services-left-panel-link.component.tsx +48 -0
  97. package/src/billable-services/billable-services-left-panel-menu.component.tsx +46 -0
  98. package/src/billable-services/billable-services-menu-item/item.component.tsx +5 -4
  99. package/src/billable-services/billable-services.component.tsx +156 -152
  100. package/src/billable-services/billable-services.scss +29 -0
  101. package/src/billable-services/billable-services.test.tsx +6 -49
  102. package/src/billable-services/cash-point/add-cash-point.modal.tsx +170 -0
  103. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  104. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  105. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  106. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +77 -0
  107. package/src/billable-services/payment-modes/payment-mode-form.modal.tsx +131 -0
  108. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +139 -0
  109. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  110. package/src/billable-services-admin-card-link.component.test.tsx +2 -2
  111. package/src/billable-services-admin-card-link.component.tsx +1 -1
  112. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  113. package/src/billing-form/billing-checkin-form.component.tsx +21 -17
  114. package/src/billing-form/billing-checkin-form.test.tsx +99 -26
  115. package/src/billing-form/billing-form.component.tsx +226 -289
  116. package/src/billing-form/billing-form.scss +143 -0
  117. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +1 -1
  118. package/src/billing.resource.ts +69 -74
  119. package/src/bills-table/bills-table.component.tsx +3 -3
  120. package/src/bills-table/bills-table.test.tsx +98 -54
  121. package/src/config-schema.ts +52 -24
  122. package/src/dashboard.meta.ts +4 -2
  123. package/src/helpers/functions.ts +5 -4
  124. package/src/index.ts +71 -9
  125. package/src/invoice/invoice-table.component.tsx +36 -70
  126. package/src/invoice/invoice-table.scss +8 -5
  127. package/src/invoice/invoice-table.test.tsx +273 -62
  128. package/src/invoice/invoice.component.tsx +39 -33
  129. package/src/invoice/invoice.scss +11 -4
  130. package/src/invoice/invoice.test.tsx +324 -120
  131. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  132. package/src/invoice/payments/payment-form/payment-form.component.tsx +43 -34
  133. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  134. package/src/invoice/payments/payment-form/payment-form.test.tsx +216 -66
  135. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  136. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  137. package/src/invoice/payments/payments.component.tsx +55 -67
  138. package/src/invoice/payments/payments.scss +4 -3
  139. package/src/invoice/payments/payments.test.tsx +282 -0
  140. package/src/invoice/payments/utils.ts +15 -27
  141. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -3
  142. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  143. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  144. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  145. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +12 -11
  146. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +16 -14
  147. package/src/invoice/printable-invoice/printable-invoice.component.tsx +20 -34
  148. package/src/left-panel-link.test.tsx +1 -4
  149. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  150. package/src/metrics-cards/metrics-cards.scss +4 -0
  151. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  152. package/src/modal/require-payment-modal.test.tsx +27 -22
  153. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  154. package/src/routes.json +44 -20
  155. package/src/types/index.ts +86 -23
  156. package/translations/am.json +133 -78
  157. package/translations/ar.json +134 -79
  158. package/translations/ar_SY.json +134 -79
  159. package/translations/bn.json +136 -81
  160. package/translations/de.json +134 -79
  161. package/translations/en.json +134 -79
  162. package/translations/en_US.json +134 -79
  163. package/translations/es.json +133 -78
  164. package/translations/es_MX.json +134 -79
  165. package/translations/fr.json +139 -84
  166. package/translations/he.json +133 -78
  167. package/translations/hi.json +134 -79
  168. package/translations/hi_IN.json +134 -79
  169. package/translations/id.json +134 -79
  170. package/translations/it.json +160 -105
  171. package/translations/ka.json +134 -79
  172. package/translations/km.json +133 -78
  173. package/translations/ku.json +134 -79
  174. package/translations/ky.json +134 -79
  175. package/translations/lg.json +134 -79
  176. package/translations/ne.json +134 -79
  177. package/translations/pl.json +134 -79
  178. package/translations/pt.json +134 -79
  179. package/translations/pt_BR.json +134 -79
  180. package/translations/qu.json +134 -79
  181. package/translations/ro_RO.json +220 -165
  182. package/translations/ru_RU.json +134 -79
  183. package/translations/si.json +134 -79
  184. package/translations/sw.json +134 -79
  185. package/translations/sw_KE.json +134 -79
  186. package/translations/tr.json +134 -79
  187. package/translations/tr_TR.json +134 -79
  188. package/translations/uk.json +134 -79
  189. package/translations/uz.json +134 -79
  190. package/translations/uz@Latn.json +134 -79
  191. package/translations/uz_UZ.json +134 -79
  192. package/translations/vi.json +134 -79
  193. package/translations/zh.json +134 -79
  194. package/translations/zh_CN.json +164 -109
  195. package/dist/1146.js.LICENSE.txt +0 -21
  196. package/dist/2352.js +0 -1
  197. package/dist/2352.js.map +0 -1
  198. package/dist/246.js +0 -1
  199. package/dist/246.js.map +0 -1
  200. package/dist/4689.js +0 -2
  201. package/dist/4689.js.map +0 -1
  202. package/dist/6557.js +0 -2
  203. package/dist/6557.js.map +0 -1
  204. package/dist/8638.js +0 -1
  205. package/dist/8638.js.map +0 -1
  206. package/dist/9968.js +0 -1
  207. package/dist/9968.js.map +0 -1
  208. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  209. package/src/billable-services/create-edit/add-billable-service.component.tsx +0 -401
  210. package/src/billable-services/create-edit/add-billable-service.test.tsx +0 -154
  211. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  212. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  213. package/src/invoice/payments/payments.component.test.tsx +0 -121
  214. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -0,0 +1,515 @@
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
+ .max(
164
+ MAX_NAME_LENGTH,
165
+ t('shortNameExceedsLimit', 'Short name cannot exceed {{MAX_NAME_LENGTH}} characters', { MAX_NAME_LENGTH }),
166
+ )
167
+ .optional(),
168
+ serviceType: z
169
+ .object({
170
+ uuid: z.string(),
171
+ display: z.string(),
172
+ })
173
+ .nullable()
174
+ .refine((val) => val !== null, t('serviceTypeRequired', 'Service type is required')),
175
+ concept: z
176
+ .object({
177
+ uuid: z.string(),
178
+ display: z.string(),
179
+ })
180
+ .nullable()
181
+ .optional(),
182
+ payment: z.array(servicePriceSchema).min(1, t('paymentOptionRequired', 'At least one payment option is required')),
183
+ });
184
+ };
185
+
186
+ const BillableServiceFormWorkspace: React.FC<BillableServiceFormWorkspaceProps> = ({
187
+ serviceToEdit,
188
+ closeWorkspace,
189
+ closeWorkspaceWithSavedChanges,
190
+ onWorkspaceClose,
191
+ }) => {
192
+ const { t } = useTranslation();
193
+ const layout = useLayoutType();
194
+ const isTablet = layout === 'tablet';
195
+ const { paymentModes, isLoadingPaymentModes } = usePaymentModes();
196
+ const { serviceTypes, isLoadingServiceTypes } = useServiceTypes();
197
+
198
+ const billableServiceSchema = useMemo(() => createBillableServiceSchema(t), [t]);
199
+
200
+ const {
201
+ control,
202
+ handleSubmit,
203
+ formState: { errors },
204
+ setValue,
205
+ reset,
206
+ } = useForm<BillableServiceFormData>({
207
+ mode: 'all',
208
+ defaultValues: transformServiceToFormData(serviceToEdit),
209
+ resolver: zodResolver(billableServiceSchema),
210
+ });
211
+ const { fields, remove, append } = useFieldArray({ name: 'payment', control });
212
+
213
+ const handleAppendPaymentMode = () => append(DEFAULT_PAYMENT_OPTION);
214
+ const handleRemovePaymentMode = (index: number) => remove(index);
215
+
216
+ const searchInputRef = useRef(null);
217
+ const [isSubmitting, setIsSubmitting] = useState(false);
218
+
219
+ const selectedConcept = useWatch({ control, name: 'concept' });
220
+ const [searchTerm, setSearchTerm] = useState('');
221
+ const debouncedSearchTerm = useDebounce(searchTerm.trim());
222
+ const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm);
223
+
224
+ // Re-initialize form when editing and dependencies load
225
+ // Needed because serviceTypes/paymentModes may not be available during initial render
226
+ useEffect(() => {
227
+ if (serviceToEdit && !isLoadingPaymentModes && !isLoadingServiceTypes) {
228
+ reset(transformServiceToFormData(serviceToEdit));
229
+ }
230
+ }, [serviceToEdit, isLoadingPaymentModes, isLoadingServiceTypes, reset]);
231
+
232
+ const onSubmit = async (data: BillableServiceFormData) => {
233
+ setIsSubmitting(true);
234
+
235
+ const payload = {
236
+ name: data.name,
237
+ shortName: data.shortName || '',
238
+ serviceType: data.serviceType!.uuid,
239
+ servicePrices: data.payment.map((payment) => {
240
+ const mode = paymentModes.find((m) => m.uuid === payment.paymentMode);
241
+ return {
242
+ paymentMode: payment.paymentMode,
243
+ name: mode?.name || 'Unknown',
244
+ price: normalizePrice(payment.price),
245
+ };
246
+ }),
247
+ serviceStatus: 'ENABLED',
248
+ concept: data.concept?.uuid,
249
+ };
250
+
251
+ try {
252
+ if (serviceToEdit) {
253
+ await updateBillableService(serviceToEdit.uuid, payload);
254
+ } else {
255
+ await createBillableService(payload);
256
+ }
257
+
258
+ showSnackbar({
259
+ title: serviceToEdit
260
+ ? t('billableServiceUpdated', 'Billable service updated')
261
+ : t('billableServiceCreated', 'Billable service created'),
262
+ subtitle: serviceToEdit
263
+ ? t('billableServiceUpdatedSuccessfully', 'Billable service updated successfully')
264
+ : t('billableServiceCreatedSuccessfully', 'Billable service created successfully'),
265
+ kind: 'success',
266
+ });
267
+
268
+ // Call onWorkspaceClose callback to refresh data in parent component
269
+ if (onWorkspaceClose) {
270
+ onWorkspaceClose();
271
+ }
272
+
273
+ // Close the workspace
274
+ if (closeWorkspaceWithSavedChanges) {
275
+ closeWorkspaceWithSavedChanges();
276
+ } else {
277
+ closeWorkspace();
278
+ }
279
+ } catch (error) {
280
+ showSnackbar({
281
+ title: t('billPaymentError', 'Bill payment error'),
282
+ kind: 'error',
283
+ subtitle: error instanceof Error ? error.message : String(error),
284
+ });
285
+ } finally {
286
+ setIsSubmitting(false);
287
+ }
288
+ };
289
+
290
+ const getPaymentErrorMessage = () => {
291
+ const paymentError = errors.payment;
292
+ if (paymentError && typeof paymentError.message === 'string') {
293
+ return paymentError.message;
294
+ }
295
+ return null;
296
+ };
297
+
298
+ if (isLoadingPaymentModes || isLoadingServiceTypes) {
299
+ return (
300
+ <InlineLoading
301
+ status="active"
302
+ iconDescription={t('loadingDescription', 'Loading')}
303
+ description={t('loading', 'Loading data') + '...'}
304
+ />
305
+ );
306
+ }
307
+
308
+ return (
309
+ <Form
310
+ aria-label={t('billableServiceForm', 'Billable service form')}
311
+ className={styles.form}
312
+ id="billable-service-form"
313
+ onSubmit={handleSubmit(onSubmit)}>
314
+ <Stack className={styles.stack} gap={5}>
315
+ <FormGroup className={styles.formGroup}>
316
+ {serviceToEdit ? (
317
+ <FormLabel className={styles.serviceNameLabel}>{serviceToEdit.name}</FormLabel>
318
+ ) : (
319
+ <Controller
320
+ name="name"
321
+ control={control}
322
+ render={({ field }) => (
323
+ <Layer>
324
+ <TextInput
325
+ {...field}
326
+ enableCounter
327
+ id="serviceName"
328
+ invalid={!!errors.name}
329
+ invalidText={errors.name?.message}
330
+ labelText={t('serviceName', 'Service name')}
331
+ maxCount={MAX_NAME_LENGTH}
332
+ placeholder={t('enterServiceName', 'Enter service name')}
333
+ type="text"
334
+ />
335
+ </Layer>
336
+ )}
337
+ />
338
+ )}
339
+ </FormGroup>
340
+ <FormGroup>
341
+ <Controller
342
+ name="shortName"
343
+ control={control}
344
+ render={({ field }) => (
345
+ <Layer>
346
+ <TextInput
347
+ {...field}
348
+ enableCounter
349
+ id="serviceShortName"
350
+ invalid={!!errors.shortName}
351
+ invalidText={errors.shortName?.message}
352
+ labelText={t('shortName', 'Short name')}
353
+ maxCount={MAX_NAME_LENGTH}
354
+ placeholder={t('enterServiceShortName', 'Enter service short name')}
355
+ type="text"
356
+ value={field.value || ''}
357
+ />
358
+ </Layer>
359
+ )}
360
+ />
361
+ </FormGroup>
362
+ <FormGroup>
363
+ <FormLabel className={styles.conceptLabel}>{t('associatedConcept', 'Associated concept')}</FormLabel>
364
+ <ResponsiveWrapper>
365
+ <Search
366
+ id="conceptsSearch"
367
+ labelText={t('associatedConcept', 'Associated concept')}
368
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
369
+ onClear={() => {
370
+ setSearchTerm('');
371
+ setValue('concept', null);
372
+ }}
373
+ placeholder={t('searchConcepts', 'Search associated concept')}
374
+ ref={searchInputRef}
375
+ value={selectedConcept?.display || searchTerm}
376
+ />
377
+ </ResponsiveWrapper>
378
+
379
+ {(() => {
380
+ if (!debouncedSearchTerm || selectedConcept) {
381
+ return null;
382
+ }
383
+ if (isSearching) {
384
+ return <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />;
385
+ }
386
+ if (searchResults && searchResults.length) {
387
+ return (
388
+ <ul className={styles.conceptsList}>
389
+ {searchResults?.map((searchResult) => (
390
+ <li
391
+ className={styles.service}
392
+ key={searchResult.concept.uuid}
393
+ onClick={() => {
394
+ setValue('concept', {
395
+ uuid: searchResult.concept.uuid,
396
+ display: searchResult.display,
397
+ });
398
+ setSearchTerm('');
399
+ }}
400
+ role="menuitem">
401
+ {searchResult.display}
402
+ </li>
403
+ ))}
404
+ </ul>
405
+ );
406
+ }
407
+ return (
408
+ <Layer>
409
+ <Tile className={styles.emptyResults}>
410
+ <span>{t('noResultsFor', 'No results for {{searchTerm}}', { searchTerm: debouncedSearchTerm })}</span>
411
+ </Tile>
412
+ </Layer>
413
+ );
414
+ })()}
415
+ </FormGroup>
416
+ <FormGroup>
417
+ <Controller
418
+ name="serviceType"
419
+ control={control}
420
+ render={({ field }) => (
421
+ <Layer>
422
+ <ComboBox
423
+ id="serviceType"
424
+ items={serviceTypes ?? []}
425
+ titleText={t('serviceType', 'Service type')}
426
+ itemToString={(item: ServiceType) => item?.display || ''}
427
+ selectedItem={field.value}
428
+ onChange={({ selectedItem }: { selectedItem: ServiceType | null }) => {
429
+ field.onChange(selectedItem);
430
+ }}
431
+ placeholder={t('selectServiceType', 'Select service type')}
432
+ invalid={!!errors.serviceType}
433
+ invalidText={errors.serviceType?.message}
434
+ />
435
+ </Layer>
436
+ )}
437
+ />
438
+ </FormGroup>
439
+ <section>
440
+ <div>
441
+ {fields.map((field, index) => (
442
+ <div key={field.id} className={styles.paymentMethodContainer}>
443
+ <Controller
444
+ control={control}
445
+ name={`payment.${index}.paymentMode`}
446
+ render={({ field }) => (
447
+ <Layer>
448
+ <Dropdown
449
+ id={`paymentMode-${index}`}
450
+ invalid={!!errors?.payment?.[index]?.paymentMode}
451
+ invalidText={errors?.payment?.[index]?.paymentMode?.message}
452
+ items={getAvailablePaymentModes(paymentModes, fields, index, field.value)}
453
+ itemToString={(item) => (item ? item.name : '')}
454
+ label={t('selectPaymentMode', 'Select payment mode')}
455
+ onChange={({ selectedItem }) => field.onChange(selectedItem.uuid)}
456
+ selectedItem={paymentModes.find((mode) => mode.uuid === field.value)}
457
+ titleText={t('paymentMode', 'Payment mode')}
458
+ />
459
+ </Layer>
460
+ )}
461
+ />
462
+ <Controller
463
+ control={control}
464
+ name={`payment.${index}.price`}
465
+ render={({ field }) => (
466
+ <Layer>
467
+ <NumberInput
468
+ allowEmpty
469
+ disableWheel
470
+ id={`price-${index}`}
471
+ invalid={!!errors?.payment?.[index]?.price}
472
+ invalidText={errors?.payment?.[index]?.price?.message}
473
+ label={t('sellingPrice', 'Selling price')}
474
+ min={0}
475
+ onChange={(_, { value }) => {
476
+ field.onChange(value === '' || value === undefined ? '' : value);
477
+ }}
478
+ placeholder={t('enterSellingPrice', 'Enter selling price')}
479
+ step={0.01}
480
+ value={field.value === undefined || field.value === null ? '' : field.value}
481
+ />
482
+ </Layer>
483
+ )}
484
+ />
485
+ <div className={styles.removeButtonContainer}>
486
+ <TrashCan onClick={() => handleRemovePaymentMode(index)} className={styles.removeButton} size={20} />
487
+ </div>
488
+ </div>
489
+ ))}
490
+ <Button
491
+ className={styles.paymentButtons}
492
+ iconDescription={t('add', 'Add')}
493
+ kind="tertiary"
494
+ onClick={handleAppendPaymentMode}
495
+ renderIcon={(props) => <Add size={24} {...props} />}
496
+ type="button">
497
+ {t('addPaymentOption', 'Add payment option')}
498
+ </Button>
499
+ {getPaymentErrorMessage() && <div className={styles.errorMessage}>{getPaymentErrorMessage()}</div>}
500
+ </div>
501
+ </section>
502
+ </Stack>
503
+ <ButtonSet className={isTablet ? styles.tablet : styles.desktop}>
504
+ <Button className={styles.button} kind="secondary" disabled={isSubmitting} onClick={closeWorkspace}>
505
+ {getCoreTranslation('cancel')}
506
+ </Button>
507
+ <Button className={styles.button} kind="primary" disabled={isSubmitting} type="submit">
508
+ {isSubmitting ? <InlineLoading description={t('saving', 'Saving') + '...'} /> : getCoreTranslation('save')}
509
+ </Button>
510
+ </ButtonSet>
511
+ </Form>
512
+ );
513
+ };
514
+
515
+ 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
  },