From 3681f429d377b18e712d63398b0aaf1e8c6d50a9 Mon Sep 17 00:00:00 2001 From: AJAL ODORA JONATHAN <43242517+ODORA0@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:07:52 +0300 Subject: [PATCH] O3-4250 (feat) Ability to add Cash Points and Payment modes via a UI (#74) * O3-4250 (feat) Ability to add cash points via a UI * Add payment mode setting * Prevent creating dublicates * Change to openmrsFetch * Use restBaseUrl --- .../billable-services-home.component.tsx | 16 +- .../cash-point-configuration.component.tsx | 276 +++++++++++++++++ .../cash-point/cash-point-configuration.scss | 23 ++ .../payment-modes-config.component.tsx | 280 ++++++++++++++++++ .../payyment-modes/payment-modes-config.scss | 23 ++ translations/en.json | 33 +++ 6 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 src/billable-services/cash-point/cash-point-configuration.component.tsx create mode 100644 src/billable-services/cash-point/cash-point-configuration.scss create mode 100644 src/billable-services/payyment-modes/payment-modes-config.component.tsx create mode 100644 src/billable-services/payyment-modes/payment-modes-config.scss diff --git a/src/billable-services/billable-services-home.component.tsx b/src/billable-services/billable-services-home.component.tsx index 8f634ae..e140e7e 100644 --- a/src/billable-services/billable-services-home.component.tsx +++ b/src/billable-services/billable-services-home.component.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { SideNav, SideNavItems, SideNavLink } from '@carbon/react'; -import { Wallet, Money } from '@carbon/react/icons'; +import { SideNav, SideNavItems, SideNavLink, SideNavMenu, SideNavMenuItem } from '@carbon/react'; +import { Wallet, Money, Settings } from '@carbon/react/icons'; import { useTranslation } from 'react-i18next'; import { UserHasAccess, navigate } from '@openmrs/esm-framework'; import AddBillableService from './create-edit/add-billable-service.component'; import BillWaiver from './bill-waiver/bill-waiver.component'; import BillableServicesDashboard from './dashboard/dashboard.component'; import BillingHeader from '../billing-header/billing-header.component'; +import CashPointConfiguration from './cash-point/cash-point-configuration.component'; +import PaymentModesConfig from './payyment-modes/payment-modes-config.component'; import styles from './billable-services.scss'; const BillableServiceHome: React.FC = () => { @@ -35,6 +37,14 @@ const BillableServiceHome: React.FC = () => { handleNavigation('waive-bill')} renderIcon={Money}> {t('billWaiver', 'Bill waiver')} + + handleNavigation('cash-point-config')}> + {t('cashPointConfig', 'Cash Point Config')} + + handleNavigation('payment-modes-config')}> + {t('paymentModesConfig', 'Payment Modes Config')} + + @@ -45,6 +55,8 @@ const BillableServiceHome: React.FC = () => { } /> } /> } /> + } /> + } /> diff --git a/src/billable-services/cash-point/cash-point-configuration.component.tsx b/src/billable-services/cash-point/cash-point-configuration.component.tsx new file mode 100644 index 0000000..6df96b3 --- /dev/null +++ b/src/billable-services/cash-point/cash-point-configuration.component.tsx @@ -0,0 +1,276 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Button, + DataTable, + TableContainer, + Table, + TableHead, + TableRow, + TableHeader, + TableBody, + TableCell, + Modal, + TextInput, + OverflowMenu, + OverflowMenuItem, + Dropdown, +} from '@carbon/react'; +import { Add } from '@carbon/react/icons'; +import { useTranslation } from 'react-i18next'; +import { useForm, Controller } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { showSnackbar, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { CardHeader } from '@openmrs/esm-patient-common-lib'; +import styles from './cash-point-configuration.scss'; + +// Validation schema +const cashPointSchema = z.object({ + name: z.string().min(1, 'Cash Point Name is required'), + uuid: z + .string() + .min(1, 'UUID is required') + .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'), + location: z.string().min(1, 'Location is required'), +}); + +type CashPointFormValues = z.infer; + +const CashPointConfiguration: React.FC = () => { + const { t } = useTranslation(); + const [cashPoints, setCashPoints] = useState([]); + const [locations, setLocations] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(cashPointSchema), + defaultValues: { + name: '', + uuid: '', + location: '', + }, + }); + + const fetchCashPoints = useCallback(async () => { + try { + const response = await openmrsFetch(`${restBaseUrl}/billing/cashPoint?v=full`); + setCashPoints(response.data.results || []); + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorFetchingCashPoints', 'An error occurred while fetching cash points.'), + kind: 'error', + isLowContrast: false, + }); + } + }, [t]); + + const fetchLocations = useCallback(async () => { + try { + const response = await openmrsFetch(`${restBaseUrl}/location?v=default`); + const allLocations = response.data.results.map((loc) => ({ + id: loc.uuid, + label: loc.display, + })); + setLocations(allLocations); + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorFetchingLocations', 'An error occurred while fetching locations.'), + kind: 'error', + isLowContrast: false, + }); + } + }, [t]); + + useEffect(() => { + fetchCashPoints(); + fetchLocations(); + }, [fetchCashPoints, fetchLocations]); + + const onSubmit = async (data: CashPointFormValues) => { + const isDuplicate = cashPoints.some( + (point) => point.name.toLowerCase() === data.name.toLowerCase() || point.uuid === data.uuid, + ); + + if (isDuplicate) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t( + 'duplicateCashPointError', + 'A cash point with the same name or UUID already exists. Please use a unique name and UUID.', + ), + kind: 'error', + isLowContrast: false, + }); + return; + } + + try { + const response = await openmrsFetch(`${restBaseUrl}/billing/cashPoint`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: { + name: data.name, + uuid: data.uuid, + location: { uuid: data.location }, + }, + }); + + if (response.ok) { + showSnackbar({ + title: t('success', 'Success'), + subtitle: t('cashPointSaved', 'Cash point was successfully saved.'), + kind: 'success', + }); + + setIsModalOpen(false); + reset({ name: '', uuid: '', location: '' }); + fetchCashPoints(); + } else { + const errorData = response.data || {}; + showSnackbar({ + title: t('error', 'Error'), + subtitle: errorData.message || t('errorSavingCashPoint', 'An error occurred while saving the cash point.'), + kind: 'error', + isLowContrast: false, + }); + } + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorSavingCashPoint', 'An error occurred while saving the cash point.'), + kind: 'error', + isLowContrast: false, + }); + } + }; + + const rowData = cashPoints.map((point) => ({ + id: point.uuid, + name: point.name, + uuid: point.uuid, + location: point.location ? point.location.display : 'None', + })); + + const headerData = [ + { key: 'name', header: t('name', 'Name') }, + { key: 'uuid', header: t('uuid', 'UUID') }, + { key: 'location', header: t('location', 'Location') }, + { key: 'actions', header: t('actions', 'Actions') }, + ]; + + return ( +
+
+ + + +
+ + {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( + + + + + {headers.map((header) => ( + + {header.header} + + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => + cell.info.header !== 'actions' ? ( + {cell.value} + ) : ( + + + + + + ), + )} + + ))} + +
+
+ )} +
+
+
+ + {/* Modal for Adding New Cash Point */} + setIsModalOpen(false)} + onRequestSubmit={handleSubmit(onSubmit)} + primaryButtonText={t('save', 'Save')} + secondaryButtonText={t('cancel', 'Cancel')} + isPrimaryButtonDisabled={isSubmitting}> +
+ ( + + )} + /> + ( + + )} + /> + ( + loc.id === field.value)} + onChange={({ selectedItem }) => field.onChange(selectedItem?.id)} + invalid={!!errors.location} + invalidText={errors.location?.message} + /> + )} + /> + +
+
+ ); +}; + +export default CashPointConfiguration; diff --git a/src/billable-services/cash-point/cash-point-configuration.scss b/src/billable-services/cash-point/cash-point-configuration.scss new file mode 100644 index 0000000..06a2adc --- /dev/null +++ b/src/billable-services/cash-point/cash-point-configuration.scss @@ -0,0 +1,23 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.container { + padding: layout.$spacing-05; +} + +.card { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: layout.$spacing-05; +} + +.billHistoryContainer { + margin-top: layout.$spacing-05; +} + +.table { + width: 100%; + table-layout: auto; +} \ No newline at end of file diff --git a/src/billable-services/payyment-modes/payment-modes-config.component.tsx b/src/billable-services/payyment-modes/payment-modes-config.component.tsx new file mode 100644 index 0000000..647bacd --- /dev/null +++ b/src/billable-services/payyment-modes/payment-modes-config.component.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Button, + DataTable, + TableContainer, + Table, + TableHead, + TableRow, + TableHeader, + TableBody, + TableCell, + Modal, + TextInput, + OverflowMenu, + OverflowMenuItem, +} from '@carbon/react'; +import { Add } from '@carbon/react/icons'; +import { useTranslation } from 'react-i18next'; +import { useForm, Controller } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { showSnackbar, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { CardHeader } from '@openmrs/esm-patient-common-lib'; +import styles from './payment-modes-config.scss'; + +// Validation schema +const paymentModeSchema = z.object({ + name: z.string().min(1, 'Payment Mode Name is required'), + description: z.string().optional(), +}); + +type PaymentModeFormValues = z.infer; + +const PaymentModesConfig: React.FC = () => { + const { t } = useTranslation(); + const [paymentModes, setPaymentModes] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedPaymentMode, setSelectedPaymentMode] = useState(null); + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(paymentModeSchema), + defaultValues: { + name: '', + description: '', + }, + }); + + const fetchPaymentModes = useCallback(async () => { + try { + const response = await openmrsFetch(`${restBaseUrl}/billing/paymentMode?v=full`); + setPaymentModes(response.data.results || []); + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorFetchingPaymentModes', 'An error occurred while fetching payment modes.'), + kind: 'error', + isLowContrast: false, + }); + } + }, [t]); + + useEffect(() => { + fetchPaymentModes(); + }, [fetchPaymentModes]); + + const onSubmit = async (data: PaymentModeFormValues) => { + // Check for duplicate payment mode name + const isDuplicate = paymentModes.some((mode) => mode.name.toLowerCase() === data.name.toLowerCase()); + + if (isDuplicate) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t( + 'duplicatePaymentModeError', + 'A payment mode with the same name already exists. Please create another payment mode', + ), + kind: 'error', + isLowContrast: false, + }); + return; + } + + try { + const response = await openmrsFetch(`${restBaseUrl}/billing/paymentMode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: data.name, + description: data.description, + }), + }); + + if (response.ok) { + showSnackbar({ + title: t('success', 'Success'), + subtitle: t('paymentModeSaved', 'Payment mode was successfully saved.'), + kind: 'success', + }); + + setIsModalOpen(false); + reset({ name: '', description: '' }); + fetchPaymentModes(); + } else { + const errorData = response.data || {}; + showSnackbar({ + title: t('error', 'Error'), + subtitle: + errorData.message || t('errorSavingPaymentMode', 'An error occurred while saving the payment mode.'), + kind: 'error', + isLowContrast: false, + }); + } + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorSavingPaymentMode', 'An error occurred while saving the payment mode.'), + kind: 'error', + isLowContrast: false, + }); + } + }; + + const handleDelete = async () => { + if (!selectedPaymentMode) return; + + try { + await openmrsFetch(`${restBaseUrl}/billing/paymentMode/${selectedPaymentMode.uuid}`, { + method: 'DELETE', + }); + + showSnackbar({ + title: t('success', 'Success'), + subtitle: t('paymentModeDeleted', 'Payment mode was successfully deleted.'), + kind: 'success', + }); + + setIsDeleteModalOpen(false); + setSelectedPaymentMode(null); + fetchPaymentModes(); + } catch (err) { + showSnackbar({ + title: t('error', 'Error'), + subtitle: t('errorDeletingPaymentMode', 'An error occurred while deleting the payment mode.'), + kind: 'error', + isLowContrast: false, + }); + } + }; + + const rowData = paymentModes.map((mode) => ({ + id: mode.uuid, + name: mode.name, + description: mode.description || '--', + })); + + const headerData = [ + { key: 'name', header: t('name', 'Name') }, + { key: 'description', header: t('description', 'Description') }, + { key: 'actions', header: t('actions', 'Actions') }, + ]; + + return ( +
+
+ + + +
+ + {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( + + + + + {headers.map((header) => ( + + {header.header} + + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => + cell.info.header !== 'actions' ? ( + {cell.value} + ) : ( + + + { + const selected = paymentModes.find((p) => p.uuid === row.id); + setSelectedPaymentMode(selected); + setIsDeleteModalOpen(true); + }} + /> + + + ), + )} + + ))} + +
+
+ )} +
+
+
+ + {/* Modal for Adding New Payment Mode */} + setIsModalOpen(false)} + onRequestSubmit={handleSubmit(onSubmit)} + primaryButtonText={t('save', 'Save')} + secondaryButtonText={t('cancel', 'Cancel')} + isPrimaryButtonDisabled={isSubmitting}> +
+ ( + + )} + /> + ( + + )} + /> + +
+ + {/* Modal for Deleting Payment Mode */} + setIsDeleteModalOpen(false)} + onRequestSubmit={handleDelete} + primaryButtonText={t('delete', 'Delete')} + secondaryButtonText={t('cancel', 'Cancel')} + primaryButtonDanger + danger> +

