@openmrs/esm-billing-app 1.0.1-pre.98 → 1.0.2-pre.58

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 (224) hide show
  1. package/.eslintignore +0 -1
  2. package/.eslintrc +33 -24
  3. package/.husky/pre-commit +1 -1
  4. package/.turbo.json +1 -1
  5. package/.tx/config +11 -0
  6. package/README.md +111 -1
  7. package/dist/1119.js +1 -0
  8. package/dist/1197.js +1 -0
  9. package/dist/1362.js +1 -0
  10. package/dist/1362.js.map +1 -0
  11. package/dist/2146.js +1 -0
  12. package/dist/2690.js +1 -0
  13. package/dist/3029.js +2 -0
  14. package/dist/3029.js.LICENSE.txt +7 -0
  15. package/dist/3029.js.map +1 -0
  16. package/dist/3099.js +1 -0
  17. package/dist/3511.js +1 -0
  18. package/dist/3511.js.map +1 -0
  19. package/dist/3584.js +1 -0
  20. package/dist/4055.js +1 -0
  21. package/dist/4132.js +1 -0
  22. package/dist/4225.js +1 -0
  23. package/dist/4225.js.map +1 -0
  24. package/dist/4300.js +1 -0
  25. package/dist/4335.js +1 -0
  26. package/dist/4618.js +1 -0
  27. package/dist/4652.js +1 -0
  28. package/dist/4817.js +2 -0
  29. package/dist/4817.js.LICENSE.txt +77 -0
  30. package/dist/4817.js.map +1 -0
  31. package/dist/4944.js +1 -0
  32. package/dist/4993.js +1 -0
  33. package/dist/4993.js.map +1 -0
  34. package/dist/5173.js +1 -0
  35. package/dist/5241.js +1 -0
  36. package/dist/5442.js +1 -0
  37. package/dist/5661.js +1 -0
  38. package/dist/6022.js +1 -0
  39. package/dist/6468.js +1 -0
  40. package/dist/6540.js +2 -0
  41. package/dist/6540.js.map +1 -0
  42. package/dist/6606.js +2 -0
  43. package/dist/{591.js.LICENSE.txt → 6606.js.LICENSE.txt} +2 -2
  44. package/dist/6606.js.map +1 -0
  45. package/dist/6679.js +1 -0
  46. package/dist/6840.js +1 -0
  47. package/dist/6859.js +1 -0
  48. package/dist/6941.js +1 -0
  49. package/dist/6941.js.map +1 -0
  50. package/dist/7097.js +1 -0
  51. package/dist/7159.js +1 -0
  52. package/dist/723.js +1 -0
  53. package/dist/7255.js +1 -0
  54. package/dist/7255.js.map +1 -0
  55. package/dist/7617.js +1 -0
  56. package/dist/763.js +1 -0
  57. package/dist/763.js.map +1 -0
  58. package/dist/8163.js +1 -0
  59. package/dist/8349.js +1 -0
  60. package/dist/8618.js +1 -0
  61. package/dist/890.js +1 -0
  62. package/dist/9055.js +1 -0
  63. package/dist/9055.js.map +1 -0
  64. package/dist/9214.js +1 -0
  65. package/dist/9538.js +1 -0
  66. package/dist/{935.js → 961.js} +2 -2
  67. package/dist/{935.js.map → 961.js.map} +1 -1
  68. package/dist/986.js +1 -0
  69. package/dist/9879.js +1 -0
  70. package/dist/9895.js +1 -0
  71. package/dist/9900.js +1 -0
  72. package/dist/9913.js +1 -0
  73. package/dist/main.js +1 -1
  74. package/dist/main.js.LICENSE.txt +31 -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 +844 -165
  78. package/dist/openmrs-esm-billing-app.js.map +1 -1
  79. package/dist/routes.json +1 -1
  80. package/jest.config.js +4 -1
  81. package/package.json +19 -21
  82. package/src/bill-history/bill-history.component.tsx +5 -3
  83. package/src/bill-history/bill-history.scss +24 -9
  84. package/src/bill-history/bill-history.test.tsx +58 -16
  85. package/src/bill-item-actions/bill-item-actions.scss +26 -0
  86. package/src/bill-item-actions/edit-bill-item.component.tsx +221 -0
  87. package/src/bill-item-actions/edit-bill-item.test.tsx +137 -0
  88. package/src/billable-services/bill-waiver/bill-selection.component.tsx +1 -1
  89. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +2 -2
  90. package/src/billable-services/bill-waiver/bill-waiver-form.scss +4 -4
  91. package/src/billable-services/bill-waiver/bill-waiver.component.tsx +4 -4
  92. package/src/billable-services/bill-waiver/patient-bills.component.tsx +1 -1
  93. package/src/billable-services/billable-service.resource.ts +19 -6
  94. package/src/billable-services/billable-services-home.component.tsx +19 -3
  95. package/src/billable-services/billable-services-menu-item/item.component.tsx +17 -0
  96. package/src/billable-services/billable-services-menu-item/item.scss +14 -0
  97. package/src/billable-services/billable-services.component.tsx +48 -9
  98. package/src/billable-services/billable-services.scss +10 -9
  99. package/src/billable-services/billable-services.test.tsx +172 -8
  100. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +276 -0
  101. package/src/billable-services/cash-point/cash-point-configuration.scss +23 -0
  102. package/src/billable-services/create-edit/add-billable-service.component.tsx +126 -47
  103. package/src/billable-services/create-edit/add-billable-service.scss +14 -8
  104. package/src/billable-services/create-edit/add-billable-service.test.tsx +12 -10
  105. package/src/billable-services/dashboard/dashboard.scss +3 -3
  106. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +280 -0
  107. package/src/billable-services/payyment-modes/payment-modes-config.scss +23 -0
  108. package/src/billing-dashboard/billing-dashboard.component.tsx +17 -4
  109. package/src/billing-dashboard/billing-dashboard.scss +3 -3
  110. package/src/billing-form/billing-form.component.tsx +31 -25
  111. package/src/billing-form/billing-form.scss +9 -10
  112. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +38 -14
  113. package/src/billing-header/billing-header.component.tsx +21 -5
  114. package/src/billing-header/billing-header.scss +1 -1
  115. package/src/billing.resource.ts +21 -4
  116. package/src/bills-table/bills-table.component.tsx +46 -36
  117. package/src/bills-table/bills-table.scss +6 -6
  118. package/src/bills-table/bills-table.test.tsx +108 -68
  119. package/src/config-schema.ts +36 -1
  120. package/src/constants.ts +2 -0
  121. package/src/dashboard.meta.ts +2 -1
  122. package/src/helpers/functions.ts +0 -2
  123. package/src/hooks/selectedDateContext.ts +10 -0
  124. package/src/index.ts +22 -27
  125. package/src/invoice/invoice-table.component.tsx +95 -56
  126. package/src/invoice/invoice-table.scss +7 -8
  127. package/src/invoice/invoice-table.test.tsx +151 -0
  128. package/src/invoice/invoice.component.tsx +7 -9
  129. package/src/invoice/invoice.scss +2 -2
  130. package/src/invoice/invoice.test.tsx +199 -169
  131. package/src/invoice/payments/payment-form/payment-form.component.tsx +84 -55
  132. package/src/invoice/payments/payment-form/payment-form.test.tsx +174 -0
  133. package/src/invoice/payments/payment-history/payment-history.component.tsx +9 -7
  134. package/src/invoice/payments/payment-history/payment-history.test.tsx +160 -0
  135. package/src/invoice/payments/payments.component.test.tsx +121 -0
  136. package/src/invoice/payments/payments.component.tsx +57 -48
  137. package/src/invoice/payments/utils.ts +17 -13
  138. package/src/invoice/printable-invoice/print-receipt.component.tsx +23 -8
  139. package/src/invoice/printable-invoice/print-receipt.test.tsx +50 -0
  140. package/src/metrics-cards/card.component.tsx +4 -2
  141. package/src/metrics-cards/metrics-cards.test.tsx +1 -1
  142. package/src/modal/require-payment-modal.component.tsx +2 -2
  143. package/src/modal/require-payment-modal.test.tsx +66 -0
  144. package/src/modal/require-payment.scss +2 -1
  145. package/src/routes.json +40 -8
  146. package/src/types/index.ts +15 -0
  147. package/{i18next-parser.config.js → tools/i18next-parser.config.js} +19 -19
  148. package/tools/update-openmrs-deps.mjs +42 -0
  149. package/translations/am.json +53 -0
  150. package/translations/ar.json +170 -0
  151. package/translations/ar_SY.json +170 -0
  152. package/translations/bn.json +170 -0
  153. package/translations/de.json +170 -0
  154. package/translations/en.json +53 -0
  155. package/translations/es.json +53 -0
  156. package/translations/es_MX.json +170 -0
  157. package/translations/fr.json +53 -0
  158. package/translations/he.json +53 -0
  159. package/translations/hi.json +170 -0
  160. package/translations/hi_IN.json +170 -0
  161. package/translations/id.json +170 -0
  162. package/translations/it.json +170 -0
  163. package/translations/km.json +53 -0
  164. package/translations/ku.json +170 -0
  165. package/translations/ky.json +170 -0
  166. package/translations/lg.json +170 -0
  167. package/translations/ne.json +170 -0
  168. package/translations/pl.json +170 -0
  169. package/translations/pt.json +170 -0
  170. package/translations/pt_BR.json +170 -0
  171. package/translations/qu.json +170 -0
  172. package/translations/ro_RO.json +170 -0
  173. package/translations/ru_RU.json +170 -0
  174. package/translations/si.json +170 -0
  175. package/translations/sw.json +170 -0
  176. package/translations/sw_KE.json +170 -0
  177. package/translations/tr.json +170 -0
  178. package/translations/tr_TR.json +170 -0
  179. package/translations/uk.json +170 -0
  180. package/translations/uz.json +170 -0
  181. package/translations/uz@Latn.json +170 -0
  182. package/translations/uz_UZ.json +170 -0
  183. package/translations/vi.json +170 -0
  184. package/translations/zh.json +170 -0
  185. package/translations/zh_CN.json +170 -0
  186. package/tsconfig.json +10 -8
  187. package/webpack.config.js +1 -1
  188. package/dist/146.js +0 -1
  189. package/dist/146.js.map +0 -1
  190. package/dist/294.js +0 -2
  191. package/dist/294.js.map +0 -1
  192. package/dist/319.js +0 -1
  193. package/dist/384.js +0 -1
  194. package/dist/384.js.map +0 -1
  195. package/dist/421.js +0 -1
  196. package/dist/421.js.map +0 -1
  197. package/dist/533.js +0 -1
  198. package/dist/533.js.map +0 -1
  199. package/dist/574.js +0 -1
  200. package/dist/591.js +0 -2
  201. package/dist/591.js.map +0 -1
  202. package/dist/614.js +0 -2
  203. package/dist/614.js.LICENSE.txt +0 -37
  204. package/dist/614.js.map +0 -1
  205. package/dist/753.js +0 -1
  206. package/dist/753.js.map +0 -1
  207. package/dist/757.js +0 -1
  208. package/dist/770.js +0 -1
  209. package/dist/770.js.map +0 -1
  210. package/dist/783.js +0 -1
  211. package/dist/783.js.map +0 -1
  212. package/dist/788.js +0 -1
  213. package/dist/800.js +0 -2
  214. package/dist/800.js.LICENSE.txt +0 -3
  215. package/dist/800.js.map +0 -1
  216. package/dist/807.js +0 -1
  217. package/dist/833.js +0 -1
  218. package/dist/992.js +0 -1
  219. package/dist/992.js.map +0 -1
  220. package/src/root.scss +0 -30
  221. /package/dist/{294.js.LICENSE.txt → 6540.js.LICENSE.txt} +0 -0
  222. /package/dist/{935.js.LICENSE.txt → 961.js.LICENSE.txt} +0 -0
  223. /package/{src → tools}/setup-tests.ts +0 -0
  224. /package/{test-helpers.tsx → tools/test-helpers.tsx} +0 -0
