@digihmis/esm-billing-app 1.0.0
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.
- package/README.md +4 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/132.js +1 -0
- package/dist/132.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/16.js +1 -0
- package/dist/16.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/317.js +1 -0
- package/dist/317.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/358.js +1 -0
- package/dist/358.js.map +1 -0
- package/dist/371.js +1 -0
- package/dist/371.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/45.js +1 -0
- package/dist/45.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/496.js +12 -0
- package/dist/496.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/640.js +1 -0
- package/dist/640.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/709.js +1 -0
- package/dist/709.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/742.js +1 -0
- package/dist/742.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/806.js.map +1 -0
- package/dist/839.js +1 -0
- package/dist/839.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/940.js +1 -0
- package/dist/940.js.map +1 -0
- package/dist/965.js +15 -0
- package/dist/965.js.map +1 -0
- package/dist/digihmis-esm-billing-app.js +6 -0
- package/dist/digihmis-esm-billing-app.js.buildmanifest.json +827 -0
- package/dist/digihmis-esm-billing-app.js.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/billing-checkin-form/billing-checkin-form-schema/index.ts +11 -0
- package/src/billing-checkin-form/billing-checkin-form.extension.tsx +179 -0
- package/src/billing-checkin-form/billing-checkin-form.resource.ts +42 -0
- package/src/billing-checkin-form/billing-checkin-form.scss +82 -0
- package/src/billing-checkin-form/billing-patient-type-tag/billing-patient-type-tag.component.tsx +39 -0
- package/src/billing-checkin-form/billing-patient-type-tag/billing-patient-type-tag.resource.ts +57 -0
- package/src/billing-checkin-form/billing-patient-type-tag/billing-patient-type-tag.scss +4 -0
- package/src/billing-checkin-form/helper/index.ts +2 -0
- package/src/billing-checkin-form/type/index.ts +42 -0
- package/src/billing-checkin-form/visit-attributes/visit-attributes-form.component.tsx +244 -0
- package/src/billing-checkin-form/visit-attributes/visit-attributes-form.resource.ts +23 -0
- package/src/billing-checkin-form/visit-attributes/visit-attributes-form.scss +186 -0
- package/src/billing-dashboard/billing-dashboard.component.tsx +224 -0
- package/src/billing-dashboard/billing-dashboard.resource.ts +101 -0
- package/src/billing-dashboard/billing-dashboard.scss +166 -0
- package/src/billing-dashboard/metrics/metrics.resource.ts +142 -0
- package/src/billing-dashboard/type/index.ts +176 -0
- package/src/billing-header/page-header/page-header.component.tsx +35 -0
- package/src/billing-header/page-header/page-header.scss +76 -0
- package/src/billing-header/page-header/page-icon/icon-wrapper.extension.tsx +17 -0
- package/src/billing-timesheet-modal/billing-timesheet-modal.scss +56 -0
- package/src/billing-timesheet-modal/billing-timesheet-modal.tsx +281 -0
- package/src/billing-timesheet-modal/billing-timesheet-resource-modal.ts +37 -0
- package/src/bills-table/bills-table.component.tsx +323 -0
- package/src/bills-table/bills-table.scss +252 -0
- package/src/config-schema.ts +148 -0
- package/src/constants/index.ts +2 -0
- package/src/dashboard.meta.ts +16 -0
- package/src/declarations.d.ts +6 -0
- package/src/index.ts +37 -0
- package/src/leftpanel/left-panel.component.tsx +98 -0
- package/src/leftpanel/left-panel.scss +13 -0
- package/src/routes.json +58 -0
- package/src/setup-tests.ts +1 -0
- package/src/utils/index.ts +270 -0
- package/translations/am.json +18 -0
- package/translations/ar.json +18 -0
- package/translations/en.json +18 -0
- package/translations/es.json +18 -0
- package/translations/fr.json +18 -0
- package/translations/he.json +18 -0
- package/translations/km.json +18 -0
- package/translations/pt.json +18 -0
- package/translations/sw.json +18 -0
- package/translations/vi.json +18 -0
- package/translations/zh.json +18 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@carbon/colors';
|
|
4
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
5
|
+
|
|
6
|
+
.patientHeader {
|
|
7
|
+
padding: layout.$spacing-05;
|
|
8
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
9
|
+
background-color: colors.$gray-10;
|
|
10
|
+
|
|
11
|
+
.patientName {
|
|
12
|
+
@include type.type-style('heading-compact-01');
|
|
13
|
+
font-weight: 600;
|
|
14
|
+
margin-right: layout.$spacing-03;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.patientId {
|
|
18
|
+
@include type.type-style('body-compact-01');
|
|
19
|
+
font-weight: 600;
|
|
20
|
+
margin-right: layout.$spacing-02;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.patientDetails {
|
|
24
|
+
@include type.type-style('body-compact-01');
|
|
25
|
+
color: colors.$gray-70;
|
|
26
|
+
}
|
|
27
|
+
.currentLocation {
|
|
28
|
+
@include type.type-style('body-compact-01');
|
|
29
|
+
color: red;
|
|
30
|
+
float: right;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
:global(.cds--select-input) {
|
|
34
|
+
background-color: colors.$gray-20;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@media (min-width: 82rem) {
|
|
38
|
+
:global(.cds--modal-container) {
|
|
39
|
+
width: 38%;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.providerInfo {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: layout.$spacing-03;
|
|
47
|
+
background-color: $ui-01;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.providerLabel {
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.providerName {
|
|
55
|
+
margin: 0;
|
|
56
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
ModalFooter,
|
|
4
|
+
ModalHeader,
|
|
5
|
+
ModalBody,
|
|
6
|
+
InlineLoading,
|
|
7
|
+
Select,
|
|
8
|
+
SelectItem,
|
|
9
|
+
InlineNotification,
|
|
10
|
+
} from '@carbon/react';
|
|
11
|
+
import React, { useState } from 'react';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
import { showSnackbar, useSession } from '@openmrs/esm-framework';
|
|
14
|
+
import { Logout } from '@carbon/react/icons';
|
|
15
|
+
import styles from './billing-timesheet-modal.scss';
|
|
16
|
+
import { useCashPoints, useCurrentTimesheet } from '../billing-dashboard/billing-dashboard.resource';
|
|
17
|
+
import { clockIn, clockOut } from './billing-timesheet-resource-modal';
|
|
18
|
+
|
|
19
|
+
interface BillingTimesheetModalProps {
|
|
20
|
+
closeModal?: () => void;
|
|
21
|
+
onClockInSuccess?: (sessionData: any) => void;
|
|
22
|
+
onClockOutSuccess?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const BillingTimesheetModal: React.FC<BillingTimesheetModalProps> = ({
|
|
26
|
+
closeModal,
|
|
27
|
+
onClockInSuccess,
|
|
28
|
+
onClockOutSuccess,
|
|
29
|
+
}) => {
|
|
30
|
+
const { t } = useTranslation();
|
|
31
|
+
const session = useSession();
|
|
32
|
+
const currentProvider = session?.currentProvider;
|
|
33
|
+
const providerUuid = currentProvider?.uuid;
|
|
34
|
+
|
|
35
|
+
const { activeTimesheet, isClockedIn, mutate: mutateTimesheet } = useCurrentTimesheet();
|
|
36
|
+
const { cashPoints, isLoading: loadingCashPoints, error: cashPointsError } = useCashPoints();
|
|
37
|
+
|
|
38
|
+
const [selectedCashPoint, setSelectedCashPoint] = useState('');
|
|
39
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
40
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
41
|
+
|
|
42
|
+
const handleCashPointChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
43
|
+
setSelectedCashPoint(event.target.value);
|
|
44
|
+
setErrorMessage('');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleClockIn = async () => {
|
|
48
|
+
if (!selectedCashPoint) {
|
|
49
|
+
setErrorMessage(t('selectCashPointRequired', 'Please select a cash point'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!providerUuid) {
|
|
54
|
+
setErrorMessage(t('providerNotFound', 'Provider information not found'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setErrorMessage('');
|
|
59
|
+
setIsSubmitting(true);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await clockIn(providerUuid, selectedCashPoint);
|
|
63
|
+
await mutateTimesheet();
|
|
64
|
+
|
|
65
|
+
showSnackbar({
|
|
66
|
+
title: t('clockedIn', 'Clocked In'),
|
|
67
|
+
kind: 'success',
|
|
68
|
+
subtitle: t('successfullyClocked', 'You have successfully clocked in'),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (onClockInSuccess) {
|
|
72
|
+
onClockInSuccess(response?.data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
closeModal?.();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const errorMsg =
|
|
78
|
+
error?.responseBody?.error?.message || t('clockInFailed', 'Failed to clock in. Please try again.');
|
|
79
|
+
setErrorMessage(errorMsg);
|
|
80
|
+
|
|
81
|
+
showSnackbar({
|
|
82
|
+
title: t('clockInError', 'Clock In Error'),
|
|
83
|
+
kind: 'error',
|
|
84
|
+
subtitle: errorMsg,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
console.error('Clock in error:', error);
|
|
88
|
+
} finally {
|
|
89
|
+
setIsSubmitting(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleSwitchCashPoint = async () => {
|
|
94
|
+
if (!selectedCashPoint) {
|
|
95
|
+
setErrorMessage(t('selectCashPointRequired', 'Please select a cash point'));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!activeTimesheet?.uuid) {
|
|
100
|
+
setErrorMessage(t('noActiveSession', 'No active session found'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!providerUuid) {
|
|
105
|
+
setErrorMessage(t('providerNotFound', 'Provider information not found'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setErrorMessage('');
|
|
110
|
+
setIsSubmitting(true);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Clock out from current cash point
|
|
114
|
+
await clockOut(activeTimesheet.uuid);
|
|
115
|
+
|
|
116
|
+
// Clock in to new cash point
|
|
117
|
+
const response = await clockIn(providerUuid, selectedCashPoint);
|
|
118
|
+
await mutateTimesheet();
|
|
119
|
+
|
|
120
|
+
showSnackbar({
|
|
121
|
+
title: t('switchedCashPoint', 'Cash Point Switched'),
|
|
122
|
+
kind: 'success',
|
|
123
|
+
subtitle: t('successfullySwitched', 'You have successfully switched cash points'),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (onClockInSuccess) {
|
|
127
|
+
onClockInSuccess(response?.data);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
closeModal?.();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const errorMsg =
|
|
133
|
+
error?.responseBody?.error?.message || t('switchFailed', 'Failed to switch cash points. Please try again.');
|
|
134
|
+
setErrorMessage(errorMsg);
|
|
135
|
+
|
|
136
|
+
showSnackbar({
|
|
137
|
+
title: t('switchError', 'Switch Error'),
|
|
138
|
+
kind: 'error',
|
|
139
|
+
subtitle: errorMsg,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.error('Switch cash point error:', error);
|
|
143
|
+
} finally {
|
|
144
|
+
setIsSubmitting(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Handle loading and error states
|
|
149
|
+
if (cashPointsError) {
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<ModalHeader closeModal={closeModal} title={t('billingSession', 'Billing Session')} />
|
|
153
|
+
<ModalBody>
|
|
154
|
+
<InlineNotification
|
|
155
|
+
kind="error"
|
|
156
|
+
title={t('error', 'Error')}
|
|
157
|
+
subtitle={t('errorLoadingCashPoints', 'Error loading cash points. Please try again.')}
|
|
158
|
+
lowContrast
|
|
159
|
+
hideCloseButton
|
|
160
|
+
/>
|
|
161
|
+
</ModalBody>
|
|
162
|
+
<ModalFooter>
|
|
163
|
+
<Button kind="secondary" onClick={closeModal} type="button">
|
|
164
|
+
{t('close', 'Close')}
|
|
165
|
+
</Button>
|
|
166
|
+
</ModalFooter>
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<>
|
|
173
|
+
<ModalHeader closeModal={closeModal}>
|
|
174
|
+
<div className={styles.patientHeader}>
|
|
175
|
+
<span className={styles.patientName}>
|
|
176
|
+
{isClockedIn ? t('switchCashPoint', 'Switch Cash Point') : t('clockInToBilling', 'Clock In to Billing')}
|
|
177
|
+
</span>
|
|
178
|
+
{isClockedIn && activeTimesheet && (
|
|
179
|
+
<span className={styles.currentLocation}>
|
|
180
|
+
·{' '}
|
|
181
|
+
{t('currentlyActiveAt', 'Currently active at {{location}}', {
|
|
182
|
+
location: activeTimesheet.cashPoint?.name || t('unknown', 'Unknown'),
|
|
183
|
+
})}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</ModalHeader>
|
|
188
|
+
|
|
189
|
+
<ModalBody>
|
|
190
|
+
{errorMessage && (
|
|
191
|
+
<InlineNotification
|
|
192
|
+
kind="error"
|
|
193
|
+
title={t('error', 'Error')}
|
|
194
|
+
subtitle={errorMessage}
|
|
195
|
+
lowContrast
|
|
196
|
+
hideCloseButton
|
|
197
|
+
className={styles.errorNotification}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{loadingCashPoints ? (
|
|
202
|
+
<InlineLoading description={t('loadingCashPoints', 'Loading cash points...')} />
|
|
203
|
+
) : (
|
|
204
|
+
<div className={styles.modalContent}>
|
|
205
|
+
{isClockedIn && activeTimesheet && (
|
|
206
|
+
<div
|
|
207
|
+
style={{
|
|
208
|
+
marginBottom: '1rem',
|
|
209
|
+
padding: '0.75rem',
|
|
210
|
+
backgroundColor: '#fff3cd',
|
|
211
|
+
borderLeft: '4px solid #ffc107',
|
|
212
|
+
borderRadius: '4px',
|
|
213
|
+
}}>
|
|
214
|
+
<p style={{ margin: 0, fontSize: '0.875rem' }}>
|
|
215
|
+
<strong>{t('switchingCashPoints', 'Switching Cash Points')}</strong>
|
|
216
|
+
</p>
|
|
217
|
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.875rem' }}>
|
|
218
|
+
{t(
|
|
219
|
+
'willAutoTimesheetClockOut',
|
|
220
|
+
'You will be automatically clocked out from {{current}} and clocked in to your selected cash point.',
|
|
221
|
+
{
|
|
222
|
+
current: activeTimesheet?.cashPoint?.name,
|
|
223
|
+
},
|
|
224
|
+
)}
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<Select
|
|
230
|
+
id="cashPoint"
|
|
231
|
+
labelText={
|
|
232
|
+
isClockedIn
|
|
233
|
+
? t('selectCashPointToSwitch', 'Select a cash point to switch to:')
|
|
234
|
+
: t('selectCashPointToClockIn', 'Select a cash point to clock in:')
|
|
235
|
+
}
|
|
236
|
+
value={selectedCashPoint}
|
|
237
|
+
onChange={handleCashPointChange}
|
|
238
|
+
disabled={isSubmitting}
|
|
239
|
+
className={styles.cashPointSelect}>
|
|
240
|
+
<SelectItem value="" text={t('chooseCashPoint', 'Choose a cash point')} />
|
|
241
|
+
{cashPoints?.map((cashPoint) => (
|
|
242
|
+
<SelectItem key={cashPoint.uuid} value={cashPoint.uuid} text={`${cashPoint.name}`} />
|
|
243
|
+
))}
|
|
244
|
+
</Select>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</ModalBody>
|
|
248
|
+
|
|
249
|
+
<ModalFooter>
|
|
250
|
+
<Button kind="secondary" onClick={closeModal} type="button" disabled={isSubmitting}>
|
|
251
|
+
{t('cancel', 'Cancel')}
|
|
252
|
+
</Button>
|
|
253
|
+
|
|
254
|
+
{isClockedIn ? (
|
|
255
|
+
<Button
|
|
256
|
+
onClick={handleSwitchCashPoint}
|
|
257
|
+
type="button"
|
|
258
|
+
disabled={isSubmitting || !selectedCashPoint || loadingCashPoints}
|
|
259
|
+
renderIcon={Logout}>
|
|
260
|
+
{isSubmitting ? (
|
|
261
|
+
<InlineLoading description={t('switchingCashPoint', 'Switching cash point...')} />
|
|
262
|
+
) : (
|
|
263
|
+
t('switchCashPoint', 'Switch Cash Point')
|
|
264
|
+
)}
|
|
265
|
+
</Button>
|
|
266
|
+
) : (
|
|
267
|
+
<Button
|
|
268
|
+
onClick={handleClockIn}
|
|
269
|
+
type="button"
|
|
270
|
+
disabled={isSubmitting || !selectedCashPoint || loadingCashPoints}>
|
|
271
|
+
{isSubmitting ? (
|
|
272
|
+
<InlineLoading description={t('clockingIn', 'Clocking in...')} />
|
|
273
|
+
) : (
|
|
274
|
+
t('clockIn', 'Clock In')
|
|
275
|
+
)}
|
|
276
|
+
</Button>
|
|
277
|
+
)}
|
|
278
|
+
</ModalFooter>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Clock in a cashier to a cash point
|
|
5
|
+
*/
|
|
6
|
+
export const clockIn = async (cashierUuid: string, cashPointUuid: string) => {
|
|
7
|
+
const url = `${restBaseUrl}/cashier/timesheet`;
|
|
8
|
+
|
|
9
|
+
return openmrsFetch(url, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
'Content-Type': 'application/json',
|
|
13
|
+
},
|
|
14
|
+
body: {
|
|
15
|
+
cashier: cashierUuid,
|
|
16
|
+
cashPoint: cashPointUuid,
|
|
17
|
+
clockIn: new Date().toISOString(),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clock out the current cashier (close the timesheet)
|
|
24
|
+
*/
|
|
25
|
+
export const clockOut = async (timesheetUuid: string) => {
|
|
26
|
+
const url = `${restBaseUrl}/cashier/timesheet/${timesheetUuid}`;
|
|
27
|
+
|
|
28
|
+
return openmrsFetch(url, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
},
|
|
33
|
+
body: {
|
|
34
|
+
clockOut: new Date().toISOString(),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import React, { useCallback, useId, useMemo, useState } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import {
|
|
5
|
+
DataTable,
|
|
6
|
+
DataTableSkeleton,
|
|
7
|
+
Dropdown,
|
|
8
|
+
InlineLoading,
|
|
9
|
+
Layer,
|
|
10
|
+
Pagination,
|
|
11
|
+
Search,
|
|
12
|
+
Table,
|
|
13
|
+
TableBody,
|
|
14
|
+
TableCell,
|
|
15
|
+
TableContainer,
|
|
16
|
+
TableHead,
|
|
17
|
+
TableHeader,
|
|
18
|
+
TableRow,
|
|
19
|
+
Tile,
|
|
20
|
+
} from '@carbon/react';
|
|
21
|
+
import { useTranslation } from 'react-i18next';
|
|
22
|
+
import {
|
|
23
|
+
useLayoutType,
|
|
24
|
+
isDesktop,
|
|
25
|
+
useConfig,
|
|
26
|
+
usePagination,
|
|
27
|
+
ErrorState,
|
|
28
|
+
ConfigurableLink,
|
|
29
|
+
} from '@openmrs/esm-framework';
|
|
30
|
+
import { EmptyDataIllustration } from '@openmrs/esm-patient-common-lib';
|
|
31
|
+
import styles from './bills-table.scss';
|
|
32
|
+
import { useBills } from '../billing-dashboard/metrics/metrics.resource';
|
|
33
|
+
import { extractString, useCurrencyFormatting } from '../utils';
|
|
34
|
+
|
|
35
|
+
const filterItems = [
|
|
36
|
+
{ id: '', text: 'All bills' },
|
|
37
|
+
{ id: 'PENDING', text: 'Pending bills' },
|
|
38
|
+
{ id: 'PAID', text: 'Paid bills' },
|
|
39
|
+
// { id: 'EXEMPTED', text: 'Exempted bills' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
type BillTableProps = {
|
|
43
|
+
defaultBillPaymentStatus?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const BillsTable: React.FC<BillTableProps> = ({ defaultBillPaymentStatus = '' }) => {
|
|
47
|
+
const { t } = useTranslation();
|
|
48
|
+
const id = useId();
|
|
49
|
+
const config = useConfig();
|
|
50
|
+
const layout = useLayoutType();
|
|
51
|
+
const responsiveSize = isDesktop(layout) ? 'sm' : 'lg';
|
|
52
|
+
const [billPaymentStatus, setBillPaymentStatus] = useState(defaultBillPaymentStatus);
|
|
53
|
+
const pageSizes = config?.bills?.pageSizes ?? [10, 20, 30, 40, 50];
|
|
54
|
+
const [pageSize, setPageSize] = useState(config?.bills?.pageSize ?? 10);
|
|
55
|
+
const [searchString, setSearchString] = useState('');
|
|
56
|
+
const { format: formatCurrency } = useCurrencyFormatting();
|
|
57
|
+
|
|
58
|
+
const { bills, isLoading, isValidating, error } = useBills(
|
|
59
|
+
'',
|
|
60
|
+
billPaymentStatus,
|
|
61
|
+
dayjs().startOf('day').toDate(),
|
|
62
|
+
dayjs().endOf('day').toDate(),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const headerData = [
|
|
66
|
+
{
|
|
67
|
+
header: t('visitTime', 'Visit time'),
|
|
68
|
+
key: 'visitTime',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
header: t('identifier', 'Identifier'),
|
|
72
|
+
key: 'identifier',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
header: t('name', 'Name'),
|
|
76
|
+
key: 'patientName',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
header: t('billedItems', 'Billed Items'),
|
|
80
|
+
key: 'billedItems',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
header: t('totalAmount', 'Total Amount'),
|
|
84
|
+
key: 'totalAmount',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
header: t('status', 'Status'),
|
|
88
|
+
key: 'status',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const searchResults = useMemo(() => {
|
|
93
|
+
if (bills !== undefined && bills.length > 0) {
|
|
94
|
+
if (searchString && searchString.trim() !== '') {
|
|
95
|
+
const search = searchString.toLowerCase();
|
|
96
|
+
return bills?.filter((activeBillRow) =>
|
|
97
|
+
Object.entries(activeBillRow).some(([header, value]) => {
|
|
98
|
+
if (header === 'patientUuid') {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return `${value}`.toLowerCase().includes(search);
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return bills;
|
|
108
|
+
}, [searchString, bills]);
|
|
109
|
+
|
|
110
|
+
const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize);
|
|
111
|
+
|
|
112
|
+
// Improved function using extractString utility
|
|
113
|
+
const setBilledItems = (bill) => {
|
|
114
|
+
if (!bill?.lineItems || bill.lineItems.length === 0) {
|
|
115
|
+
return '--';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return bill.lineItems
|
|
119
|
+
.map((item) => extractString(item?.billableService || item?.item || ''))
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join(' & ');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const rowData = results?.map((bill, index) => ({
|
|
125
|
+
id: `${index}`,
|
|
126
|
+
uuid: bill.uuid,
|
|
127
|
+
patientName: (
|
|
128
|
+
<ConfigurableLink
|
|
129
|
+
style={{ textDecoration: 'none', maxWidth: '50%' }}
|
|
130
|
+
to={`\${openmrsSpaBase}/home/accounting/patient/\${patientUuid}/\${uuid}`}
|
|
131
|
+
templateParams={{ patientUuid: bill.patientUuid, uuid: bill.uuid }}>
|
|
132
|
+
{bill.patientName}
|
|
133
|
+
</ConfigurableLink>
|
|
134
|
+
),
|
|
135
|
+
visitTime: bill.dateCreated,
|
|
136
|
+
identifier: bill.identifier,
|
|
137
|
+
billedItems: setBilledItems(bill),
|
|
138
|
+
totalAmount: formatCurrency(bill.totalAmount || 0),
|
|
139
|
+
status: (
|
|
140
|
+
<span
|
|
141
|
+
className={classNames(styles.statusBadge, {
|
|
142
|
+
[styles.statusPaid]: bill.status === 'PAID',
|
|
143
|
+
[styles.statusPending]: bill.status === 'PENDING',
|
|
144
|
+
[styles.statusExempted]: bill.status === 'EXEMPTED',
|
|
145
|
+
})}>
|
|
146
|
+
{bill.status}
|
|
147
|
+
</span>
|
|
148
|
+
),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const handleSearch = useCallback(
|
|
152
|
+
(e) => {
|
|
153
|
+
goTo(1);
|
|
154
|
+
setSearchString(e.target.value);
|
|
155
|
+
},
|
|
156
|
+
[goTo, setSearchString],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const handleFilterChange = ({ selectedItem }) => setBillPaymentStatus(selectedItem.id);
|
|
160
|
+
|
|
161
|
+
if (isLoading) {
|
|
162
|
+
return (
|
|
163
|
+
<div className={styles.loaderContainer}>
|
|
164
|
+
<DataTableSkeleton
|
|
165
|
+
rowCount={pageSize}
|
|
166
|
+
showHeader={false}
|
|
167
|
+
showToolbar={false}
|
|
168
|
+
zebra
|
|
169
|
+
columnCount={headerData?.length}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (error) {
|
|
176
|
+
return (
|
|
177
|
+
<div className={styles.errorContainer}>
|
|
178
|
+
<Layer>
|
|
179
|
+
<ErrorState error={error} headerTitle={t('billsList', 'Bill list')} />
|
|
180
|
+
</Layer>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
<div className={styles.filterContainer}>
|
|
188
|
+
{bills?.length > 0 ? (
|
|
189
|
+
<Dropdown
|
|
190
|
+
className={styles.filterDropdown}
|
|
191
|
+
direction="bottom"
|
|
192
|
+
id={`filter-${id}`}
|
|
193
|
+
initialSelectedItem={filterItems.find((item) => item.id === billPaymentStatus)}
|
|
194
|
+
items={filterItems}
|
|
195
|
+
itemToString={(item) => (item ? item.text : '')}
|
|
196
|
+
label=""
|
|
197
|
+
onChange={handleFilterChange}
|
|
198
|
+
size={responsiveSize}
|
|
199
|
+
titleText={t('filterBy', 'Filter by') + ':'}
|
|
200
|
+
type="inline"
|
|
201
|
+
/>
|
|
202
|
+
) : null}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{bills?.length > 0 ? (
|
|
206
|
+
<div className={styles.billListContainer}>
|
|
207
|
+
<FilterableTableHeader
|
|
208
|
+
handleSearch={handleSearch}
|
|
209
|
+
isValidating={isValidating}
|
|
210
|
+
layout={layout}
|
|
211
|
+
responsiveSize={responsiveSize}
|
|
212
|
+
t={t}
|
|
213
|
+
/>
|
|
214
|
+
<DataTable
|
|
215
|
+
isSortable
|
|
216
|
+
rows={rowData}
|
|
217
|
+
headers={headerData}
|
|
218
|
+
size={responsiveSize}
|
|
219
|
+
useZebraStyles={rowData?.length > 1 ? true : false}>
|
|
220
|
+
{({ rows, headers, getRowProps, getTableProps }) => (
|
|
221
|
+
<TableContainer>
|
|
222
|
+
<Table {...getTableProps()} aria-label="bill list">
|
|
223
|
+
<TableHead>
|
|
224
|
+
<TableRow>
|
|
225
|
+
{headers.map((header) => (
|
|
226
|
+
<TableHeader key={header.key}>{header.header}</TableHeader>
|
|
227
|
+
))}
|
|
228
|
+
</TableRow>
|
|
229
|
+
</TableHead>
|
|
230
|
+
<TableBody>
|
|
231
|
+
{rows.map((row) => (
|
|
232
|
+
<TableRow
|
|
233
|
+
key={row.id}
|
|
234
|
+
{...getRowProps({
|
|
235
|
+
row,
|
|
236
|
+
})}>
|
|
237
|
+
{row.cells.map((cell) => (
|
|
238
|
+
<TableCell key={cell.id}>{cell.value}</TableCell>
|
|
239
|
+
))}
|
|
240
|
+
</TableRow>
|
|
241
|
+
))}
|
|
242
|
+
</TableBody>
|
|
243
|
+
</Table>
|
|
244
|
+
</TableContainer>
|
|
245
|
+
)}
|
|
246
|
+
</DataTable>
|
|
247
|
+
{searchResults?.length === 0 && (
|
|
248
|
+
<>
|
|
249
|
+
<div className={styles.emptyStateContainer}>
|
|
250
|
+
<Tile className={styles.tile}>
|
|
251
|
+
<div className={styles.tileContent}>
|
|
252
|
+
<p className={styles.content}>{t('noMatchingBillsToDisplay', 'No matching bills to display')}</p>
|
|
253
|
+
<p className={styles.emptyStateHelperText}>{t('checkFilters', 'Check the filters above')}</p>
|
|
254
|
+
</div>
|
|
255
|
+
</Tile>
|
|
256
|
+
</div>
|
|
257
|
+
</>
|
|
258
|
+
)}
|
|
259
|
+
{paginated && (
|
|
260
|
+
<Pagination
|
|
261
|
+
forwardText="Next page"
|
|
262
|
+
backwardText="Previous page"
|
|
263
|
+
page={currentPage}
|
|
264
|
+
pageSize={pageSize}
|
|
265
|
+
pageSizes={pageSizes}
|
|
266
|
+
totalItems={searchResults?.length}
|
|
267
|
+
className={styles.pagination}
|
|
268
|
+
size={responsiveSize}
|
|
269
|
+
onChange={({ pageSize: newPageSize, page: newPage }) => {
|
|
270
|
+
if (newPageSize !== pageSize) {
|
|
271
|
+
setPageSize(newPageSize);
|
|
272
|
+
}
|
|
273
|
+
if (newPage !== currentPage) {
|
|
274
|
+
goTo(newPage);
|
|
275
|
+
}
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
) : (
|
|
281
|
+
<>
|
|
282
|
+
<div className={styles.emptyStateContainer}>
|
|
283
|
+
<Tile className={styles.tile}>
|
|
284
|
+
<div className={styles.tileContent}>
|
|
285
|
+
<p className={styles.content}> {t('noBillsAvailable', 'No bills available')}</p>
|
|
286
|
+
<p className={styles.emptyStateHelperText}>
|
|
287
|
+
{t('noBillsToDisplay', 'There are currently no bills to display.')}
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
</Tile>
|
|
291
|
+
</div>
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
</>
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function FilterableTableHeader({ layout, handleSearch, isValidating, responsiveSize, t }) {
|
|
299
|
+
return (
|
|
300
|
+
<>
|
|
301
|
+
<div className={styles.headerContainer}>
|
|
302
|
+
<div
|
|
303
|
+
className={classNames({
|
|
304
|
+
[styles.tabletHeading]: !isDesktop(layout),
|
|
305
|
+
[styles.desktopHeading]: isDesktop(layout),
|
|
306
|
+
})}>
|
|
307
|
+
<h4>{t('billList', 'Bill list')}</h4>
|
|
308
|
+
</div>
|
|
309
|
+
<div className={styles.backgroundDataFetchingIndicator}>
|
|
310
|
+
<span>{isValidating ? <InlineLoading /> : null}</span>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
<Search
|
|
314
|
+
labelText=""
|
|
315
|
+
placeholder={t('filterTable', 'Filter table')}
|
|
316
|
+
onChange={handleSearch}
|
|
317
|
+
size={responsiveSize}
|
|
318
|
+
/>
|
|
319
|
+
</>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export default BillsTable;
|