{t('confirmDeleteMessage', 'Are you sure you want to delete this payment mode? Proceed cautiously.')}

+
+
+ ); +}; + +export default PaymentModesConfig; diff --git a/src/billable-services/payyment-modes/payment-modes-config.scss b/src/billable-services/payyment-modes/payment-modes-config.scss new file mode 100644 index 0000000..6b7efc4 --- /dev/null +++ b/src/billable-services/payyment-modes/payment-modes-config.scss @@ -0,0 +1,23 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.container { + padding: layout.$spacing-05; +} + +.card { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: layout.$spacing-05; +} + +.historyContainer { + margin-top: layout.$spacing-05; +} + +.table { + width: 100%; + table-layout: auto; +} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json index cc60651..0e3dfdb 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2,8 +2,10 @@ "actions": "Actions", "addBill": "Add bill item(s)", "addBillableServices": "Add Billable Services", + "addCashPoint": "Add Cash Point", "addNewBillableService": "Add new billable service", "addNewService": "Add new service", + "addPaymentMode": "Add Payment Mode", "addPaymentOptions": "Add payment option", "amount": "Amount", "amountDue": "Amount Due", @@ -18,6 +20,7 @@ "billing": "Billing", "billingForm": "Billing form", "billingHistory": "Billing History", + "billingSettings": "Billing Settings", "billItem": "Bill item", "billItems": "Bill Items", "billLineItemEmpty": "This bill has no line items", @@ -34,13 +37,28 @@ "billWaiverError": "Bill waiver failed {{error}}", "billWaiverSuccess": "Bill waiver successful", "cancel": "Cancel", + "cashPointConfig": "Cash Point Config", + "cashPointHistory": "Cash Point History", + "cashPointLocation": "Cash Point Location", + "cashPointName": "Cash Point Name", + "cashPointNamePlaceholder": "e.g., Pharmacy Cash Point", + "cashPointSaved": "Cash point was successfully saved.", + "cashPointUuid": "Cash Point UUID", + "cashPointUuidPlaceholder": "Enter UUID", "checkFilters": "Check the filters above", "clearSearchInput": "Clear search input", "clientBalance": "Client Balance", + "confirmDeleteMessage": "Are you sure you want to delete this payment mode? Proceed cautiously.", "createdSuccessfully": "Billable service created successfully", "currentPrice": "Current price", + "delete": "Delete", + "deletePaymentMode": "Delete Payment Mode", + "description": "Description", + "descriptionPlaceholder": "e.g., Used for all cash transactions", "discard": "Discard", "discount": "Discount", + "duplicateCashPointError": "A cash point with the same name or UUID already exists. Please use a unique name and UUID.", + "duplicatePaymentModeError": "A payment mode with the same name already exists. Please create another payment mode", "editBillableService": "Edit billable service", "editBillableServices": "Edit Billable Services", "editBillLineItem": "Edit bill line item?", @@ -49,8 +67,14 @@ "enterConcept": "Associated concept", "enterReferenceNumber": "Enter ref. number", "error": "Error", + "errorDeletingPaymentMode": "An error occurred while deleting the payment mode.", + "errorFetchingCashPoints": "An error occurred while fetching cash points.", + "errorFetchingLocations": "An error occurred while fetching locations.", + "errorFetchingPaymentModes": "An error occurred while fetching payment modes.", "errorLoadingBillServices": "Error loading bill services", "errorLoadingPaymentModes": "Payment modes error", + "errorSavingCashPoint": "An error occurred while saving the cash point.", + "errorSavingPaymentMode": "An error occurred while saving the payment mode.", "filterBy": "Filter by", "filterTable": "Filter table", "grandTotal": "Grand total", @@ -68,6 +92,7 @@ "loading": "Loading data...", "loadingBillingServices": "Loading billing services...", "loadingDescription": "Loading", + "location": "Select Location", "manageBillableServices": "Manage billable services", "name": "Name", "nextPage": "Next page", @@ -87,6 +112,12 @@ "paymentMethod": "Payment method", "paymentMethods": "Payment methods", "paymentMode": "Payment Mode", + "paymentModeDeleted": "Payment mode was successfully deleted.", + "paymentModeHistory": "Payment Mode History", + "paymentModeName": "Payment Mode Name", + "paymentModeNamePlaceholder": "e.g., Cash, Credit Card", + "paymentModeSaved": "Payment mode was successfully saved.", + "paymentModesConfig": "Payment Modes Config", "payments": "Payments", "pleaseRequiredFields": "Please fill all required fields", "policyNumber": "Policy number", @@ -127,11 +158,13 @@ "status": "Service Status", "stockItem": "Stock Item", "submitting": "Submitting...", + "success": "Success", "total": "Total", "totalAmount": "Total Amount", "totalTendered": "Total Tendered", "unitPrice": "Unit price", "updatedSuccessfully": "Billable service updated successfully", + "uuid": "UUID", "visitTime": "Visit time", "waiverForm": "Waiver form" }