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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.eslintrc +16 -2
  2. package/README.md +54 -9
  3. package/__mocks__/bills.mock.ts +12 -0
  4. package/__mocks__/react-i18next.js +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1146.js +1 -2
  7. package/dist/1146.js.map +1 -1
  8. package/dist/1197.js +1 -1
  9. package/dist/1856.js +1 -0
  10. package/dist/1856.js.map +1 -0
  11. package/dist/2146.js +1 -1
  12. package/dist/2372.js +1 -0
  13. package/dist/2372.js.map +1 -0
  14. package/dist/2524.js +1 -0
  15. package/dist/2524.js.map +1 -0
  16. package/dist/2690.js +1 -1
  17. package/dist/3099.js +1 -1
  18. package/dist/3584.js +1 -1
  19. package/dist/3717.js +2 -0
  20. package/dist/3717.js.map +1 -0
  21. package/dist/4055.js +1 -1
  22. package/dist/4132.js +1 -1
  23. package/dist/4300.js +1 -1
  24. package/dist/4335.js +1 -1
  25. package/dist/4618.js +1 -1
  26. package/dist/4652.js +1 -1
  27. package/dist/4724.js +1 -0
  28. package/dist/4724.js.map +1 -0
  29. package/dist/4739.js +1 -1
  30. package/dist/4739.js.map +1 -1
  31. package/dist/4944.js +1 -1
  32. package/dist/5173.js +1 -1
  33. package/dist/5241.js +1 -1
  34. package/dist/5442.js +1 -1
  35. package/dist/5661.js +1 -1
  36. package/dist/6022.js +1 -1
  37. package/dist/6468.js +1 -1
  38. package/dist/6540.js +1 -1
  39. package/dist/6540.js.map +1 -1
  40. package/dist/6679.js +1 -1
  41. package/dist/6840.js +1 -1
  42. package/dist/6859.js +1 -1
  43. package/dist/7097.js +1 -1
  44. package/dist/7159.js +1 -1
  45. package/dist/723.js +1 -1
  46. package/dist/7255.js +1 -1
  47. package/dist/7255.js.map +1 -1
  48. package/dist/7617.js +1 -1
  49. package/dist/795.js +1 -1
  50. package/dist/8163.js +1 -1
  51. package/dist/8349.js +1 -1
  52. package/dist/8618.js +1 -1
  53. package/dist/8708.js +2 -0
  54. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  55. package/dist/8708.js.map +1 -0
  56. package/dist/890.js +1 -1
  57. package/dist/9214.js +1 -1
  58. package/dist/9538.js +1 -1
  59. package/dist/9569.js +1 -1
  60. package/dist/961.js +1 -1
  61. package/dist/961.js.map +1 -1
  62. package/dist/986.js +1 -1
  63. package/dist/9879.js +1 -1
  64. package/dist/9895.js +1 -1
  65. package/dist/9900.js +1 -1
  66. package/dist/9913.js +1 -1
  67. package/dist/main.js +1 -1
  68. package/dist/main.js.map +1 -1
  69. package/dist/openmrs-esm-billing-app.js +1 -1
  70. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +263 -301
  71. package/dist/openmrs-esm-billing-app.js.map +1 -1
  72. package/dist/routes.json +1 -1
  73. package/e2e/README.md +19 -18
  74. package/e2e/core/test.ts +1 -1
  75. package/e2e/fixtures/api.ts +1 -1
  76. package/e2e/specs/sample-test.spec.ts +0 -1
  77. package/e2e/support/github/Dockerfile +1 -1
  78. package/package.json +18 -15
  79. package/src/bill-history/bill-history.component.tsx +20 -28
  80. package/src/bill-history/bill-history.scss +4 -94
  81. package/src/bill-history/bill-history.test.tsx +37 -78
  82. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  83. package/src/bill-item-actions/edit-bill-item.modal.tsx +225 -0
  84. package/src/bill-item-actions/edit-bill-item.test.tsx +214 -40
  85. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  86. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +28 -32
  87. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  88. package/src/billable-services/bill-waiver/utils.ts +13 -3
  89. package/src/billable-services/billable-service.resource.ts +42 -26
  90. package/src/billable-services/billable-services-home.component.tsx +12 -35
  91. package/src/billable-services/billable-services-left-panel-link.component.tsx +48 -0
  92. package/src/billable-services/billable-services-left-panel-menu.component.tsx +46 -0
  93. package/src/billable-services/billable-services.component.tsx +149 -148
  94. package/src/billable-services/billable-services.scss +29 -0
  95. package/src/billable-services/billable-services.test.tsx +6 -49
  96. package/src/billable-services/cash-point/add-cash-point.modal.tsx +168 -0
  97. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  98. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  99. package/src/billable-services/create-edit/add-billable-service.component.tsx +388 -301
  100. package/src/billable-services/create-edit/add-billable-service.scss +7 -68
  101. package/src/billable-services/create-edit/add-billable-service.test.tsx +720 -77
  102. package/src/billable-services/create-edit/edit-billable-service.modal.tsx +51 -0
  103. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  104. package/src/billable-services/payment-modes/add-payment-mode.modal.tsx +121 -0
  105. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +74 -0
  106. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +125 -0
  107. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  108. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  109. package/src/billing-form/billing-checkin-form.component.tsx +21 -17
  110. package/src/billing-form/billing-checkin-form.test.tsx +99 -26
  111. package/src/billing-form/billing-form.component.tsx +222 -292
  112. package/src/billing-form/billing-form.scss +143 -0
  113. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +1 -1
  114. package/src/billing.resource.ts +69 -74
  115. package/src/bills-table/bills-table.component.tsx +3 -3
  116. package/src/bills-table/bills-table.test.tsx +98 -54
  117. package/src/config-schema.ts +52 -24
  118. package/src/dashboard.meta.ts +4 -2
  119. package/src/helpers/functions.ts +5 -4
  120. package/src/index.ts +67 -9
  121. package/src/invoice/invoice-table.component.tsx +36 -70
  122. package/src/invoice/invoice-table.scss +8 -5
  123. package/src/invoice/invoice-table.test.tsx +273 -62
  124. package/src/invoice/invoice.component.tsx +39 -32
  125. package/src/invoice/invoice.scss +11 -4
  126. package/src/invoice/invoice.test.tsx +324 -120
  127. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  128. package/src/invoice/payments/payment-form/payment-form.component.tsx +43 -34
  129. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  130. package/src/invoice/payments/payment-form/payment-form.test.tsx +216 -66
  131. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  132. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  133. package/src/invoice/payments/payments.component.tsx +55 -67
  134. package/src/invoice/payments/payments.scss +4 -3
  135. package/src/invoice/payments/payments.test.tsx +282 -0
  136. package/src/invoice/payments/utils.ts +15 -27
  137. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -2
  138. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  139. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  140. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  141. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +12 -11
  142. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +16 -14
  143. package/src/invoice/printable-invoice/printable-invoice.component.tsx +20 -34
  144. package/src/left-panel-link.test.tsx +1 -4
  145. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  146. package/src/metrics-cards/metrics-cards.scss +4 -0
  147. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  148. package/src/modal/require-payment-modal.test.tsx +27 -22
  149. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  150. package/src/routes.json +39 -8
  151. package/src/types/index.ts +81 -23
  152. package/translations/am.json +127 -75
  153. package/translations/ar.json +128 -76
  154. package/translations/ar_SY.json +128 -76
  155. package/translations/bn.json +130 -78
  156. package/translations/de.json +128 -76
  157. package/translations/en.json +128 -76
  158. package/translations/en_US.json +128 -76
  159. package/translations/es.json +127 -75
  160. package/translations/es_MX.json +128 -76
  161. package/translations/fr.json +133 -81
  162. package/translations/he.json +127 -75
  163. package/translations/hi.json +128 -76
  164. package/translations/hi_IN.json +128 -76
  165. package/translations/id.json +128 -76
  166. package/translations/it.json +154 -102
  167. package/translations/ka.json +128 -76
  168. package/translations/km.json +127 -75
  169. package/translations/ku.json +128 -76
  170. package/translations/ky.json +128 -76
  171. package/translations/lg.json +128 -76
  172. package/translations/ne.json +128 -76
  173. package/translations/pl.json +128 -76
  174. package/translations/pt.json +128 -76
  175. package/translations/pt_BR.json +128 -76
  176. package/translations/qu.json +128 -76
  177. package/translations/ro_RO.json +217 -165
  178. package/translations/ru_RU.json +128 -76
  179. package/translations/si.json +128 -76
  180. package/translations/sw.json +128 -76
  181. package/translations/sw_KE.json +128 -76
  182. package/translations/tr.json +128 -76
  183. package/translations/tr_TR.json +128 -76
  184. package/translations/uk.json +128 -76
  185. package/translations/uz.json +128 -76
  186. package/translations/uz@Latn.json +128 -76
  187. package/translations/uz_UZ.json +128 -76
  188. package/translations/vi.json +128 -76
  189. package/translations/zh.json +128 -76
  190. package/translations/zh_CN.json +159 -107
  191. package/dist/1146.js.LICENSE.txt +0 -21
  192. package/dist/2352.js +0 -1
  193. package/dist/2352.js.map +0 -1
  194. package/dist/246.js +0 -1
  195. package/dist/246.js.map +0 -1
  196. package/dist/4689.js +0 -2
  197. package/dist/4689.js.map +0 -1
  198. package/dist/6557.js +0 -2
  199. package/dist/6557.js.map +0 -1
  200. package/dist/8638.js +0 -1
  201. package/dist/8638.js.map +0 -1
  202. package/dist/9968.js +0 -1
  203. package/dist/9968.js.map +0 -1
  204. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  205. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  206. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  207. package/src/invoice/payments/payments.component.test.tsx +0 -121
  208. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useRef, useState, useEffect } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  Button,