@@ -6,6 +6,9 @@ import {
6
6
  DataTable,
7
7
  InlineLoading,
8
8
  Layer,
9
+ Modal,
10
+ OverflowMenu,
11
+ OverflowMenuItem,
9
12
  Pagination,
10
13
  Search,
11
14
  Table,
@@ -20,7 +23,9 @@ import {
20
23
  import { ArrowRight } from '@carbon/react/icons';
21
24
  import { useLayoutType, isDesktop, useConfig, usePagination, ErrorState, navigate } from '@openmrs/esm-framework';
22
25
  import { EmptyState } from '@openmrs/esm-patient-common-lib';
26
+ import { type BillableService } from '../types/index';
23
27
  import { useBillableServices } from './billable-service.resource';
28
+ import AddBillableService from './create-edit/add-billable-service.component';
24
29
  import styles from './billable-services.scss';
25
30
 
26
31
  const BillableServices = () => {
@@ -33,9 +38,8 @@ const BillableServices = () => {
33
38
  const pageSizes = config?.billableServices?.pageSizes ?? [10, 20, 30, 40, 50];
34
39
  const [pageSize, setPageSize] = useState(config?.billableServices?.pageSize ?? 10);
35
40
 
36
- //creating service state
37
41
  const [showOverlay, setShowOverlay] = useState(false);
38
- const [overlayHeader, setOverlayTitle] = useState('');
42
+ const [editingService, setEditingService] = useState(null);
39
43
 
40
44
  const headerData = [
41
45
  {
@@ -66,25 +70,29 @@ const BillableServices = () => {
66
70
 
67
71
  const launchBillableServiceForm = useCallback(() => {
68
72
  navigate({ to: window.getOpenmrsSpaBase() + 'billable-services/add-service' });
73
+ setEditingService(null);
74
+ setShowOverlay(true);
69
75
  }, []);
70
76
 
71
- const searchResults = useMemo(() => {
72
- if (billableServices !== undefined && billableServices.length > 0) {
77
+ const searchResults: BillableService[] = useMemo(() => {
78
+ const flatBillableServices = Array.isArray(billableServices) ? billableServices.flat() : billableServices;
79
+
80
+ if (flatBillableServices !== undefined && flatBillableServices.length > 0) {
73
81
  if (searchString && searchString.trim() !== '') {
74
82
  const search = searchString.toLowerCase();
75
- return billableServices?.filter((service) =>
83
+ return flatBillableServices.filter((service: BillableService) =>
76
84
  Object.entries(service).some(([header, value]) => {
77
85
  return header === 'uuid' ? false : `${value}`.toLowerCase().includes(search);
78
86
  }),
79
87
  );
80
88
  }
81
89
  }
82
- return billableServices;
90
+ return flatBillableServices;
83
91
  }, [searchString, billableServices]);
84
92
 
85
- const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize);
93
+ const { paginated, goTo, results, currentPage } = usePagination<BillableService>(searchResults, pageSize);
94
+ const rowData = [];
86
95
 
87
- let rowData = [];
88
96
  if (results) {
89
97
  results.forEach((service, index) => {
90
98
  const s = {
@@ -95,7 +103,16 @@ const BillableServices = () => {
95
103
  serviceType: service?.serviceType?.display,
96
104
  status: service.serviceStatus,
97
105
  prices: '--',
98
- actions: '--',
106
+ actions: (
107
+ <TableCell>
108
+ <OverflowMenu size="sm" flipped>
109
+ <OverflowMenuItem
110
+ itemText={t('editBillableService', 'Edit Billable Service')}
111
+ onClick={() => handleEditService(service)}
112
+ />
113
+ </OverflowMenu>
114
+ </TableCell>
115
+ ),
99
116
  };
100
117
  let cost = '';
101
118
  service.servicePrices.forEach((price) => {
@@ -113,6 +130,15 @@ const BillableServices = () => {
113
130
  },
114
131
  [goTo, setSearchString],
115
132
  );
133
+ const handleEditService = useCallback((service) => {
134
+ setEditingService(service);
135
+ setShowOverlay(true);
136
+ }, []);
137
+
138
+ const closeModal = useCallback(() => {
139
+ setShowOverlay(false);
140
+ setEditingService(null);
141
+ }, []);
116
142
 
117
143
  if (isLoading) {
118
144
  <InlineLoading status="active" iconDescription="Loading" description="Loading data..." />;
@@ -212,6 +238,19 @@ const BillableServices = () => {
212
238
  headerTitle={t('billableService', 'Billable service')}
213
239
  />
214
240
  )}
241
+ {showOverlay && (
242
+ <Modal
243
+ open={showOverlay}
244
+ modalHeading={t('billableService', 'Billable Service')}
245
+ primaryButtonText={null}
246
+ secondaryButtonText={t('cancel', 'Cancel')}
247
+ onRequestClose={closeModal}
248
+ onSecondarySubmit={closeModal}
249
+ size="lg"
250
+ passiveModal={true}>
251
+ <AddBillableService editingService={editingService} onClose={closeModal} />
252
+ </Modal>
253
+ )}
215
254
  </>
216
255
  );
217
256
  };
@@ -1,9 +1,9 @@
1
1
  @use '@carbon/layout';
2
2
  @use '@carbon/type';
3
- @import '~@openmrs/esm-styleguide/src/vars';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
4
 
5
5
  .container {
6
- margin: 2rem 0;
6
+ margin: layout.$spacing-07 0;
7
7
  }
8
8
 
9
9
  .emptyStateContainer,
@@ -39,7 +39,7 @@
39
39
  }
40
40
 
41
41
  :global(.cds--list-box__menu-icon) {
42
- height: 1rem;
42
+ height: layout.$spacing-05;
43
43
  }
44
44
 
45
45
  :global(.cds--list-box__menu) {
@@ -70,7 +70,7 @@
70
70
  justify-content: space-between;
71
71
 
72
72
  &:global(.cds--inline-loading) {
73
- max-height: 1rem;
73
+ max-height: layout.$spacing-05;
74
74
  }
75
75
  }
76
76
 
@@ -95,7 +95,7 @@
95
95
  }
96
96
 
97
97
  .emptyRow {
98
- padding: 0 1rem;
98
+ padding: 0 layout.$spacing-05;
99
99
  display: flex;
100
100
  align-items: center;
101
101
  }
@@ -103,7 +103,7 @@
103
103
  .visitSummaryContainer {
104
104
  width: 100%;
105
105
  max-width: 768px;
106
- margin: 1rem auto;
106
+ margin: layout.$spacing-05 auto;
107
107
  }
108
108
 
109
109
  .expandedActiveVisitRow > td > div {
@@ -111,11 +111,11 @@
111
111
  }
112
112
 
113
113
  .expandedActiveVisitRow td {
114
- padding: 0 2rem;
114
+ padding: 0 layout.$spacing-07;
115
115
  }
116
116
 
117
117
  .expandedActiveVisitRow th[colspan] td[colspan] > div:first-child {
118
- padding: 0 1rem;
118
+ padding: 0 layout.$spacing-05;
119
119
  }
120
120
 
121
121
  .action {
@@ -146,7 +146,7 @@
146
146
  &:after {
147
147
  content: '';
148
148
  display: block;
149
- width: 2rem;
149
+ width: layout.$spacing-07;
150
150
  padding-top: 3px;
151
151
  border-bottom: 0.375rem solid;
152
152
  @include brand-03(border-bottom-color);
@@ -216,3 +216,4 @@
216
216
  display: grid;
217
217
  grid-template-columns: 16rem 1fr;
218
218
  }
219
+
@@ -1,16 +1,180 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import BillableServices from './billable-services.component';
5
+ import { useBillableServices } from './billable-service.resource';
6
+
7
+ // Mock the resource
8
+ jest.mock('./billable-service.resource', () => ({
9
+ useBillableServices: jest.fn(),
10
+ }));
11
+
12
+ // Mock the empty state component
13
+ jest.mock('@openmrs/esm-patient-common-lib', () => ({
14
+ EmptyState: jest.fn(({ displayText, headerTitle }) => (
15
+ <div data-testid="empty-state">
16
+ <h1>{headerTitle}</h1>
17
+ <p>{displayText}</p>
18
+ </div>
19
+ )),
20
+ }));
21
+
22
+ // Mock navigation
23
+ jest.mock('@openmrs/esm-framework', () => ({
24
+ useLayoutType: jest.fn(() => 'desktop'),
25
+ isDesktop: jest.fn(() => true),
26
+ useConfig: jest.fn(() => ({
27
+ billableServices: {
28
+ pageSizes: [10, 20, 30, 40, 50],
29
+ pageSize: 10,
30
+ },
31
+ })),
32
+ usePagination: jest.fn().mockImplementation((data) => ({
33
+ currentPage: 1,
34
+ goTo: jest.fn(),
35
+ results: data,
36
+ paginated: true,
37
+ })),
38
+ navigate: jest.fn(),
39
+ ErrorState: jest.fn(({ error }) => <div>Error: {error?.message || error}</div>),
40
+ }));
41
+
42
+ // Mock i18next
43
+ jest.mock('react-i18next', () => ({
44
+ useTranslation: () => ({
45
+ t: (key: string, fallback: string) => fallback || key,
46
+ }),
47
+ }));
4
48
 
5
49
  describe('BillableService', () => {
6
- test('renders an empty state when there are no billable services', () => {
7
- renderBillableServices();
50
+ const mockedUseBillableServices = useBillableServices as jest.Mock;
8
51
 
9
- expect(screen.getByText(/Empty data illustration/i)).toBeInTheDocument();
10
- expect(screen.getByText(/There are no services to display to display for this patient/i)).toBeInTheDocument();
52
+ beforeEach(() => {
53
+ jest.clearAllMocks();
11
54
  });
12
- });
13
55
 
14
- function renderBillableServices() {
15
- render(<BillableServices />);
16
- }
56
+ it('renders an empty state when there are no billable services', () => {
57
+ mockedUseBillableServices.mockReturnValue({
58
+ billableServices: [],
59
+ isLoading: false,
60
+ isValidating: false,
61
+ error: null,
62
+ mutate: jest.fn(),
63
+ });
64
+
65
+ render(<BillableServices />);
66
+
67
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
68
+ expect(screen.getByText('Billable service')).toBeInTheDocument();
69
+ expect(screen.getByText('There are no services to display')).toBeInTheDocument();
70
+ });
71
+
72
+ it('renders billable services table correctly', () => {
73
+ const mockServices = [
74
+ {
75
+ uuid: '1',
76
+ name: 'Service 1',
77
+ shortName: 'S1',
78
+ serviceType: { display: 'Type 1' },
79
+ serviceStatus: 'ACTIVE',
80
+ servicePrices: [{ name: 'Price 1', price: 100 }],
81
+ },
82
+ {
83
+ uuid: '2',
84
+ name: 'Service 2',
85
+ shortName: 'S2',
86
+ serviceType: { display: 'Type 2' },
87
+ serviceStatus: 'ACTIVE',
88
+ servicePrices: [{ name: 'Price 2', price: 200 }],
89
+ },
90
+ ];
91
+
92
+ mockedUseBillableServices.mockReturnValue({
93
+ billableServices: mockServices,
94
+ isLoading: false,
95
+ isValidating: false,
96
+ error: null,
97
+ mutate: jest.fn(),
98
+ });
99
+
100
+ render(<BillableServices />);
101
+
102
+ // Check table headers
103
+ expect(screen.getByText('Service Name')).toBeInTheDocument();
104
+ expect(screen.getByText('Short Name')).toBeInTheDocument();
105
+ expect(screen.getByText('Service Type')).toBeInTheDocument();
106
+ expect(screen.getByText('Service Status')).toBeInTheDocument();
107
+
108
+ // Check service data
109
+ expect(screen.getByText('Service 1')).toBeInTheDocument();
110
+ expect(screen.getByText('Service 2')).toBeInTheDocument();
111
+ });
112
+
113
+ it('filters services based on search input', async () => {
114
+ const mockServices = [
115
+ {
116
+ uuid: '1',
117
+ name: 'Service 1',
118
+ shortName: 'S1',
119
+ serviceType: { display: 'Type 1' },
120
+ serviceStatus: 'ACTIVE',
121
+ servicePrices: [{ name: 'Price 1', price: 100 }],
122
+ },
123
+ {
124
+ uuid: '2',
125
+ name: 'Different Service',
126
+ shortName: 'S2',
127
+ serviceType: { display: 'Type 2' },
128
+ serviceStatus: 'ACTIVE',
129
+ servicePrices: [{ name: 'Price 2', price: 200 }],
130
+ },
131
+ ];
132
+
133
+ mockedUseBillableServices.mockReturnValue({
134
+ billableServices: mockServices,
135
+ isLoading: false,
136
+ isValidating: false,
137
+ error: null,
138
+ mutate: jest.fn(),
139
+ });
140
+
141
+ const user = userEvent.setup();
142
+ render(<BillableServices />);
143
+
144
+ const searchInput = screen.getByRole('searchbox');
145
+ await user.type(searchInput, 'Service 1');
146
+
147
+ expect(screen.getByText('Service 1')).toBeInTheDocument();
148
+ expect(screen.queryByText('Different Service')).not.toBeInTheDocument();
149
+ });
150
+
151
+ it('shows empty state message when search returns no results', async () => {
152
+ const mockServices = [
153
+ {
154
+ uuid: '1',
155
+ name: 'Service 1',
156
+ shortName: 'S1',
157
+ serviceType: { display: 'Type 1' },
158
+ serviceStatus: 'ACTIVE',
159
+ servicePrices: [{ name: 'Price 1', price: 100 }],
160
+ },
161
+ ];
162
+
163
+ mockedUseBillableServices.mockReturnValue({
164
+ billableServices: mockServices,
165
+ isLoading: false,
166
+ isValidating: false,
167
+ error: null,
168
+ mutate: jest.fn(),
169
+ });
170
+
171
+ const user = userEvent.setup();
172
+ render(<BillableServices />);
173
+
174
+ const searchInput = screen.getByRole('searchbox');
175
+ await user.type(searchInput, 'nonexistent service');
176
+
177
+ expect(screen.getByText('No matching services to display')).toBeInTheDocument();
178
+ expect(screen.getByText('Check the filters above')).toBeInTheDocument();
179
+ });
180
+ });
@@ -0,0 +1,276 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ TableContainer,
6
+ Table,
7
+ TableHead,
8
+ TableRow,
9
+ TableHeader,
10
+ TableBody,
11
+ TableCell,
12
+ Modal,
13
+ TextInput,
14
+ OverflowMenu,
15
+ OverflowMenuItem,
16
+ Dropdown,
17
+ } from '@carbon/react';
18
+ import { Add } from '@carbon/react/icons';
19
+ import { useTranslation } from 'react-i18next';
20
+ import { useForm, Controller } from 'react-hook-form';
21
+ import { z } from 'zod';
22
+ import { zodResolver } from '@hookform/resolvers/zod';
23
+ import { showSnackbar, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
24
+ import { CardHeader } from '@openmrs/esm-patient-common-lib';
25
+ import styles from './cash-point-configuration.scss';
26
+
27
+ // Validation schema
28
+ const cashPointSchema = z.object({
29
+ name: z.string().min(1, 'Cash Point Name is required'),
30
+ uuid: z
31
+ .string()
32
+ .min(1, 'UUID is required')
33
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 'Invalid UUID format'),
34
+ location: z.string().min(1, 'Location is required'),
35
+ });
36
+
37
+ type CashPointFormValues = z.infer<typeof cashPointSchema>;
38
+
39
+ const CashPointConfiguration: React.FC = () => {
40
+ const { t } = useTranslation();
41
+ const [cashPoints, setCashPoints] = useState([]);
42
+ const [locations, setLocations] = useState([]);
43
+ const [isModalOpen, setIsModalOpen] = useState(false);
44
+
45
+ const {
46
+ control,
47
+ handleSubmit,
48
+ reset,
49
+ formState: { errors, isSubmitting },
50
+ } = useForm<CashPointFormValues>({
51
+ resolver: zodResolver(cashPointSchema),
52
+ defaultValues: {
53
+ name: '',
54
+ uuid: '',
55
+ location: '',
56
+ },
57
+ });
58
+
59
+ const fetchCashPoints = useCallback(async () => {
60
+ try {
61
+ const response = await openmrsFetch(`${restBaseUrl}/billing/cashPoint?v=full`);
62
+ setCashPoints(response.data.results || []);
63
+ } catch (err) {
64
+ showSnackbar({
65
+ title: t('error', 'Error'),
66
+ subtitle: t('errorFetchingCashPoints', 'An error occurred while fetching cash points.'),
67
+ kind: 'error',
68
+ isLowContrast: false,
69
+ });
70
+ }
71
+ }, [t]);
72
+
73
+ const fetchLocations = useCallback(async () => {
74
+ try {
75
+ const response = await openmrsFetch(`${restBaseUrl}/location?v=default`);
76
+ const allLocations = response.data.results.map((loc) => ({
77
+ id: loc.uuid,
78
+ label: loc.display,
79
+ }));
80
+ setLocations(allLocations);
81
+ } catch (err) {
82
+ showSnackbar({
83
+ title: t('error', 'Error'),
84
+ subtitle: t('errorFetchingLocations', 'An error occurred while fetching locations.'),
85
+ kind: 'error',
86
+ isLowContrast: false,
87
+ });
88
+ }
89
+ }, [t]);
90
+
91
+ useEffect(() => {
92
+ fetchCashPoints();
93
+ fetchLocations();
94
+ }, [fetchCashPoints, fetchLocations]);
95
+
96
+ const onSubmit = async (data: CashPointFormValues) => {
97
+ const isDuplicate = cashPoints.some(
98
+ (point) => point.name.toLowerCase() === data.name.toLowerCase() || point.uuid === data.uuid,
99
+ );
100
+
101
+ if (isDuplicate) {
102
+ showSnackbar({
103
+ title: t('error', 'Error'),
104
+ subtitle: t(
105
+ 'duplicateCashPointError',
106
+ 'A cash point with the same name or UUID already exists. Please use a unique name and UUID.',
107
+ ),
108
+ kind: 'error',
109
+ isLowContrast: false,
110
+ });
111
+ return;
112
+ }
113
+
114
+ try {
115
+ const response = await openmrsFetch(`${restBaseUrl}/billing/cashPoint`, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ },
120
+ body: {
121
+ name: data.name,
122
+ uuid: data.uuid,
123
+ location: { uuid: data.location },
124
+ },
125
+ });
126
+
127
+ if (response.ok) {
128
+ showSnackbar({
129
+ title: t('success', 'Success'),
130
+ subtitle: t('cashPointSaved', 'Cash point was successfully saved.'),
131
+ kind: 'success',
132
+ });
133
+
134
+ setIsModalOpen(false);
135
+ reset({ name: '', uuid: '', location: '' });
136
+ fetchCashPoints();
137
+ } else {
138
+ const errorData = response.data || {};
139
+ showSnackbar({
140
+ title: t('error', 'Error'),
141
+ subtitle: errorData.message || t('errorSavingCashPoint', 'An error occurred while saving the cash point.'),
142
+ kind: 'error',
143
+ isLowContrast: false,
144
+ });
145
+ }
146
+ } catch (err) {
147
+ showSnackbar({
148
+ title: t('error', 'Error'),
149
+ subtitle: t('errorSavingCashPoint', 'An error occurred while saving the cash point.'),
150
+ kind: 'error',
151
+ isLowContrast: false,
152
+ });
153
+ }
154
+ };
155
+
156
+ const rowData = cashPoints.map((point) => ({
157
+ id: point.uuid,
158
+ name: point.name,
159
+ uuid: point.uuid,
160
+ location: point.location ? point.location.display : 'None',
161
+ }));
162
+
163
+ const headerData = [
164
+ { key: 'name', header: t('name', 'Name') },
165
+ { key: 'uuid', header: t('uuid', 'UUID') },
166
+ { key: 'location', header: t('location', 'Location') },
167
+ { key: 'actions', header: t('actions', 'Actions') },
168
+ ];
169
+
170
+ return (
171
+ <div className={styles.container}>
172
+ <div className={styles.card}>
173
+ <CardHeader title={t('cashPointHistory', 'Cash Point History')}>
174
+ <Button renderIcon={Add} onClick={() => setIsModalOpen(true)} kind="ghost">
175
+ {t('addCashPoint', 'Add New Cash Point')}
176
+ </Button>
177
+ </CardHeader>
178
+ <div className={styles.billHistoryContainer}>
179
+ <DataTable rows={rowData} headers={headerData} isSortable size="lg">
180
+ {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => (
181
+ <TableContainer>
182
+ <Table className={styles.table} {...getTableProps()}>
183
+ <TableHead>
184
+ <TableRow>
185
+ {headers.map((header) => (
186
+ <TableHeader key={header.key} {...getHeaderProps({ header })}>
187
+ {header.header}
188
+ </TableHeader>
189
+ ))}
190
+ </TableRow>
191
+ </TableHead>
192
+ <TableBody>
193
+ {rows.map((row) => (
194
+ <TableRow key={row.id} {...getRowProps({ row })}>
195
+ {row.cells.map((cell) =>
196
+ cell.info.header !== 'actions' ? (
197
+ <TableCell key={cell.id}>{cell.value}</TableCell>
198
+ ) : (
199
+ <TableCell key={cell.id}>
200
+ <OverflowMenu>
201
+ <OverflowMenuItem itemText={t('delete', 'Delete')} disabled />
202
+ </OverflowMenu>
203
+ </TableCell>
204
+ ),
205
+ )}
206
+ </TableRow>
207
+ ))}
208
+ </TableBody>
209
+ </Table>
210
+ </TableContainer>
211
+ )}
212
+ </DataTable>
213
+ </div>
214
+ </div>
215
+
216
+ {/* Modal for Adding New Cash Point */}
217
+ <Modal
218
+ open={isModalOpen}
219
+ modalHeading={t('addCashPoint', 'Add Cash Point')}
220
+ onRequestClose={() => setIsModalOpen(false)}
221
+ onRequestSubmit={handleSubmit(onSubmit)}
222
+ primaryButtonText={t('save', 'Save')}
223
+ secondaryButtonText={t('cancel', 'Cancel')}
224
+ isPrimaryButtonDisabled={isSubmitting}>
225
+ <form>
226
+ <Controller
227
+ name="name"
228
+ control={control}
229
+ render={({ field }) => (
230
+ <TextInput
231
+ id="cash-point-name"
232
+ labelText={t('cashPointName', 'Cash Point Name')}
233
+ placeholder={t('cashPointNamePlaceholder', 'e.g., Pharmacy Cash Point')}
234
+ invalid={!!errors.name}
235
+ invalidText={errors.name?.message}
236
+ {...field}
237
+ />
238
+ )}
239
+ />
240
+ <Controller
241
+ name="uuid"
242
+ control={control}
243
+ render={({ field }) => (
244
+ <TextInput
245
+ id="cash-point-uuid"
246
+ labelText={t('cashPointUuid', 'Cash Point UUID')}
247
+ placeholder={t('cashPointUuidPlaceholder', 'Enter UUID')}
248
+ invalid={!!errors.uuid}
249
+ invalidText={errors.uuid?.message}
250
+ {...field}
251
+ />
252
+ )}
253
+ />
254
+ <Controller
255
+ name="location"
256
+ control={control}
257
+ render={({ field }) => (
258
+ <Dropdown
259
+ id="cash-point-location"
260
+ label={t('location', 'Select Location')}
261
+ titleText={t('cashPointLocation', 'Cash Point Location')}
262
+ items={locations}
263
+ selectedItem={locations.find((loc) => loc.id === field.value)}
264
+ onChange={({ selectedItem }) => field.onChange(selectedItem?.id)}
265
+ invalid={!!errors.location}
266
+ invalidText={errors.location?.message}
267
+ />
268
+ )}
269
+ />
270
+ </form>
271
+ </Modal>
272
+ </div>
273
+ );
274
+ };
275
+
276
+ export default CashPointConfiguration;
@@ -0,0 +1,23 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .container {
6
+ padding: layout.$spacing-05;
7
+ }
8
+
9
+ .card {
10
+ width: 100%;
11
+ max-width: 1200px;
12
+ margin: 0 auto;
13
+ padding: layout.$spacing-05;
14
+ }
15
+
16
+ .billHistoryContainer {
17
+ margin-top: layout.$spacing-05;
18
+ }
19
+
20
+ .table {
21
+ width: 100%;
22
+ table-layout: auto;
23
+ }