@openmrs/esm-billing-app 1.0.2-pre.112 → 1.0.2-pre.1124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/.eslintrc +16 -2
  2. package/README.md +54 -9
  3. package/__mocks__/bills.mock.ts +12 -0
  4. package/__mocks__/react-i18next.js +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1146.js +1 -2
  7. package/dist/1146.js.map +1 -1
  8. package/dist/1197.js +1 -1
  9. package/dist/1537.js +1 -0
  10. package/dist/1537.js.map +1 -0
  11. package/dist/1856.js +1 -0
  12. package/dist/1856.js.map +1 -0
  13. package/dist/2146.js +1 -1
  14. package/dist/2177.js +1 -1
  15. package/dist/2177.js.map +1 -1
  16. package/dist/2524.js +1 -0
  17. package/dist/2524.js.map +1 -0
  18. package/dist/2690.js +1 -1
  19. package/dist/3099.js +1 -1
  20. package/dist/3584.js +1 -1
  21. package/dist/3717.js +2 -0
  22. package/dist/3717.js.map +1 -0
  23. package/dist/4055.js +1 -1
  24. package/dist/4132.js +1 -1
  25. package/dist/4300.js +1 -1
  26. package/dist/4335.js +1 -1
  27. package/dist/439.js +1 -0
  28. package/dist/4618.js +1 -1
  29. package/dist/4652.js +1 -1
  30. package/dist/4724.js +1 -0
  31. package/dist/4724.js.map +1 -0
  32. package/dist/4739.js +1 -1
  33. package/dist/4739.js.map +1 -1
  34. package/dist/4944.js +1 -1
  35. package/dist/5173.js +1 -1
  36. package/dist/5241.js +1 -1
  37. package/dist/5442.js +1 -1
  38. package/dist/5661.js +1 -1
  39. package/dist/6022.js +1 -1
  40. package/dist/6468.js +1 -1
  41. package/dist/6540.js +1 -1
  42. package/dist/6540.js.map +1 -1
  43. package/dist/6589.js +1 -0
  44. package/dist/6679.js +1 -1
  45. package/dist/6840.js +1 -1
  46. package/dist/6859.js +1 -1
  47. package/dist/7097.js +1 -1
  48. package/dist/7159.js +1 -1
  49. package/dist/723.js +1 -1
  50. package/dist/7255.js +1 -1
  51. package/dist/7255.js.map +1 -1
  52. package/dist/7617.js +1 -1
  53. package/dist/795.js +1 -1
  54. package/dist/8163.js +1 -1
  55. package/dist/8349.js +1 -1
  56. package/dist/8371.js +1 -0
  57. package/dist/8572.js +1 -0
  58. package/dist/8572.js.map +1 -0
  59. package/dist/8618.js +1 -1
  60. package/dist/8708.js +2 -0
  61. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  62. package/dist/8708.js.map +1 -0
  63. package/dist/890.js +1 -1
  64. package/dist/9214.js +1 -1
  65. package/dist/9538.js +1 -1
  66. package/dist/9569.js +1 -1
  67. package/dist/961.js +1 -1
  68. package/dist/961.js.map +1 -1
  69. package/dist/986.js +1 -1
  70. package/dist/9879.js +1 -1
  71. package/dist/9895.js +1 -1
  72. package/dist/9900.js +1 -1
  73. package/dist/9913.js +1 -1
  74. package/dist/main.js +1 -1
  75. package/dist/main.js.map +1 -1
  76. package/dist/openmrs-esm-billing-app.js +1 -1
  77. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +321 -249
  78. package/dist/openmrs-esm-billing-app.js.map +1 -1
  79. package/dist/routes.json +1 -1
  80. package/e2e/README.md +19 -18
  81. package/e2e/commands/billing-operations.ts +96 -0
  82. package/e2e/commands/index.ts +3 -0
  83. package/e2e/commands/patient-operations.ts +82 -0
  84. package/e2e/commands/types.ts +73 -0
  85. package/e2e/core/test.ts +12 -1
  86. package/e2e/fixtures/api.ts +2 -3
  87. package/e2e/pages/billing-dashboard-page.ts +84 -0
  88. package/e2e/pages/billing-form-page.ts +114 -0
  89. package/e2e/pages/index.ts +4 -1
  90. package/e2e/pages/invoice-page.ts +113 -0
  91. package/e2e/pages/payment-page.ts +104 -0
  92. package/e2e/specs/billing-dashboard.spec.ts +203 -0
  93. package/e2e/specs/billing-patient-chart.spec.ts +738 -0
  94. package/e2e/support/github/Dockerfile +1 -1
  95. package/e2e/support/github/run-e2e-docker-env.sh +29 -32
  96. package/example.env +15 -1
  97. package/package.json +19 -16
  98. package/playwright.config.ts +4 -2
  99. package/src/bill-history/bill-history.component.tsx +15 -17
  100. package/src/bill-history/bill-history.scss +4 -94
  101. package/src/bill-history/bill-history.test.tsx +37 -77
  102. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  103. package/src/bill-item-actions/edit-bill-item.modal.tsx +227 -0
  104. package/src/bill-item-actions/edit-bill-item.test.tsx +233 -40
  105. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  106. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +37 -39
  107. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  108. package/src/billable-services/bill-waiver/utils.ts +13 -3
  109. package/src/billable-services/{create-edit/add-billable-service.scss → billable-service-form/billable-service-form.scss} +32 -64
  110. package/src/billable-services/billable-service-form/billable-service-form.test.tsx +1099 -0
  111. package/src/billable-services/billable-service-form/billable-service-form.workspace.tsx +516 -0
  112. package/src/billable-services/billable-service.resource.ts +71 -27
  113. package/src/billable-services/billable-services-home.component.tsx +13 -42
  114. package/src/billable-services/billable-services-left-panel-link.component.tsx +50 -0
  115. package/src/billable-services/billable-services-left-panel-menu.component.tsx +48 -0
  116. package/src/billable-services/billable-services-menu-item/item.component.tsx +5 -4
  117. package/src/billable-services/billable-services.component.tsx +156 -152
  118. package/src/billable-services/billable-services.scss +29 -0
  119. package/src/billable-services/billable-services.test.tsx +6 -49
  120. package/src/billable-services/cash-point/add-cash-point.modal.tsx +170 -0
  121. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  122. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  123. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  124. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +77 -0
  125. package/src/billable-services/payment-modes/payment-mode-form.modal.tsx +131 -0
  126. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +139 -0
  127. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  128. package/src/billable-services-admin-card-link.component.test.tsx +2 -2
  129. package/src/billable-services-admin-card-link.component.tsx +1 -1
  130. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  131. package/src/billing-form/billing-checkin-form.component.tsx +29 -29
  132. package/src/billing-form/billing-checkin-form.scss +0 -7
  133. package/src/billing-form/billing-checkin-form.test.tsx +100 -27
  134. package/src/billing-form/billing-form.component.tsx +226 -284
  135. package/src/billing-form/billing-form.scss +143 -0
  136. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +47 -41
  137. package/src/billing-form/visit-attributes/visit-attributes-form.scss +16 -3
  138. package/src/billing.resource.test.ts +503 -0
  139. package/src/billing.resource.ts +68 -76
  140. package/src/bills-table/bills-table.component.tsx +17 -24
  141. package/src/bills-table/bills-table.test.tsx +98 -54
  142. package/src/config-schema.ts +84 -31
  143. package/src/dashboard.meta.ts +5 -3
  144. package/src/helpers/functions.ts +5 -4
  145. package/src/index.ts +75 -13
  146. package/src/invoice/invoice-table.component.tsx +36 -70
  147. package/src/invoice/invoice-table.scss +8 -5
  148. package/src/invoice/invoice-table.test.tsx +273 -62
  149. package/src/invoice/invoice.component.tsx +44 -38
  150. package/src/invoice/invoice.scss +11 -4
  151. package/src/invoice/invoice.test.tsx +324 -120
  152. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  153. package/src/invoice/payments/payment-form/payment-form.component.tsx +45 -36
  154. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  155. package/src/invoice/payments/payment-form/payment-form.test.tsx +217 -67
  156. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  157. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  158. package/src/invoice/payments/payments.component.tsx +56 -68
  159. package/src/invoice/payments/payments.scss +4 -3
  160. package/src/invoice/payments/payments.test.tsx +282 -0
  161. package/src/invoice/payments/utils.ts +15 -27
  162. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -3
  163. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  164. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  165. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  166. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +20 -11
  167. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +95 -16
  168. package/src/invoice/printable-invoice/printable-invoice.component.tsx +21 -35
  169. package/src/left-panel-link.component.tsx +9 -8
  170. package/src/left-panel-link.test.tsx +1 -4
  171. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  172. package/src/metrics-cards/metrics-cards.scss +4 -0
  173. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  174. package/src/modal/require-payment-modal.test.tsx +27 -22
  175. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  176. package/src/routes.json +44 -20
  177. package/src/types/index.ts +87 -24
  178. package/translations/am.json +140 -80
  179. package/translations/ar.json +141 -81
  180. package/translations/ar_SY.json +141 -81
  181. package/translations/bn.json +143 -83
  182. package/translations/cs.json +230 -0
  183. package/translations/de.json +141 -81
  184. package/translations/en.json +142 -81
  185. package/translations/en_US.json +141 -81
  186. package/translations/es.json +140 -80
  187. package/translations/es_MX.json +141 -81
  188. package/translations/fr.json +224 -164
  189. package/translations/he.json +140 -80
  190. package/translations/hi.json +141 -81
  191. package/translations/hi_IN.json +141 -81
  192. package/translations/id.json +141 -81
  193. package/translations/it.json +166 -106
  194. package/translations/ka.json +141 -81
  195. package/translations/km.json +140 -80
  196. package/translations/ku.json +141 -81
  197. package/translations/ky.json +141 -81
  198. package/translations/lg.json +141 -81
  199. package/translations/ne.json +141 -81
  200. package/translations/pl.json +141 -81
  201. package/translations/pt.json +141 -81
  202. package/translations/pt_BR.json +141 -81
  203. package/translations/qu.json +141 -81
  204. package/translations/ro_RO.json +225 -165
  205. package/translations/ru_RU.json +141 -81
  206. package/translations/si.json +141 -81
  207. package/translations/sq.json +230 -0
  208. package/translations/sw.json +141 -81
  209. package/translations/sw_KE.json +141 -81
  210. package/translations/tr.json +141 -81
  211. package/translations/tr_TR.json +141 -81
  212. package/translations/uk.json +141 -81
  213. package/translations/uz.json +141 -81
  214. package/translations/uz@Latn.json +141 -81
  215. package/translations/uz_UZ.json +141 -81
  216. package/translations/vi.json +141 -81
  217. package/translations/zh.json +141 -81
  218. package/translations/zh_CN.json +171 -111
  219. package/translations/zh_TW.json +230 -0
  220. package/dist/1146.js.LICENSE.txt +0 -21
  221. package/dist/2352.js +0 -1
  222. package/dist/2352.js.map +0 -1
  223. package/dist/246.js +0 -1
  224. package/dist/246.js.map +0 -1
  225. package/dist/4689.js +0 -2
  226. package/dist/4689.js.map +0 -1
  227. package/dist/6557.js +0 -2
  228. package/dist/6557.js.map +0 -1
  229. package/dist/8638.js +0 -1
  230. package/dist/8638.js.map +0 -1
  231. package/dist/9968.js +0 -1
  232. package/dist/9968.js.map +0 -1
  233. package/e2e/pages/home-page.ts +0 -9
  234. package/e2e/specs/sample-test.spec.ts +0 -11
  235. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  236. package/src/billable-services/create-edit/add-billable-service.component.tsx +0 -401
  237. package/src/billable-services/create-edit/add-billable-service.test.tsx +0 -154
  238. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  239. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  240. package/src/invoice/payments/payments.component.test.tsx +0 -121
  241. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -1,11 +1,7 @@
