@kenyaemr/esm-billing-app 5.4.2-pre.2301 → 5.4.2-pre.2306

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.
@@ -4,6 +4,7 @@ import {
4
4
  defaultVisitCustomRepresentation,
5
5
  ExtensionSlot,
6
6
  formatDatetime,
7
+ launchWorkspace,
7
8
  navigate,
8
9
  parseDate,
9
10
  restBaseUrl,
@@ -172,7 +173,7 @@ const Invoice: React.FC = () => {
172
173
  <div className={styles.actionArea}>
173
174
  <Button
174
175
  onClick={handleBillPayment}
175
- disabled={bill?.status === 'PAID'}
176
+ disabled={bill?.balance === 0}
176
177
  size="sm"
177
178
  renderIcon={Wallet}
178
179
  iconDescription="Add"
@@ -257,21 +258,19 @@ export function InvoiceSummary({ bill }: { readonly bill: MappedBill }) {
257
258
  renderIcon={Printer}>
258
259
  {t('printInvoice', 'Print Invoice')}
259
260
  </Button>
260
- {bill.balance === 0 && (
261
- <Button
262
- kind="ghost"
263
- size="sm"
264
- onClick={() => {
265
- const dispose = showModal('print-preview-modal', {
266
- onClose: () => dispose(),
267
- title: `${t('receipt', 'Receipt')} ${bill?.receiptNumber} - ${startCase(bill?.patientName)}`,
268
- documentUrl: `/openmrs${restBaseUrl}/cashier/receipt?billId=${bill.id}`,
269
- });
270
- }}
271
- renderIcon={Printer}>
272
- {t('printReceipt', 'Print Receipt')}
273
- </Button>
274
- )}
261
+ <Button
262
+ kind="ghost"
263
+ size="sm"
264
+ onClick={() => {
265
+ const dispose = showModal('print-preview-modal', {
266
+ onClose: () => dispose(),
267
+ title: `${t('receipt', 'Receipt')} ${bill?.receiptNumber} - ${startCase(bill?.patientName)}`,
268
+ documentUrl: `/openmrs${restBaseUrl}/cashier/receipt?billId=${bill.id}`,
269
+ });
270
+ }}
271
+ renderIcon={Printer}>
272
+ {t('printReceipt', 'Print Receipt')}
273
+ </Button>
275
274
  <Button
276
275
  kind="ghost"
277
276
  size="sm"
@@ -315,6 +314,22 @@ export function InvoiceSummary({ bill }: { readonly bill: MappedBill }) {
315
314
  </Button>
316
315
  </UserHasAccess>
317
316
  )}
317
+ <Button
318
+ kind="ghost"
319
+ size="sm"
320
+ renderIcon={Wallet}
321
+ iconDescription="Add"
322
+ tooltipPosition="right"
323
+ onClick={() =>
324
+ launchWorkspace('payment-workspace', {
325
+ bill,
326
+ workspaceTitle: t('additionalPayment', 'Additional Payment (Balance {{billBalance}})', {
327
+ billBalance: convertToCurrency(bill.balance),
328
+ }),
329
+ })
330
+ }>
331
+ {t('additionalPayment', 'Additional Payment')}
332
+ </Button>
318
333
  </div>
319
334
  </div>
320
335
  <div className={styles.invoiceSummaryContainer}>
@@ -0,0 +1,33 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .form {
6
+ display: flex;
7
+ flex-direction: column;
8
+ justify-content: space-between;
9
+ height: 100%;
10
+ }
11
+
12
+ .formContainer {
13
+ margin: layout.$spacing-05;
14
+ }
15
+
16
+ .tablet {
17
+ padding: layout.$spacing-06 layout.$spacing-05;
18
+ background-color: colors.$white;
19
+ }
20
+
21
+ .desktop {
22
+ padding: 0;
23
+ }
24
+
25
+ .paymentMethods {
26
+ display: flex;
27
+ flex-direction: column;
28
+ row-gap: layout.$spacing-05;
29
+ }
30
+
31
+ .formStackControl {
32
+ row-gap: layout.$layout-01;
33
+ }
@@ -0,0 +1,195 @@
1
+ import React, { useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { MappedBill } from '../../../types';
4
+ import styles from './payment.scss';
5
+ import { Stack, TextInput, Button, ButtonSet, InlineLoading, Dropdown } from '@carbon/react';
6
+ import {
7
+ DefaultWorkspaceProps,
8
+ ResponsiveWrapper,
9
+ showNotification,
10
+ showSnackbar,
11
+ useLayoutType,
12
+ } from '@openmrs/esm-framework';
13
+ import classNames from 'classnames';
14
+ import { Controller } from 'react-hook-form';
15
+ import { addPaymentToBill, usePaymentModes } from '../../../billing.resource';
16
+ import { usePaymentForm } from './use-payment-form';
17
+ import { z } from 'zod';
18
+ import { mutate } from 'swr';
19
+
20
+ type PaymentWorkspaceProps = DefaultWorkspaceProps & {
21
+ bill: MappedBill;
22
+ };
23
+
24
+ const PaymentWorkspace: React.FC<PaymentWorkspaceProps> = ({
25
+ bill,
26
+ closeWorkspace,
27
+ promptBeforeClosing,
28
+ closeWorkspaceWithSavedChanges,
29
+ }) => {
30
+ const { t } = useTranslation();
31
+ const isTablet = useLayoutType() === 'tablet';
32
+ const { formMethods, paymentSchema } = usePaymentForm(t, bill.balance);
33
+
34
+ type PaymentFormData = z.infer<typeof paymentSchema>;
35
+
36
+ const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes();
37
+
38
+ const {
39
+ formState: { isSubmitting, errors },
40
+ control,
41
+ handleSubmit,
42
+ } = formMethods;
43
+
44
+ const onSubmit = async (data: PaymentFormData) => {
45
+ const payment = {
46
+ instanceType: data.instanceType?.uuid,
47
+ amount: data.amountTendered,
48
+ amountTendered: data.amountTendered,
49
+ attributes: data.attributes
50
+ ? Object.entries(data.attributes).map(([uuid, value]) => ({
51
+ attributeType: {
52
+ uuid,
53
+ },
54
+ value,
55
+ }))
56
+ : [],
57
+ };
58
+
59
+ try {
60
+ const response = await addPaymentToBill(bill.uuid, payment);
61
+ if (response.ok) {
62
+ showSnackbar({
63
+ title: t('paymentSaved', 'Payment saved'),
64
+ kind: 'success',
65
+ subtitle: t('paymentSavedSuccessfully', 'Payment saved successfully'),
66
+ });
67
+ }
68
+ const url = `/ws/rest/v1/cashier/bill/${bill.uuid}`;
69
+ mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true });
70
+ closeWorkspaceWithSavedChanges();
71
+ } catch (error) {
72
+ showSnackbar({
73
+ title: t('errorSavingPayment', 'Error saving payment'),
74
+ kind: 'error',
75
+ subtitle: error.message,
76
+ });
77
+ }
78
+ };
79
+
80
+ const handleError = (error: any) => {
81
+ showSnackbar({
82
+ title: t('errorSavingPayment', 'Error generating payment'),
83
+ kind: 'error',
84
+ subtitle: JSON.stringify(error, null, 2),
85
+ });
86
+ };
87
+
88
+ useEffect(() => {
89
+ promptBeforeClosing(() => formMethods.formState.isDirty);
90
+ }, [formMethods.formState.isDirty, promptBeforeClosing]);
91
+
92
+ if (isLoadingPaymentModes) {
93
+ return <InlineLoading status="active" iconDescription="Loading payment modes" />;
94
+ }
95
+
96
+ const attributeTypes = (formMethods.watch('instanceType')?.attributeTypes as Array<Record<string, string>>) || [];
97
+
98
+ return (
99
+ <form onSubmit={handleSubmit(onSubmit, handleError)} className={styles.form}>
100
+ <div className={styles.formContainer}>
101
+ <Stack className={styles.formStackControl} gap={7}>
102
+ <ResponsiveWrapper>
103
+ <Stack gap={4}>
104
+ <Controller
105
+ name="instanceType"
106
+ control={control}
107
+ render={({ field }) => (
108
+ <Dropdown
109
+ {...field}
110
+ id="instanceType"
111
+ titleText={t('instanceType', 'Instance Type')}
112
+ label={t('selectInstanceType', 'Select instance type')}
113
+ items={paymentModes}
114
+ onChange={({ selectedItem }) => field.onChange(selectedItem)}
115
+ itemToString={(item) => (item ? item.name : '')}
116
+ invalid={!!errors.instanceType}
117
+ invalidText={errors.instanceType?.message}
118
+ />
119
+ )}
120
+ />
121
+ </Stack>
122
+ </ResponsiveWrapper>
123
+ <ResponsiveWrapper>
124
+ <Controller
125
+ name="amountTendered"
126
+ control={control}
127
+ render={({ field }) => (
128
+ <TextInput
129
+ {...field}
130
+ id="amountTendered"
131
+ labelText={t('amountTendered', 'Amount Tendered')}
132
+ placeholder={t('enterAmountTendered', 'Enter amount tendered, max is {{max}}', {
133
+ max: bill.balance,
134
+ })}
135
+ type="number"
136
+ step="0.01"
137
+ max={bill.balance}
138
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
139
+ invalid={!!errors.amountTendered}
140
+ invalidText={errors.amountTendered?.message}
141
+ />
142
+ )}
143
+ />
144
+ </ResponsiveWrapper>
145
+ <ResponsiveWrapper>
146
+ {attributeTypes.map((attributeType) => (
147
+ <Controller
148
+ key={attributeType.uuid}
149
+ name={`attributes.${attributeType.uuid}`}
150
+ control={control}
151
+ render={({ field }) => (
152
+ <TextInput
153
+ {...field}
154
+ id={attributeType.uuid}
155
+ labelText={`${attributeType.name || 'Attribute'}${
156
+ attributeType.required ? t('required', ' (Required)') : ''
157
+ }`}
158
+ placeholder={attributeType.description || 'Enter value'}
159
+ invalid={!!errors.attributes?.[attributeType.uuid] || (attributeType.required && !field.value)}
160
+ invalidText={
161
+ errors.attributes?.[attributeType.uuid]?.message ||
162
+ (attributeType.required && !field.value
163
+ ? t('attributeValueRequired', 'Attribute value is required')
164
+ : '')
165
+ }
166
+ />
167
+ )}
168
+ />
169
+ ))}
170
+ </ResponsiveWrapper>
171
+ </Stack>
172
+ </div>
173
+ <ButtonSet className={classNames({ [styles.tablet]: isTablet, [styles.desktop]: !isTablet })}>
174
+ <Button style={{ maxWidth: '50%' }} kind="secondary" onClick={() => closeWorkspace()}>
175
+ {t('cancel', 'Cancel')}
176
+ </Button>
177
+ <Button
178
+ disabled={isSubmitting || Object.keys(errors).length > 0}
179
+ style={{ maxWidth: '50%' }}
180
+ kind="primary"
181
+ type="submit">
182
+ {isSubmitting ? (
183
+ <span style={{ display: 'flex', justifyItems: 'center' }}>
184
+ {t('submitting', 'Submitting...')} <InlineLoading status="active" iconDescription="Loading" />
185
+ </span>
186
+ ) : (
187
+ t('saveAndClose', 'Save & close')
188
+ )}
189
+ </Button>
190
+ </ButtonSet>
191
+ </form>
192
+ );
193
+ };
194
+
195
+ export default PaymentWorkspace;
@@ -0,0 +1,77 @@
1
+ import { useForm } from 'react-hook-form';
2
+ import { zodResolver } from '@hookform/resolvers/zod';
3
+ import { z } from 'zod';
4
+
5
+ export const createPaymentSchema = (t: (key: string, defaultValue?: string) => string, billBalance: number) =>
6
+ z
7
+ .object({
8
+ instanceType: z
9
+ .object({
10
+ uuid: z.string().min(1, t('instanceTypeUuidRequired', 'Instance type UUID is required')),
11
+ name: z.string().min(1, t('instanceTypeNameRequired', 'Instance type name is required')),
12
+ description: z.string().optional(),
13
+ retired: z.boolean().optional(),
14
+ retireReason: z.string().nullable().optional(),
15
+ attributeTypes: z
16
+ .array(
17
+ z.object({
18
+ uuid: z.string().optional(),
19
+ name: z.string().optional(),
20
+ description: z.string().optional(),
21
+ required: z.boolean().optional(),
22
+ }),
23
+ )
24
+ .optional(),
25
+ sortOrder: z.number().nullable().optional(),
26
+ resourceVersion: z.string().optional(),
27
+ })
28
+ .optional(),
29
+ amountTendered: z
30
+ .number()
31
+ .positive(t('amountTenderedPositive', 'Amount tendered must be positive'))
32
+ .max(billBalance, t('amountTenderedExceedsBalance', 'Amount tendered cannot exceed bill balance')),
33
+ attributes: z.record(z.string(), z.string()).optional(),
34
+ })
35
+ .refine(
36
+ (data) => {
37
+ // Check if all required attributes have values
38
+ if (!data.instanceType?.attributeTypes) {
39
+ return true;
40
+ }
41
+
42
+ const requiredAttributeTypes = data.instanceType.attributeTypes.filter((attr) => attr.required && attr.uuid);
43
+ const providedAttributes = data.attributes || {};
44
+
45
+ for (const requiredAttr of requiredAttributeTypes) {
46
+ if (!providedAttributes[requiredAttr.uuid] || providedAttributes[requiredAttr.uuid].trim() === '') {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ return true;
52
+ },
53
+ {
54
+ message: t('requiredAttributesMissing', 'Required attributes are missing'),
55
+ path: ['attributes'],
56
+ },
57
+ );
58
+
59
+ export const usePaymentForm = (t: (key: string, defaultValue?: string) => string, billBalance: number) => {
60
+ const paymentSchema = createPaymentSchema(t, billBalance);
61
+
62
+ type PaymentFormData = z.infer<typeof paymentSchema>;
63
+
64
+ const formMethods = useForm<PaymentFormData>({
65
+ resolver: zodResolver(paymentSchema),
66
+ defaultValues: {
67
+ instanceType: undefined,
68
+ amountTendered: undefined,
69
+ attributes: {},
70
+ },
71
+ });
72
+
73
+ return {
74
+ formMethods,
75
+ paymentSchema,
76
+ };
77
+ };
package/src/routes.json CHANGED
@@ -279,6 +279,12 @@
279
279
  "component": "depositTransactionWorkspace",
280
280
  "title": "Deposit Transaction",
281
281
  "type": "other-form"
282
+ },
283
+ {
284
+ "name": "payment-workspace",
285
+ "component": "paymentWorkspace",
286
+ "title": "Payment Workspace",
287
+ "type": "other-form"
282
288
  }
283
289
  ],
284
290
  "modals": [
@@ -6,6 +6,7 @@
6
6
  "addBill": "የሂሳብ ዕቃ(ዎች) ይጨምሩ",
7
7
  "addCommodityChargeItem": "የክፍያ ዕቃ ይጨምሩ",
8
8
  "addDeposit": "ተቀማጭ ገንዘብ ይጨምሩ",
9
+ "additionalPayment": "ተቀማጭ ክፍያ",
9
10
  "addPaymentOptions": "የክፍያ አማራጭ ይጨምሩ",
10
11
  "addSchema": "ስኬማ ይጨምሩ",
11
12
  "addServiceChargeItem": "የክፍያ አገልግሎት ይጨምሩ",
@@ -6,6 +6,7 @@
6
6
  "addBill": "Add bill item(s)",
7
7
  "addCommodityChargeItem": "Add charge item",
8
8
  "addDeposit": "Add deposit",
9
+ "additionalPayment": "Additional Payment",
9
10
  "addPaymentOptions": "Add payment option",
10
11
  "addSchema": "Add Schema",
11
12
  "addServiceChargeItem": "Add charge service",
@@ -6,6 +6,7 @@
6
6
  "addBill": "Add bill item(s)",
7
7
  "addCommodityChargeItem": "Add charge item",
8
8
  "addDeposit": "Add Deposit",
9
+ "additionalPayment": "Additional Payment",
9
10
  "addPaymentOptions": "Add payment option",
10
11
  "addSchema": "Add Schema",
11
12
  "addServiceChargeItem": "Add charge service",