4
4
  ComboBox,
@@ -7,156 +7,257 @@ import {
7
7
  FormLabel,
8
8
  InlineLoading,
9
9
  Layer,
10
+ NumberInput,
10
11
  Search,
12
+ Stack,
11
13
  TextInput,
12
14
  Tile,
13
15
  } from '@carbon/react';
14
- import { Add, TrashCan, WarningFilled } from '@carbon/react/icons';
15
- import { Controller, useFieldArray, useForm } from 'react-hook-form';
16
+ import { type TFunction } from 'i18next';
17
+ import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
16
18
  import { useTranslation } from 'react-i18next';
19
+ import { Add, TrashCan } from '@carbon/react/icons';
17
20
  import { z } from 'zod';
18
21
  import { zodResolver } from '@hookform/resolvers/zod';
19
- import { navigate, showSnackbar, useDebounce, useLayoutType } from '@openmrs/esm-framework';
22
+ import { getCoreTranslation, navigate, ResponsiveWrapper, showSnackbar, useDebounce } from '@openmrs/esm-framework';
23
+ import type { BillableService, ServicePrice } from '../../types';
20
24
  import {
21
- createBillableSerice,
25
+ createBillableService,
22
26
  updateBillableService,
23
27
  useConceptsSearch,
24
28
  usePaymentModes,
25
29
  useServiceTypes,
26
30
  } from '../billable-service.resource';