1
+ @use '@carbon/colors';
1
2
  @use '@carbon/layout';
2
3
  @use '@carbon/type';
3
- @use '@carbon/colors';
4
4
 
5
- .section {
6
- margin: layout.$spacing-03;
7
- }
8
-
9
5
  .sectionTitle {
10
6
  @include type.type-style('heading-compact-02');
11
7
  color: colors.$gray-70;
@@ -16,6 +12,16 @@
16
12
  padding-bottom: layout.$spacing-05;
17
13
  }
18
14
 
15
+ .billInfo {
16
+ @include type.type-style('body-compact-01');
17
+ color: colors.$gray-70;
18
+
19
+ .separator {
20
+ margin: 0 layout.$spacing-02;
21
+ color: colors.$gray-50;
22
+ }
23
+ }
24
+
19
25
  .label {
20
26
  @include type.type-style('heading-compact-01');
21
27
  margin-bottom: layout.$spacing-05;
@@ -24,3 +30,13 @@
24
30
  .controlField {
25
31
  margin-bottom: layout.$spacing-05;
26
32
  }
33
+
34
+ .loader {
35
+ &:global(.cds--inline-loading) {
36
+ min-height: layout.$spacing-05;
37
+ }
38
+
39
+ :global(.cds--inline-loading__text) {
40
+ font-size: unset;
41
+ }
42
+ }
@@ -0,0 +1,227 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Form,
5
+ InlineLoading,
6
+ ModalBody,
7
+ ModalFooter,
8
+ ModalHeader,
9
+ NumberInput,
10
+ Stack,
11
+ TextInput,
12
+ } from '@carbon/react';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { Controller, useForm } from 'react-hook-form';
15
+ import { useSWRConfig } from 'swr';
16
+ import { z } from 'zod';
17
+ import { zodResolver } from '@hookform/resolvers/zod';
18
+ import { getCoreTranslation, showSnackbar, useConfig } from '@openmrs/esm-framework';
19
+ import { apiBasePath } from '../constants';
20
+ import { getBillableServiceUuid } from '../invoice/payments/utils';
21
+ import { type LineItem, type MappedBill } from '../types';
22
+ import { updateBillItems } from '../billing.resource';
23
+ import { useBillableServices } from '../billable-services/billable-service.resource';
24
+ import { type BillingConfig } from '../config-schema';
25
+ import { convertToCurrency } from '../helpers';
26
+ import styles from './bill-item-actions.scss';
27
+
28
+ interface EditBillLineItemModalProps {
29
+ bill: MappedBill;
30
+ closeModal: () => void;
31
+ item: LineItem;
32
+ }
33
+
34
+ const EditBillLineItemModal: React.FC<EditBillLineItemModalProps> = ({ bill, closeModal, item }) => {
35
+ const { t } = useTranslation();
36
+ const { defaultCurrency } = useConfig<BillingConfig>();
37
+ const { billableServices } = useBillableServices();
38
+ const [total, setTotal] = useState(0);
39
+ const { mutate } = useSWRConfig();
40
+
41
+ const schema = useMemo(
42
+ () =>
43
+ z.object({
44
+ // NOTE: Frontend-only validation - quantities <1 or >100 can still be submitted via API.
45
+ // Backend (BillServiceImpl.java:100) has empty validate() method.
46
+ // TODO: Add server-side validation to enforce data integrity
47
+ quantity: z.coerce
48
+ .number({
49
+ required_error: t('quantityRequired', 'Quantity is required'),
50
+ invalid_type_error: t('quantityMustBeNumber', 'Quantity must be a valid number'),
51
+ })
52
+ .int(t('quantityMustBeInteger', 'Quantity must be a whole number'))
53
+ .min(1, t('quantityMustBeAtLeastOne', 'Quantity must be at least 1'))
54
+ .max(100, t('quantityCannotExceed100', 'Quantity cannot exceed 100')),
55
+ price: z.coerce
56
+ .number({
57
+ required_error: t('priceIsRequired', 'Price is required'),
58
+ invalid_type_error: t('priceMustBeNumber', 'Price must be a valid number'),
59
+ })
60
+ .positive(t('priceMustBePositive', 'Price must be greater than 0')),
61
+ }),
62
+ [t],
63
+ );
64
+
65
+ type BillLineItemForm = z.infer<typeof schema>;
66
+
67
+ const {
68
+ control,
69
+ handleSubmit,
70
+ formState: { isSubmitting, errors },
71
+ watch,
72
+ } = useForm<BillLineItemForm>({
73
+ defaultValues: {
74
+ quantity: item.quantity,
75
+ price: item.price,
76
+ },
77
+ resolver: zodResolver(schema),
78
+ });
79
+
80
+ const quantity = watch('quantity');
81
+ const price = watch('price');
82
+
83
+ useEffect(() => {
84
+ const quantityNum = typeof quantity === 'number' ? quantity : parseFloat(quantity) || 0;
85
+ const priceNum = typeof price === 'number' ? price : parseFloat(price) || 0;
86
+ const newTotal = quantityNum * priceNum;
87
+ setTotal(isNaN(newTotal) ? 0 : newTotal);
88
+ }, [quantity, price]);
89
+
90
+ const onSubmit = async (data: BillLineItemForm) => {
91
+ const url = `${apiBasePath}bill`;
92
+
93
+ const newItem = {
94
+ ...item,
95
+ quantity: data.quantity,
96
+ price: data.price,
97
+ billableService: getBillableServiceUuid(billableServices, item.billableService),
98
+ item: item?.item,
99
+ };
100
+
101
+ const previousLineitems = bill?.lineItems
102
+ .filter((currItem) => currItem.uuid !== item?.uuid)
103
+ .map((currItem) => ({
104
+ ...currItem,
105
+ billableService: getBillableServiceUuid(billableServices, currItem.billableService),
106
+ }));
107
+
108
+ const updatedLineItems = previousLineitems.concat(newItem);
109
+
110
+ const payload = {
111
+ cashPoint: bill.cashPointUuid,
112
+ cashier: bill.cashier.uuid,
113
+ lineItems: updatedLineItems,
114
+ patient: bill.patientUuid,
115
+ status: bill.status,
116
+ uuid: bill.uuid,
117
+ };
118
+
119
+ try {
120
+ await updateBillItems(payload);
121
+ mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true });
122
+ showSnackbar({
123
+ title: t('lineItemUpdated', 'Line item updated'),
124
+ subtitle: t('lineItemUpdateSuccess', 'The bill line item has been updated successfully'),
125
+ kind: 'success',
126
+ });
127
+ closeModal();
128
+ } catch (error) {
129
+ showSnackbar({
130
+ title: t('lineItemUpdateFailed', 'Failed to update line item'),
131
+ subtitle:
132
+ error?.message || t('lineItemUpdateErrorDefault', 'Unable to update the bill line item. Please try again.'),
133
+ kind: 'error',
134
+ });
135
+ }
136
+ };
137
+
138
+ if (Object.keys(bill)?.length === 0) {
139
+ return <ModalHeader closeModal={closeModal} title={t('billLineItemEmpty', 'This bill has no line items')} />;
140
+ }
141
+
142
+ return (
143
+ <>
144
+ <ModalHeader closeModal={closeModal} title={t('editBillLineItem', 'Edit bill line item')} />
145
+ <Form onSubmit={handleSubmit(onSubmit)}>
146
+ <ModalBody>
147
+ <Stack gap={5}>
148
+ <div className={styles.modalBody}>
149
+ <div className={styles.billInfo}>
150
+ {bill?.patientName}
151
+ <span className={styles.separator}>·</span>
152
+ {bill?.cashPointName}
153
+ <span className={styles.separator}>·</span>
154
+ {bill?.receiptNumber}
155
+ </div>
156
+ </div>
157
+ <section>
158
+ <p className={styles.label}>
159
+ {t('item', 'Item')}: {item?.billableService ? item?.billableService : item?.item}
160
+ </p>
161
+ <p className={styles.label}>
162
+ {t('currentPrice', 'Current price')}: {convertToCurrency(item?.price, defaultCurrency)}
163
+ </p>
164
+ <p className={styles.label}>
165
+ {t('serviceStatus', 'Service status')}: {item?.paymentStatus}
166
+ </p>
167
+ <Controller
168
+ name="quantity"
169
+ control={control}
170
+ render={({ field: { onChange, value } }) => (
171
+ <NumberInput
172
+ allowEmpty
173
+ disableWheel
174
+ className={styles.controlField}
175
+ hideSteppers
176
+ id="quantityInput"
177
+ invalid={!!errors.quantity}
178
+ invalidText={errors.quantity?.message}
179
+ label={t('quantity', 'Quantity')}
180
+ onChange={(_event, state: { value: number | string; direction: string }) => {
181
+ onChange(state.value);
182
+ }}
183
+ value={value}
184
+ />
185
+ )}
186
+ />
187
+
188
+ <Controller
189
+ name="price"
190
+ control={control}
191
+ render={({ field: { value } }) => (
192
+ <TextInput
193
+ className={styles.controlField}
194
+ helperText={t('unitPriceHelperText', 'This is the unit price for this item')}
195
+ id="priceInput"
196
+ labelText={t('unitPrice', 'Unit price')}
197
+ readOnly
198
+ value={value}
199
+ />
200
+ )}
201
+ />
202
+ <p className={styles.label} aria-live="polite">
203
+ {t('total', 'Total')}: {convertToCurrency(total, defaultCurrency)}
204
+ </p>
205
+ </section>
206
+ </Stack>
207
+ </ModalBody>
208
+ <ModalFooter>
209
+ <Button kind="secondary" onClick={closeModal}>
210
+ {getCoreTranslation('cancel')}
211
+ </Button>
212
+ <Button type="submit" disabled={isSubmitting}>
213
+ {isSubmitting ? (
214
+ <div className={styles.inline}>
215
+ <InlineLoading className={styles.loader} description={`${t('submitting', 'Submitting')}...`} />
216
+ </div>
217
+ ) : (
218
+ getCoreTranslation('save')
219
+ )}
220
+ </Button>
221
+ </ModalFooter>
222
+ </Form>
223
+ </>
224
+ );
225
+ };
226
+
227
+ export default EditBillLineItemModal;
@@ -1,22 +1,29 @@
1
1
  import React from 'react';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import { showSnackbar } from '@openmrs/esm-framework';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import { type FetchResponse, getDefaultsFromConfigSchema, showSnackbar, useConfig } from '@openmrs/esm-framework';
