@openmrs/esm-billing-app 1.0.2-pre.86 → 1.0.2-pre.863

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