27
- import { type ServiceConcept } from '../../types';
28
31
  import styles from './add-billable-service.scss';
29
32
 
30
- type PaymentMode = {
33
+ interface AddBillableServiceProps {
34
+ serviceToEdit?: BillableService;
35
+ onClose: () => void;
36
+ onServiceUpdated?: () => void;
37
+ isModal?: boolean;
38
+ }
39
+
40
+ interface BillableServiceFormData {
41
+ name: string;
42
+ payment: PaymentModeForm[];
43
+ serviceType: ServiceType | null;
44
+ concept?: { uuid: string; display: string } | null;
45
+ shortName?: string;
46
+ }
47
+
48
+ interface PaymentModeForm {
31
49
  paymentMode: string;
32
- price: string | number;
50
+ price: string | number | undefined;
51
+ }
52
+
53
+ interface ServiceType {
54
+ uuid: string;
55
+ display: string;
56
+ }
57
+
58
+ const DEFAULT_PAYMENT_OPTION: PaymentModeForm = { paymentMode: '', price: '' };
59
+ const MAX_NAME_LENGTH = 255;
60
+
61
+ /**
62
+ * Transforms a BillableService into form data structure
63
+ * Centralizes the mapping logic to avoid duplication between defaultValues and reset()
64
+ * Exported for testing
65
+ */
66
+ export const transformServiceToFormData = (service?: BillableService): BillableServiceFormData => {
67
+ if (!service) {
68
+ return {
69
+ name: '',
70
+ shortName: '',
71
+ serviceType: null,
72
+ concept: null,
73
+ payment: [DEFAULT_PAYMENT_OPTION],
74
+ };
75
+ }
76
+
77
+ return {
78
+ name: service.name || '',
79
+ shortName: service.shortName || '',
80
+ serviceType: service.serviceType || null,
81
+ concept: service.concept ? { uuid: service.concept.uuid, display: service.concept.display } : null,
82
+ payment: service.servicePrices?.map((servicePrice: ServicePrice) => ({
83
+ paymentMode: servicePrice.paymentMode?.uuid || '',
84
+ price: servicePrice.price ?? '',
85
+ })) || [DEFAULT_PAYMENT_OPTION],
86
+ };
33
87
  };
34
88
 
35
- type PaymentModeFormValue = {
36
- payment: Array<PaymentMode>;
89
+ /**
90
+ * Normalizes price value from form (string | number | undefined) to number
91
+ * Handles Carbon NumberInput which can return either type
92
+ * Exported for testing
93
+ */
94
+ export const normalizePrice = (price: string | number | undefined): number => {
95
+ if (typeof price === 'number') {
96
+ return price;
97
+ }
98
+ return parseFloat(String(price));
37
99
  };
38
100
 
39
- const servicePriceSchema = z.object({
40
- paymentMode: z.string().refine((value) => !!value, 'Payment method is required'),
41
- price: z.union([
42
- z.number().refine((value) => !!value, 'Price is required'),
43
- z.string().refine((value) => !!value, 'Price is required'),
44
- ]),
45
- });
101
+ const createBillableServiceSchema = (t: TFunction) => {
102
+ const servicePriceSchema = z.object({
103
+ paymentMode: z
104
+ .string({
105
+ required_error: t('paymentModeRequired', 'Payment mode is required'),
106
+ })
107
+ .trim()
108
+ .min(1, t('paymentModeRequired', 'Payment mode is required')),
109
+ price: z.union([z.number(), z.string(), z.undefined()]).superRefine((val, ctx) => {
110
+ if (val === undefined || val === null || val === '') {
111
+ ctx.addIssue({
112
+ code: z.ZodIssueCode.custom,
113
+ message: t('priceIsRequired', 'Price is required'),
114
+ });
115
+ return;
116
+ }
46
117
 
47
- const paymentFormSchema = z.object({
48
- payment: z.array(servicePriceSchema).min(1, 'At least one payment option is required'),
49
- });
118
+ const numValue = typeof val === 'number' ? val : parseFloat(val);
119
+ if (isNaN(numValue) || numValue <= 0) {
120
+ ctx.addIssue({
121
+ code: z.ZodIssueCode.custom,
122
+ message: t('priceMustBePositive', 'Price must be greater than 0'),
123
+ });
124
+ }
125
+ }),
126
+ });
50
127
 
51
- const DEFAULT_PAYMENT_OPTION = { paymentMode: '', price: 0 };
128
+ return z.object({
129
+ name: z
130
+ .string({
131
+ required_error: t('serviceNameRequired', 'Service name is required'),
132
+ })
133
+ .trim()
134
+ .min(1, t('serviceNameRequired', 'Service name is required'))
135
+ .max(
136
+ MAX_NAME_LENGTH,
137
+ t('serviceNameExceedsLimit', 'Service name cannot exceed {{MAX_NAME_LENGTH}} characters', {
138
+ MAX_NAME_LENGTH,
139
+ }),
140
+ ),
141
+ shortName: z
142
+ .string()
143
+ .max(
144
+ MAX_NAME_LENGTH,
145
+ t('shortNameExceedsLimit', 'Short name cannot exceed {{MAX_NAME_LENGTH}} characters', { MAX_NAME_LENGTH }),
146
+ )
147
+ .optional(),
148
+ serviceType: z
149
+ .object({
150
+ uuid: z.string(),
151
+ display: z.string(),
152
+ })
153
+ .nullable()
154
+ .refine((val) => val !== null, t('serviceTypeRequired', 'Service type is required')),
155
+ concept: z
156
+ .object({
157
+ uuid: z.string(),
158
+ display: z.string(),
159
+ })
160
+ .nullable()
161
+ .optional(),
162
+ payment: z.array(servicePriceSchema).min(1, t('paymentOptionRequired', 'At least one payment option is required')),
163
+ });
164
+ };
52
165
 
53
- const AddBillableService: React.FC<{ editingService?: any; onClose: () => void }> = ({ editingService, onClose }) => {
166
+ const AddBillableService: React.FC<AddBillableServiceProps> = ({
167
+ serviceToEdit,
168
+ onClose,
169
+ onServiceUpdated,
170
+ isModal = false,
171
+ }) => {
54
172
  const { t } = useTranslation();
173
+ const { paymentModes, isLoadingPaymentModes } = usePaymentModes();
174
+ const { serviceTypes, isLoadingServiceTypes } = useServiceTypes();
55
175
 
56
- const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes();
57
- const { serviceTypes, isLoading: isLoadingServicesTypes } = useServiceTypes();
58
- const [billableServicePayload, setBillableServicePayload] = useState(editingService || {});
176
+ const billableServiceSchema = useMemo(() => createBillableServiceSchema(t), [t]);
59
177
 
60
178
  const {
61
179
  control,
62
180
  handleSubmit,
63
- formState: { errors, isValid },
181
+ formState: { errors },
64
182
  setValue,
65
- } = useForm<any>({
183
+ reset,
184
+ } = useForm<BillableServiceFormData>({
66
185
  mode: 'all',
67
- defaultValues: {
68
- name: editingService?.name,
69
- serviceShortName: editingService?.shortName,
70
- serviceType: editingService?.serviceType,
71
- conceptsSearch: editingService?.concept,
72
- payment: editingService?.servicePrices || [DEFAULT_PAYMENT_OPTION],
73
- },
74
- resolver: zodResolver(paymentFormSchema),
75
- shouldUnregister: !editingService,
186
+ defaultValues: transformServiceToFormData(serviceToEdit),
187
+ resolver: zodResolver(billableServiceSchema),
76
188
  });
77
- const { fields, remove, append } = useFieldArray({ name: 'payment', control: control });
189
+ const { fields, remove, append } = useFieldArray({ name: 'payment', control });
78
190
 
79
- const handleAppendPaymentMode = useCallback(() => append(DEFAULT_PAYMENT_OPTION), [append]);
80
- const handleRemovePaymentMode = useCallback((index) => remove(index), [remove]);
191
+ const handleAppendPaymentMode = () => append(DEFAULT_PAYMENT_OPTION);
192
+ const handleRemovePaymentMode = (index: number) => remove(index);
81
193
 
82
- const isTablet = useLayoutType() === 'tablet';
83
194
  const searchInputRef = useRef(null);
84
- const handleSearchTermChange = (event: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(event.target.value);
85
195
 
86
- const [selectedConcept, setSelectedConcept] = useState<ServiceConcept>(null);
196
+ const selectedConcept = useWatch({ control, name: 'concept' });
87
197
  const [searchTerm, setSearchTerm] = useState('');
88
- const debouncedSearchTerm = useDebounce(searchTerm);
198
+ const debouncedSearchTerm = useDebounce(searchTerm.trim());
89
199
  const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm);
90
- const handleConceptChange = useCallback((selectedConcept: any) => {
91
- setSelectedConcept(selectedConcept);
92
- }, []);
93
200
 
94
201
  const handleNavigateToServiceDashboard = () =>
95
202
  navigate({
96
203
  to: window.getOpenmrsSpaBase() + 'billable-services',
97
204
  });
98
205
 
206
+ // Re-initialize form when editing and dependencies load
207
+ // Needed because serviceTypes/paymentModes may not be available during initial render
99
208
  useEffect(() => {
100
- if (editingService && !isLoadingPaymentModes) {
101
- setBillableServicePayload(editingService);
102
- setValue('serviceName', editingService.name || '');
103
- setValue('shortName', editingService.shortName || '');
104
- setValue('serviceType', editingService.serviceType || '');
105
- setValue(
106
- 'payment',
107
- editingService.servicePrices.map((payment) => ({
108
- paymentMode: payment.paymentMode?.uuid || '',
109
- price: payment.price,
110
- })),
111
- );
112
- setValue('conceptsSearch', editingService.concept);
113
-
114
- if (editingService.concept) {
115
- setSelectedConcept(editingService.concept);
116
- }
209
+ if (serviceToEdit && !isLoadingPaymentModes && !isLoadingServiceTypes) {
210
+ reset(transformServiceToFormData(serviceToEdit));
117
211
  }
118
- }, [editingService, isLoadingPaymentModes, paymentModes, serviceTypes, setValue]);
212
+ }, [serviceToEdit, isLoadingPaymentModes, isLoadingServiceTypes, reset]);
119
213
 
120
- const MAX_NAME_LENGTH = 19;
121
-
122
- const onSubmit = (data) => {
214
+ const onSubmit = async (data: BillableServiceFormData) => {
123
215
  const payload = {
124
- name: billableServicePayload.name.substring(0, MAX_NAME_LENGTH),
125
- shortName: billableServicePayload.shortName.substring(0, MAX_NAME_LENGTH),
126
- serviceType: billableServicePayload.serviceType.uuid,
216
+ name: data.name,
217
+ shortName: data.shortName || '',
218
+ serviceType: data.serviceType!.uuid,
127
219
  servicePrices: data.payment.map((payment) => {
128
220
  const mode = paymentModes.find((m) => m.uuid === payment.paymentMode);
129
221
  return {
130
222
  paymentMode: payment.paymentMode,
131
223
  name: mode?.name || 'Unknown',
132
- price: parseFloat(payment.price),
224
+ price: normalizePrice(payment.price),
133
225
  };
134
226
  }),
135
227
  serviceStatus: 'ENABLED',
136
- concept: selectedConcept?.uuid,
228
+ concept: data.concept?.uuid,
137
229
  };
138
230
 
139
- const saveAction = editingService
140
- ? updateBillableService(editingService.uuid, payload)
141
- : createBillableSerice(payload);
231
+ try {
232
+ if (serviceToEdit) {
233
+ await updateBillableService(serviceToEdit.uuid, payload);
234
+ } else {
235
+ await createBillableService(payload);
236
+ }
237
+
238
+ showSnackbar({
239
+ title: t('billableService', 'Billable service'),
240
+ subtitle: serviceToEdit
241
+ ? t('updatedSuccessfully', 'Billable service updated successfully')
242
+ : t('createdSuccessfully', 'Billable service created successfully'),
243
+ kind: 'success',
244
+ });
142
245
 
143
- saveAction.then(
144
- (resp) => {
145
- showSnackbar({
146
- title: t('billableService', 'Billable service'),
147
- subtitle: editingService
148
- ? t('updatedSuccessfully', 'Billable service updated successfully')
149
- : t('createdSuccessfully', 'Billable service created successfully'),
150
- kind: 'success',
151
- timeoutInMs: 3000,
152
- });
246
+ if (serviceToEdit) {
153
247
  onClose();
154
- handleNavigateToServiceDashboard();
155
- },
156
- (error) => {
157
- showSnackbar({ title: t('billPaymentError', 'Bill payment error'), kind: 'error', subtitle: error?.message });
158
- },
159
- );
248
+ }
249
+
250
+ if (onServiceUpdated) {
251
+ onServiceUpdated();
252
+ }
253
+ handleNavigateToServiceDashboard();
254
+ } catch (error) {
255
+ showSnackbar({
256
+ title: t('billPaymentError', 'Bill payment error'),
257
+ kind: 'error',
258
+ subtitle: error instanceof Error ? error.message : String(error),
259
+ });
260
+ }
160
261
  };
161
262
 
162
263
  const getPaymentErrorMessage = () => {
@@ -167,235 +268,221 @@ const AddBillableService: React.FC<{ editingService?: any; onClose: () => void }
167
268
  return null;
168
269
  };
169
270
 
170
- if (isLoadingPaymentModes && isLoadingServicesTypes) {
271
+ if (isLoadingPaymentModes || isLoadingServiceTypes) {
171
272
  return (
172
273
  <InlineLoading
173
274
  status="active"
174
275
  iconDescription={t('loadingDescription', 'Loading')}
175
- description={t('loading', 'Loading data...')}
276
+ description={t('loading', 'Loading data') + '...'}
176
277
  />
177
278
  );
178
279
  }
179
280
 
180
281
  return (
181
- <Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
182
- <h4>
183
- {editingService
184
- ? t('editBillableServices', 'Edit Billable Services')
185
- : t('addBillableServices', 'Add Billable Services')}
186
- </h4>
187
- <section className={styles.section}>
188
- <Layer>
189
- <TextInput
190
- id="serviceName"
191
- type="text"
192
- labelText={t('serviceName', 'Service Name')}
193
- size="md"
194
- value={billableServicePayload.name || ''}
195
- onChange={(e) => {
196
- const newName = e.target.value.substring(0, MAX_NAME_LENGTH);
197
- setBillableServicePayload({
198
- ...billableServicePayload,
199
- name: newName,
200
- });
201
- }}
202
- placeholder="Enter service name"
203
- maxLength={MAX_NAME_LENGTH}
204
- />
205
- {billableServicePayload.name?.length >= MAX_NAME_LENGTH && (
206
- <span className={styles.errorMessage}>
207
- {t('serviceNameExceedsLimit', 'Service Name exceeds the character limit of {{MAX_NAME_LENGTH}}.', {
208
- MAX_NAME_LENGTH,
209
- })}
210
- </span>
282
+ <Form id="billable-service-form" className={styles.form} onSubmit={handleSubmit(onSubmit)}>
283
+ <Stack gap={5}>
284
+ <h4>
285
+ {serviceToEdit
286
+ ? t('editBillableService', 'Edit billable service')
287
+ : t('addBillableService', 'Add billable service')}
288
+ </h4>
289
+ <section>
290
+ {serviceToEdit ? (
291
+ <FormLabel className={styles.serviceNameLabel}>{serviceToEdit.name}</FormLabel>
292
+ ) : (
293
+ <Controller
294
+ name="name"
295
+ control={control}
296
+ render={({ field }) => (
297
+ <Layer>
298
+ <TextInput
299
+ {...field}
300
+ enableCounter
301
+ id="serviceName"
302
+ invalid={!!errors.name}
303
+ invalidText={errors.name?.message}
304
+ labelText={t('serviceName', 'Service name')}
305
+ maxCount={MAX_NAME_LENGTH}
306
+ placeholder={t('enterServiceName', 'Enter service name')}
307
+ type="text"
308
+ />
309
+ </Layer>
310
+ )}
311
+ />
211
312
  )}
212
- </Layer>
213
- </section>
214
- <section className={styles.section}>
215
- <Layer>
216
- <TextInput
217
- id="serviceShortName"
218
- type="text"
219
- labelText={t('serviceShortName', 'Short Name')}
220
- size="md"
221
- value={billableServicePayload.shortName || ''}
222
- onChange={(e) => {
223
- const newShortName = e.target.value.substring(0, MAX_NAME_LENGTH);
224
- setBillableServicePayload({
225
- ...billableServicePayload,
226
- shortName: newShortName,
227
- });
228
- }}
229
- placeholder="Enter service short name"
230
- maxLength={MAX_NAME_LENGTH}
313
+ </section>
314
+ <section>
315
+ <Controller
316
+ name="shortName"
317
+ control={control}
318
+ render={({ field }) => (
319
+ <Layer>
320
+ <TextInput
321
+ {...field}
322
+ enableCounter
323
+ id="serviceShortName"
324
+ invalid={!!errors.shortName}
325
+ invalidText={errors.shortName?.message}
326
+ labelText={t('shortName', 'Short name')}
327
+ maxCount={MAX_NAME_LENGTH}
328
+ placeholder={t('enterServiceShortName', 'Enter service short name')}
329
+ type="text"
330
+ value={field.value || ''}
331
+ />
332
+ </Layer>
333
+ )}
231
334
  />
232
- {billableServicePayload.shortName?.length >= MAX_NAME_LENGTH && (
233
- <span className={styles.errorMessage}>
234
- {t('shortNameExceedsLimit', 'Short Name exceeds the character limit of {{MAX_NAME_LENGTH}}.', {
235
- MAX_NAME_LENGTH,
236
- })}
237
- </span>
238
- )}
239
- </Layer>
240
- </section>
241
- <section>
242
- <FormLabel className={styles.conceptLabel}>Associated Concept</FormLabel>
243
- <Controller
244
- name="search"
245
- control={control}
246
- render={({ field: { onChange, value, onBlur } }) => (
247
- <ResponsiveWrapper isTablet={isTablet}>
248
- <Search
249
- ref={searchInputRef}
250
- size="md"
251
- id="conceptsSearch"
252
- labelText={t('enterConcept', 'Associated concept')}
253
- placeholder={t('searchConcepts', 'Search associated concept')}
254
- className={errors?.search && styles.serviceError}
255
- onChange={(e) => {
256
- setSearchTerm(e.target.value);
257
- onChange(e);
258
- handleSearchTermChange(e);
259
- }}
260
- renderIcon={errors?.search && <WarningFilled />}
261
- onBlur={onBlur}
262
- onClear={() => {
263
- setSearchTerm('');
264
- setSelectedConcept(null);
265
- }}
266
- value={(() => {
267
- if (selectedConcept) {
268
- return selectedConcept.display;
269
- }
270
- if (debouncedSearchTerm) {
271
- return value;
272
- }
273
- })()}
274
- />
275
- </ResponsiveWrapper>
276
- )}
277
- />
335
+ </section>
336
+ <section>
337
+ <FormLabel className={styles.conceptLabel}>{t('associatedConcept', 'Associated concept')}</FormLabel>
338
+ <ResponsiveWrapper>
339
+ <Search
340
+ id="conceptsSearch"
341
+ labelText={t('associatedConcept', 'Associated concept')}
342
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
343
+ onClear={() => {
344
+ setSearchTerm('');
345
+ setValue('concept', null);
346
+ }}
347
+ placeholder={t('searchConcepts', 'Search associated concept')}
348
+ ref={searchInputRef}
349
+ value={selectedConcept?.display || searchTerm}
350
+ />
351
+ </ResponsiveWrapper>
278
352
 
279
- {(() => {
280
- if (!debouncedSearchTerm || selectedConcept) return null;
281
- if (isSearching)
282
- return <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />;
283
- if (searchResults && searchResults.length) {
353
+ {(() => {
354
+ if (!debouncedSearchTerm || selectedConcept) {
355
+ return null;
356
+ }
357
+ if (isSearching) {
358
+ return <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />;
359
+ }
360
+ if (searchResults && searchResults.length) {
361
+ return (
362
+ <ul className={styles.conceptsList}>
363
+ {searchResults?.map((searchResult) => (
364
+ <li
365
+ className={styles.service}
366
+ key={searchResult.concept.uuid}
367
+ onClick={() => {
368
+ setValue('concept', {
369
+ uuid: searchResult.concept.uuid,
370
+ display: searchResult.display,
371
+ });
372
+ setSearchTerm('');
373
+ }}
374
+ role="menuitem">
375
+ {searchResult.display}
376
+ </li>
377
+ ))}
378
+ </ul>
379
+ );
380
+ }
284
381
  return (
285
- <ul className={styles.conceptsList}>
286
- {/*TODO: use uuid instead of index as the key*/}
287
- {searchResults?.map((searchResult, index) => (
288
- <li
289
- role="menuitem"
290
- className={styles.service}
291
- key={index}
292
- onClick={() => handleConceptChange(searchResult)}>
293
- {searchResult.display}
294
- </li>
295
- ))}
296
- </ul>
382
+ <Layer>
383
+ <Tile className={styles.emptyResults}>
384
+ <span>{t('noResultsFor', 'No results for {{searchTerm}}', { searchTerm: debouncedSearchTerm })}</span>
385
+ </Tile>
386
+ </Layer>
297
387
  );
298
- }
299
- return (
300
- <Layer>
301
- <Tile className={styles.emptyResults}>
302
- <span>
303
- {t('noResultsFor', 'No results for')} <strong>"{debouncedSearchTerm}"</strong>
304
- </span>
305
- </Tile>
306
- </Layer>
307
- );
308
- })()}
309
- </section>
310
- <section className={styles.section}>
311
- <Layer>
312
- <ComboBox
313
- id="serviceType"
314
- items={serviceTypes ?? []}
315
- titleText={t('serviceType', 'Service Type')}
316
- itemToString={(item) => item?.display || ''}
317
- selectedItem={billableServicePayload.serviceType || null}
318
- onChange={({ selectedItem }) => {
319
- setBillableServicePayload({
320
- ...billableServicePayload,
321
- display: selectedItem?.display,
322
- serviceType: selectedItem,
323
- });
324
- }}
325
- placeholder="Select service type"
326
- required
388
+ })()}
389
+ </section>
390
+ <section>
391
+ <Controller
392
+ name="serviceType"
393
+ control={control}
394
+ render={({ field }) => (
395
+ <Layer>
396
+ <ComboBox
397
+ id="serviceType"
398
+ items={serviceTypes ?? []}
399
+ titleText={t('serviceType', 'Service type')}
400
+ itemToString={(item: ServiceType) => item?.display || ''}
401
+ selectedItem={field.value}
402
+ onChange={({ selectedItem }: { selectedItem: ServiceType | null }) => {
403
+ field.onChange(selectedItem);
404
+ }}
405
+ placeholder={t('selectServiceType', 'Select service type')}
406
+ invalid={!!errors.serviceType}
407
+ invalidText={errors.serviceType?.message}
408
+ />
409
+ </Layer>
410
+ )}
327
411
  />
328
- </Layer>
329
- </section>
330
-
331
- <section>
332
- <div className={styles.container}>
333
- {fields.map((field, index) => (
334
- <div key={field.id} className={styles.paymentMethodContainer}>
335
- <Controller
336
- control={control}
337
- name={`payment.${index}.paymentMode`}
338
- render={({ field }) => (
339
- <Layer>
340
- <Dropdown
341
- onChange={({ selectedItem }) => field.onChange(selectedItem.uuid)}
342
- titleText={t('paymentMode', 'Payment Mode')}
343
- label={t('selectPaymentMethod', 'Select payment method')}
344
- items={paymentModes ?? []}
345
- itemToString={(item) => (item ? item.name : '')}
346
- selectedItem={paymentModes.find((mode) => mode.uuid === field.value)}
347
- invalid={!!errors?.payment?.[index]?.paymentMode}
348
- invalidText={errors?.payment?.[index]?.paymentMode?.message}
349
- />
350
- </Layer>
351
- )}
352
- />
353
- <Controller
354
- control={control}
355
- name={`payment.${index}.price`}
356
- render={({ field }) => (
357
- <Layer>
358
- <TextInput
359
- {...field}
360
- invalid={!!errors?.payment?.[index]?.price}
361
- invalidText={errors?.payment?.[index]?.price?.message}
362
- labelText={t('sellingPrice', 'Selling Price')}
363
- placeholder={t('sellingAmount', 'Enter selling price')}
364
- />
365
- </Layer>
366
- )}
367
- />
368
- <div className={styles.removeButtonContainer}>
369
- <TrashCan onClick={() => handleRemovePaymentMode(index)} className={styles.removeButton} size={20} />
412
+ </section>
413
+ <section>
414
+ <div>
415
+ {fields.map((field, index) => (
416
+ <div key={field.id} className={styles.paymentMethodContainer}>
417
+ <Controller
418
+ control={control}
419
+ name={`payment.${index}.paymentMode`}
420
+ render={({ field }) => (
421
+ <Layer>
422
+ <Dropdown
423
+ id={`paymentMode-${index}`}
424
+ invalid={!!errors?.payment?.[index]?.paymentMode}
425
+ invalidText={errors?.payment?.[index]?.paymentMode?.message}
426
+ items={paymentModes ?? []}
427
+ itemToString={(item) => (item ? item.name : '')}
428
+ label={t('selectPaymentMode', 'Select payment mode')}
429
+ onChange={({ selectedItem }) => field.onChange(selectedItem.uuid)}
430
+ selectedItem={paymentModes.find((mode) => mode.uuid === field.value)}
431
+ titleText={t('paymentMode', 'Payment mode')}
432
+ />
433
+ </Layer>
434
+ )}
435
+ />
436
+ <Controller
437
+ control={control}
438
+ name={`payment.${index}.price`}
439
+ render={({ field }) => (
440
+ <Layer>
441
+ <NumberInput
442
+ allowEmpty
443
+ id={`price-${index}`}
444
+ invalid={!!errors?.payment?.[index]?.price}
445
+ invalidText={errors?.payment?.[index]?.price?.message}
446
+ label={t('sellingPrice', 'Selling price')}
447
+ min={0}
448
+ onChange={(_, { value }) => {
449
+ field.onChange(value === '' || value === undefined ? '' : value);
450
+ }}
451
+ placeholder={t('enterSellingPrice', 'Enter selling price')}
452
+ step={0.01}
453
+ value={field.value === undefined || field.value === null ? '' : field.value}
454
+ />
455
+ </Layer>
456
+ )}
457
+ />
458
+ <div className={styles.removeButtonContainer}>
459
+ <TrashCan onClick={() => handleRemovePaymentMode(index)} className={styles.removeButton} size={20} />
460
+ </div>
370
461
  </div>
371
- </div>
372
- ))}
373
- <Button
374
- size="md"
375
- onClick={handleAppendPaymentMode}
376
- className={styles.paymentButtons}
377
- renderIcon={(props) => <Add size={24} {...props} />}
378
- iconDescription="Add">
379
- {t('addPaymentOptions', 'Add payment option')}
462
+ ))}
463
+ <Button
464
+ className={styles.paymentButtons}
465
+ iconDescription={t('add', 'Add')}
466
+ kind="tertiary"
467
+ onClick={handleAppendPaymentMode}
468
+ renderIcon={(props) => <Add size={24} {...props} />}
469
+ type="button">
470
+ {t('addPaymentOption', 'Add payment option')}
471
+ </Button>
472
+ {getPaymentErrorMessage() && <div className={styles.errorMessage}>{getPaymentErrorMessage()}</div>}
473
+ </div>
474
+ </section>
475
+ </Stack>
476
+ {!isModal && (
477
+ <section>
478
+ <Button kind="secondary" onClick={onClose}>
479
+ {getCoreTranslation('cancel')}
380
480
  </Button>
381
- {getPaymentErrorMessage() && <div className={styles.errorMessage}>{getPaymentErrorMessage()}</div>}
382
- </div>
383
- </section>
384
-
385
- <section>
386
- <Button kind="secondary" onClick={onClose}>
387
- {t('cancel', 'Cancel')}
388
- </Button>
389
- <Button type="submit" disabled={!isValid || Object.keys(errors).length > 0}>
390
- {t('save', 'Save')}
391
- </Button>
392
- </section>
481
+ <Button type="submit">{getCoreTranslation('save')}</Button>
482
+ </section>
483
+ )}
393
484
  </Form>
394
485
  );
395
486
  };
396
487
 
397
- function ResponsiveWrapper({ children, isTablet }: { children: React.ReactNode; isTablet: boolean }) {
398
- return isTablet ? <Layer>{children} </Layer> : <>{children}</>;
399
- }
400
-
401
488
  export default AddBillableService;