5
+ import { configSchema, type BillingConfig } from '../config-schema';
4
6
  import { type MappedBill } from '../types';
5
7
  import { updateBillItems } from '../billing.resource';
6
- import ChangeStatus from './edit-bill-item.component';
8
+ import EditBillLineItemModal from './edit-bill-item.modal';
7
9
 
8
- // Mock external dependencies
9
- jest.mock('../billing.resource', () => ({
10
- updateBillItems: jest.fn(() => Promise.resolve({})),
11
- }));
10
+ const mockUpdateBillItems = jest.mocked(updateBillItems);
11
+ const mockShowSnackbar = jest.mocked(showSnackbar);
12
+ const mockUseConfig = jest.mocked(useConfig<BillingConfig>);
13
+
14
+ const mockBillableServices = [
15
+ { name: 'X-Ray Service', uuid: 'xray-uuid-123' },
16
+ { name: 'Lab Test Service', uuid: 'lab-uuid-456' },
17
+ { name: 'Consultation Service', uuid: 'consult-uuid-789' },
18
+ ];
12
19
 
13
- jest.mock('@openmrs/esm-framework', () => ({
14
- showSnackbar: jest.fn(),
20
+ jest.mock('../billing.resource', () => ({
21
+ updateBillItems: jest.fn().mockResolvedValue({}),
15
22
  }));
16
23
 
17
24
  jest.mock('../billable-services/billable-service.resource', () => ({
18
25
  useBillableServices: jest.fn(() => ({
19
- billableServices: [],
26
+ billableServices: mockBillableServices,
20
27
  })),
21
28
  }));
22
29
 
@@ -41,7 +48,7 @@ const mockBill: MappedBill = {
41
48
  voided: false,
42
49
  voidReason: null,
43
50
  priceName: 'Service Price',
44
- billableService: 'service-uuid',
51
+ billableService: 'X-Ray Service',
45
52
  priceUuid: 'price-uuid',
46
53
  lineItemOrder: 1,
47
54
  resourceVersion: '1.0',
@@ -62,7 +69,7 @@ const mockItem = {
62
69
  uuid: 'item-uuid',
63
70
  quantity: 2,
64
71
  price: 100,
65
- billableService: 'service-uuid',
72
+ billableService: 'X-Ray Service',
66
73
  paymentStatus: 'UNPAID',
67
74
  item: 'Test Service',
68
75
  display: 'Test Service',
@@ -74,64 +81,250 @@ const mockItem = {
74
81
  resourceVersion: '1.0',
75
82
  };
76
83
 
77
- describe('ChangeStatus component', () => {
78
- const closeModalMock = jest.fn();
79
-
84
+ describe('EditBillItem', () => {
80
85
  beforeEach(() => {
81
- jest.clearAllMocks();
86
+ mockUseConfig.mockReturnValue({
87
+ ...getDefaultsFromConfigSchema(configSchema),
88
+ defaultCurrency: 'USD',
89
+ });
82
90
  });
83
91
 
92
+ const mockCloseModal = jest.fn();
93
+
84
94
  test('renders the form with correct fields and default values', () => {
85
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
95
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
86
96
 
87
- expect(screen.getByText('Edit bill line item?')).toBeInTheDocument();
88
- expect(screen.getByText('John Doe · Main Cashpoint · 123456')).toBeInTheDocument();
89
- expect(screen.getByRole('spinbutton', { name: /Quantity/ })).toHaveValue(2);
90
- expect(screen.getByLabelText(/Unit Price/)).toHaveValue('100');
91
- expect(screen.getByText(/Total/)).toHaveTextContent('200');
97
+ expect(screen.getByText(/edit bill line item/i)).toBeInTheDocument();
98
+ expect(screen.getByText(/John Doe/)).toBeInTheDocument();
99
+ expect(screen.getByText(/Main Cashpoint/)).toBeInTheDocument();
100
+ expect(screen.getByText(/123456/)).toBeInTheDocument();
101
+ expect(screen.getByRole('spinbutton', { name: /quantity/i })).toHaveValue(2);
102
+ expect(screen.getByLabelText(/unit price/i)).toHaveValue('100');
103
+ expect(screen.getByText(/total/i)).toHaveTextContent(/200/);
92
104
  });
93
105
 
94
- test('updates total when quantity is changed', () => {
95
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
106
+ test('updates total when quantity is changed', async () => {
107
+ const user = userEvent.setup();
108
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
96
109
 
97
- const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
98
- fireEvent.change(quantityInput, { target: { value: 3 } });
110
+ const quantityInput = screen.getByRole('spinbutton', { name: /quantity/i });
111
+ await user.clear(quantityInput);
112
+ await user.type(quantityInput, '3');
99
113
 
100
- expect(screen.getByText(/Total/)).toHaveTextContent('300');
114
+ expect(screen.getByText(/total/i)).toHaveTextContent(/300/);
101
115
  });
102
116
 
103
117
  test('submits the form and shows a success notification', async () => {
104
- (updateBillItems as jest.Mock).mockResolvedValueOnce({});
118
+ const user = userEvent.setup();
119
+ mockUpdateBillItems.mockResolvedValueOnce({} as FetchResponse<any>);
105
120
 
106
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
121
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
107
122
 
108
- fireEvent.click(screen.getByText(/Save/));
123
+ await user.click(screen.getByText(/save/i));
109
124
 
110
125
  await waitFor(() => {
111
- expect(updateBillItems).toHaveBeenCalled();
126
+ expect(mockUpdateBillItems).toHaveBeenCalled();
112
127
  expect(showSnackbar).toHaveBeenCalledWith({
113
- title: 'Save Bill',
114
- subtitle: 'Bill processing has been successful',
128
+ title: 'Line item updated',
129
+ subtitle: 'The bill line item has been updated successfully',
115
130
  kind: 'success',
116
- timeoutInMs: 3000,
117
131
  });
118
- expect(closeModalMock).toHaveBeenCalled();
132
+ expect(mockCloseModal).toHaveBeenCalled();
119
133
  });
120
134
  });
121
135
 
122
136
  test('shows error notification when submission fails', async () => {
123
- (updateBillItems as jest.Mock).mockRejectedValueOnce({ message: 'Error occurred' });
137
+ const user = userEvent.setup();
138
+ mockUpdateBillItems.mockRejectedValueOnce({ message: 'Error occurred' });
124
139
 
125
- render(<ChangeStatus bill={mockBill} item={mockItem} closeModal={closeModalMock} />);
140
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
126
141
 
127
- fireEvent.click(screen.getByText(/Save/));
142
+ await user.click(screen.getByText(/Save/));
128
143
 
129
144
  await waitFor(() => {
130
- expect(showSnackbar).toHaveBeenCalledWith({
131
- title: 'Bill processing error',
145
+ expect(mockShowSnackbar).toHaveBeenCalledWith({
146
+ title: 'Failed to update line item',
132
147
  kind: 'error',
133
148
  subtitle: 'Error occurred',
134
149
  });
135
150
  });
136
151
  });
152
+
153
+ test('preserves billable service UUIDs for other line items when editing', async () => {
154
+ const user = userEvent.setup();
155
+ mockUpdateBillItems.mockResolvedValueOnce({} as FetchResponse<any>);
156
+
157
+ // Bill with multiple line items with different billable services
158
+ const billWithMultipleItems: MappedBill = {
159
+ ...mockBill,
160
+ lineItems: [
161
+ {
162
+ uuid: 'item-1',
163
+ quantity: 1,
164
+ price: 100,
165
+ billableService: 'X-Ray Service',
166
+ paymentStatus: 'PENDING',
167
+ item: 'X-Ray',
168
+ display: 'X-Ray',
169
+ voided: false,
170
+ voidReason: null,
171
+ priceName: 'X-Ray Price',
172
+ priceUuid: 'xray-price-uuid',
173
+ lineItemOrder: 1,
174
+ resourceVersion: '1.0',
175
+ },
176
+ {
177
+ uuid: 'item-2',
178
+ quantity: 2,
179
+ price: 50,
180
+ billableService: 'Lab Test Service',
181
+ paymentStatus: 'PENDING',
182
+ item: 'Lab Test',
183
+ display: 'Lab Test',
184
+ voided: false,
185
+ voidReason: null,
186
+ priceName: 'Lab Price',
187
+ priceUuid: 'lab-price-uuid',
188
+ lineItemOrder: 2,
189
+ resourceVersion: '1.0',
190
+ },
191
+ {
192
+ uuid: 'item-3',
193
+ quantity: 1,
194
+ price: 200,
195
+ billableService: 'Consultation Service',
196
+ paymentStatus: 'PENDING',
197
+ item: 'Consultation',
198
+ display: 'Consultation',
199
+ voided: false,
200
+ voidReason: null,
201
+ priceName: 'Consult Price',
202
+ priceUuid: 'consult-price-uuid',
203
+ lineItemOrder: 3,
204
+ resourceVersion: '1.0',
205
+ },
206
+ ],
207
+ };
208
+
209
+ // Editing the Lab Test item (item-2)
210
+ const itemToEdit = billWithMultipleItems.lineItems[1];
211
+
212
+ render(<EditBillLineItemModal bill={billWithMultipleItems} item={itemToEdit} closeModal={mockCloseModal} />);
213
+
214
+ await user.click(screen.getByText(/Save/));
215
+
216
+ await waitFor(() => {
217
+ expect(mockUpdateBillItems).toHaveBeenCalled();
218
+ const payload = mockUpdateBillItems.mock.calls[0][0];
219
+
220
+ // Verify that each line item has the correct billable service UUID
221
+ const xrayItem = payload.lineItems.find((li) => li.uuid === 'item-1');
222
+ const consultItem = payload.lineItems.find((li) => li.uuid === 'item-3');
223
+
224
+ // These should NOT have the Lab Test UUID (lab-uuid-456)
225
+ // They should keep their original UUIDs
226
+ expect(xrayItem?.billableService).toBe('xray-uuid-123');
227
+ expect(consultItem?.billableService).toBe('consult-uuid-789');
228
+
229
+ // The edited item should have the Lab Test UUID
230
+ const labItem = payload.lineItems.find((li) => li.uuid === 'item-2');
231
+ expect(labItem?.billableService).toBe('lab-uuid-456');
232
+ });
233
+ });
234
+
235
+ test('shows validation error for quantity less than 1', async () => {
236
+ const user = userEvent.setup();
237
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
238
+
239
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
240
+ await user.clear(quantityInput);
241
+ await user.type(quantityInput, '0');
242
+
243
+ // Try to submit
244
+ await user.click(screen.getByText(/Save/));
245
+
246
+ // Should show validation error
247
+ await waitFor(() => {
248
+ expect(screen.getByText(/Quantity must be at least 1/)).toBeInTheDocument();
249
+ });
250
+
251
+ // Should NOT call the update function
252
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
253
+ });
254
+
255
+ test('shows validation error for quantity greater than 100', async () => {
256
+ const user = userEvent.setup();
257
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
258
+
259
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
260
+ await user.clear(quantityInput);
261
+ await user.type(quantityInput, '101');
262
+
263
+ await user.click(screen.getByText(/Save/));
264
+
265
+ await waitFor(() => {
266
+ expect(screen.getByText(/Quantity cannot exceed 100/)).toBeInTheDocument();
267
+ });
268
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
269
+ });
270
+
271
+ test('shows validation error for non-integer quantity', async () => {
272
+ const user = userEvent.setup();
273
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
274
+
275
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
276
+ await user.clear(quantityInput);
277
+ await user.type(quantityInput, '2.5');
278
+
279
+ await user.click(screen.getByText(/Save/));
280
+
281
+ await waitFor(() => {
282
+ expect(screen.getByText(/Quantity must be a whole number/)).toBeInTheDocument();
283
+ });
284
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
285
+ });
286
+
287
+ test('clears validation error when valid quantity is entered', async () => {
288
+ const user = userEvent.setup();
289
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
290
+
291
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
292
+
293
+ // Enter invalid value
294
+ await user.clear(quantityInput);
295
+ await user.type(quantityInput, '0');
296
+ await user.click(screen.getByText(/Save/));
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByText(/Quantity must be at least 1/)).toBeInTheDocument();
300
+ });
301
+
302
+ // Fix it
303
+ await user.clear(quantityInput);
304
+ await user.type(quantityInput, '5');
305
+
306
+ // Error should disappear
307
+ await waitFor(() => {
308
+ expect(screen.queryByText(/Quantity must be at least 1/)).not.toBeInTheDocument();
309
+ });
310
+ });
311
+
312
+ test('shows validation error when quantity field is left empty', async () => {
313
+ const user = userEvent.setup();
314
+ render(<EditBillLineItemModal bill={mockBill} item={mockItem} closeModal={mockCloseModal} />);
315
+
316
+ const quantityInput = screen.getByRole('spinbutton', { name: /Quantity/ });
317
+ await user.clear(quantityInput);
318
+
319
+ // Try to submit with empty field
320
+ await user.click(screen.getByText(/save/i));
321
+
322
+ // Should show validation error (empty string coerces to 0, triggering min validation)
323
+ await waitFor(() => {
324
+ expect(screen.getByText(/Quantity must be at least 1/)).toBeInTheDocument();
325
+ });
326
+
327
+ // Should NOT call the update function
328
+ expect(mockUpdateBillItems).not.toHaveBeenCalled();
329
+ });
137
330
  });
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import {
3
3
  Checkbox,
4
4
  Layer,
@@ -9,7 +9,7 @@ import {
9
9
  StructuredListWrapper,
10
10
  } from '@carbon/react';
11
11
  import { useTranslation } from 'react-i18next';
12
- import { useConfig } from '@openmrs/esm-framework';
12
+ import { getCoreTranslation, useConfig } from '@openmrs/esm-framework';
13
13
  import { convertToCurrency } from '../../helpers';
14
14
  import { type MappedBill, type LineItem } from '../../types';
15
15
  import BillWaiverForm from './bill-waiver-form.component';
@@ -20,7 +20,7 @@ const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (pat
20
20
  setPatientUuid,
21
21
  }) => {
22
22
  const { t } = useTranslation();
23
- const [selectedBills, setSelectedBills] = React.useState<Array<LineItem>>([]);
23
+ const [selectedBills, setSelectedBills] = useState<Array<LineItem>>([]);
24
24
  const { defaultCurrency } = useConfig();
25
25
 
26
26
  const checkBoxLabel = (lineItem) => {
@@ -42,9 +42,9 @@ const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (pat
42
42
  <StructuredListRow head>
43
43
  <StructuredListCell head>{t('billItem', 'Bill item')}</StructuredListCell>
44
44
  <StructuredListCell head>{t('quantity', 'Quantity')}</StructuredListCell>
45
- <StructuredListCell head>{t('unitPrice', 'Unit Price')}</StructuredListCell>
45
+ <StructuredListCell head>{t('unitPrice', 'Unit price')}</StructuredListCell>
46
46
  <StructuredListCell head>{t('total', 'Total')}</StructuredListCell>
47
- <StructuredListCell head>{t('actions', 'Actions')}</StructuredListCell>
47
+ <StructuredListCell head>{getCoreTranslation('actions')}</StructuredListCell>
48
48
  </StructuredListRow>
49
49
  </StructuredListHead>
50
50
  <StructuredListBody>