@openmrs/esm-billing-app 1.0.2-pre.82 → 1.0.2-pre.820

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