@@ -149,6 +153,7 @@ const CommodityForm: React.FC<{editingService?: any; onClose: () => void}> = ({
+ >
);
};
diff --git a/packages/esm-billing-app/src/billable-services/billables/services/service-form.scss b/packages/esm-billing-app/src/billable-services/billables/services/service-form.scss
index 6a64375..f91353f 100644
--- a/packages/esm-billing-app/src/billable-services/billables/services/service-form.scss
+++ b/packages/esm-billing-app/src/billable-services/billables/services/service-form.scss
@@ -1,6 +1,7 @@
@use '@carbon/colors';
@use '@carbon/layout';
@use '@carbon/type';
+@use '~@openmrs/esm-styleguide/src/vars' as *;
.form {
display: flex;
@@ -9,51 +10,128 @@
height: 100%;
}
-.formContainer {
+.section {
+ margin: layout.$spacing-03;
+}
+
+.sectionTitle {
+ @include type.type-style('heading-compact-02');
+ color: $text-02;
+ margin-bottom: layout.$spacing-04;
+}
+
+.modalBody {
+ padding-bottom: layout.$spacing-05;
+}
+
+.container {
margin: layout.$spacing-05;
}
-.tablet {
- padding: layout.$spacing-06 layout.$spacing-05;
- background-color: colors.$white;
+.paymentContainer {
+ margin: layout.$layout-01;
+ padding: layout.$layout-01;
+ width: 70%;
+ border-right: 1px solid colors.$cool-gray-40;
}
-.desktop {
- padding: 0;
+.paymentButtons {
+ margin: layout.$layout-01 0;
}
-.paymentMethods {
- display: flex;
- flex-direction: column;
- row-gap: layout.$spacing-05;
-}
-
-.searchResults {
- position: absolute;
- top: 2.625rem;
- z-index: 99;
- height: 16rem;
- padding: 0.5rem;
- overflow-y: scroll;
- background-color: colors.$white;
- border: 1px solid colors.$gray-30;
+.paymentMethodContainer {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(auto, 1fr));
+ align-items: flex-start;
+ column-gap: layout.$spacing-05;
+ margin: 0.625rem 0;
width: 100%;
}
-.searchItem {
- padding: 0.5rem;
- color: colors.$gray-70;
+.paymentTotals {
+ margin-top: layout.$spacing-01;
+}
+
+.processPayments {
+ display: flex;
+ justify-content: flex-end;
+ margin: layout.$spacing-05;
+ column-gap: layout.$spacing-04;
+}
+
+.errorPaymentContainer {
+ margin: layout.$spacing-04;
+ min-height: layout.$spacing-09;
+}
+
+.removeButtonContainer {
+ display: flex;
+ align-self: center;
cursor: pointer;
- @include type.type-style('label-01');
- border-bottom: 1px solid colors.$gray-30;
- &:hover {
- background-color: colors.$gray-20;
+ margin-left: layout.$spacing-07;
+}
+
+.removeButton {
+ color: colors.$red-60;
+}
+
+.service {
+ padding: layout.$spacing-05 layout.$spacing-04;
+}
+
+.conceptsList {
+ background-color: $ui-02;
+ max-height: 14rem;
+ overflow-y: auto;
+ border: 1px solid $ui-03;
+
+ li:hover {
+ background-color: $ui-03;
}
}
-.formGroupWithConcept {
- position: relative;
+.emptyResults {
+ @include type.type-style('body-compact-01');
+ color: $text-02;
+ min-height: layout.$spacing-05;
+ border: 1px solid $ui-03;
}
-.formStackControl {
- row-gap: layout.$layout-01;
+
+.conceptLabel {
+ @include type.type-style('label-02');
+ margin: layout.$spacing-05;
}
+
+.errorContainer {
+ margin: layout.$spacing-05;
+}
+
+.serviceError {
+ :global(.cds--search-input):focus {
+ outline: 2.5px solid $danger;
+ }
+
+ :global(.cds--search-magnifier) {
+ svg {
+ fill: $danger;
+ }
+ }
+}
+
+.errorMessage {
+ @include type.type-style('label-02');
+ color: $danger;
+ margin-top: 0.5rem;
+}
+
+.spinner {
+ &:global(.cds--inline-loading) {
+ min-height: layout.$spacing-05;
+ }
+}
+
+.errorMessage {
+ color: red;
+ font-size: 0.875rem;
+}
+
diff --git a/packages/esm-billing-app/src/billable-services/billables/services/service-form.workspace.tsx b/packages/esm-billing-app/src/billable-services/billables/services/service-form.workspace.tsx
index 15daef6..991d888 100644
--- a/packages/esm-billing-app/src/billable-services/billables/services/service-form.workspace.tsx
+++ b/packages/esm-billing-app/src/billable-services/billables/services/service-form.workspace.tsx
@@ -1,254 +1,380 @@
-import React, { useState, useMemo, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
+import React, { useCallback, useRef, useState, useEffect } from 'react';
import {
- ButtonSet,
Button,
- Stack,
- TextInput,
ComboBox,
- Toggle,
- InlineNotification,
+ Dropdown,
+ Form,
+ FormLabel,
InlineLoading,
+ Layer,
+ Search,
+ TextInput,
+ Tile,
} from '@carbon/react';
-import { Add } from '@carbon/react/icons';
-import { Controller, useFieldArray, useForm, FormProvider } from 'react-hook-form';
+import { navigate, showSnackbar, useDebounce, useLayoutType } from '@openmrs/esm-framework';
+import { Add, TrashCan, WarningFilled } from '@carbon/react/icons';
+import { Controller, useFieldArray, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
+import { createBillableSerice, updateBillableService, useConceptsSearch,usePaymentModes, useServiceTypes } from '../../billable-service.resource';
+import { type ServiceConcept } from '../../../types';
+import styles from './service-form.scss'
+
+import LeftPanel from '../../../left-panel/left-panel.component';
+
+type PaymentMode = {
+ paymentMode: string;
+ price: string | number;
+};
+
+type PaymentModeFormValue = {
+ payment: Array
;
+};
-import { useLayoutType, useDebounce, ResponsiveWrapper, showSnackbar, restBaseUrl } from '@openmrs/esm-framework';
+const servicePriceSchema = z.object({
+ paymentMode: z.string().refine((value) => !!value, 'Payment method is required'),
+ price: z.union([
+ z.number().refine((value) => !!value, 'Price is required'),
+ z.string().refine((value) => !!value, 'Price is required'),
+ ]),
+});
-import { createBillableSerice, useConceptsSearch, useServiceTypes } from '../../billable-service.resource';
-import PriceField from './price.component';
-import { billableFormSchema, BillableFormSchema } from '../form-schemas';
+const paymentFormSchema = z.object({
+ payment: z.array(servicePriceSchema).min(1, 'At least one payment option is required'),
+});
-import classNames from 'classnames';
-import styles from './service-form.scss';
-import { formatBillableServicePayloadForSubmission, mapInputToPayloadSchema } from '../form-helper';
-import ConceptSearch from './concept-search.component';
-import { handleMutate } from '../../utils';
+const DEFAULT_PAYMENT_OPTION = { paymentMode: '', price: 0 };
-const AddServiceForm: React.FC<{ editingService?: any; onClose: () => void }> = ({ onClose, editingService }) => {
+const AddServiceForm: React.FC<{ editingService?: any; onClose: () => void }> = ({ editingService, onClose }) => {
const { t } = useTranslation();
- const isTablet = useLayoutType() === 'tablet';
- const [conceptToLookup, setConceptToLookup] = useState('');
- const debouncedConceptToLookup = useDebounce(conceptToLookup, 500);
- const [selectedConcept, setSelectedConcept] = useState(null);
- const inEditMode = !!editingService;
-
- const { isLoading: isLoadingServiceTypes, serviceTypes } = useServiceTypes();
- const { isSearching, searchResults: concepts } = useConceptsSearch(debouncedConceptToLookup);
- const formMethods = useForm({
- resolver: zodResolver(billableFormSchema),
- defaultValues: editingService
- ? mapInputToPayloadSchema(editingService)
- : { servicePrices: [], serviceStatus: 'ENABLED' },
- });
+
+ const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes();
+ const { serviceTypes, isLoading: isLoadingServicesTypes } = useServiceTypes();
+ const [billableServicePayload, setBillableServicePayload] = useState(editingService || {});
const {
- setValue,
control,
handleSubmit,
- formState: { errors, isDirty, defaultValues, isSubmitting },
- } = formMethods;
+ formState: { errors, isValid },
+ setValue,
+ } = useForm({
+ mode: 'all',
+ defaultValues: {
+ name: editingService?.name,
+ serviceShortName: editingService?.shortName,
+ serviceType: editingService?.serviceType,
+ conceptsSearch: editingService?.concept,
+ payment: editingService?.servicePrices || [DEFAULT_PAYMENT_OPTION],
+ },
+ resolver: zodResolver(paymentFormSchema),
+ shouldUnregister: !editingService,
+ });
+ const { fields, remove, append } = useFieldArray({ name: 'payment', control: control });
+
+ const handleAppendPaymentMode = useCallback(() => append(DEFAULT_PAYMENT_OPTION), [append]);
+ const handleRemovePaymentMode = useCallback((index) => remove(index), [remove]);
+
+ const isTablet = useLayoutType() === 'tablet';
+ const searchInputRef = useRef(null);
+ const handleSearchTermChange = (event: React.ChangeEvent) => setSearchTerm(event.target.value);
+
+ const [selectedConcept, setSelectedConcept] = useState(null);
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearchTerm = useDebounce(searchTerm);
+ const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm);
+ const handleConceptChange = useCallback((selectedConcept: any) => {
+ setSelectedConcept(selectedConcept);
+ }, []);
+
+ const handleNavigateToServiceDashboard = () =>
+ navigate({
+ to: window.getOpenmrsSpaBase() + 'billing/charge-items',
+ });
useEffect(() => {
- if (editingService) {
- setConceptToLookup(editingService.concept?.concept?.display);
- }
- }, [editingService]);
+ if (editingService && !isLoadingPaymentModes) {
+ setBillableServicePayload(editingService);
+ setValue('serviceName', editingService.name || '');
+ setValue('shortName', editingService.shortName || '');
+ setValue('serviceType', editingService.serviceType || '');
+ setValue(
+ 'payment',
+ editingService.servicePrices.map((payment) => ({
+ paymentMode: payment.paymentMode?.uuid || '',
+ price: payment.price,
+ })),
+ );
+ setValue('conceptsSearch', editingService.concept);
- const {
- fields: servicePriceFields,
- append: appendServicePrice,
- remove: removeServicePrice,
- } = useFieldArray({
- control,
- name: 'servicePrices',
- });
+ if (editingService.concept) {
+ setSelectedConcept(editingService.concept);
+ }
+ }
+ }, [editingService, paymentModes, serviceTypes, setValue]);
+ const onSubmit = (data) => {
+ const payload = {
+ name: billableServicePayload.name.substring(0),
+ shortName: billableServicePayload.shortName.substring(0),
+ serviceType: billableServicePayload.serviceType.uuid,
+ servicePrices: data.payment.map((payment) => {
+ const mode = paymentModes.find((m) => m.uuid === payment.paymentMode);
+ return {
+ paymentMode: payment.paymentMode,
+ name: mode?.name || 'Unknown',
+ price: parseFloat(payment.price),
+ };
+ }),
+ serviceStatus: 'ENABLED',
+ concept: selectedConcept?.uuid,
+ };
- const handleSelectConcept = (concept) => {
- setSelectedConcept(concept);
- setValue('concept', concept);
- setConceptToLookup('');
- };
+ const saveAction = editingService
+ ? updateBillableService(editingService.uuid, payload)
+ : createBillableSerice(payload);
- const onSubmit = async (data: BillableFormSchema) => {
- const formPayload = formatBillableServicePayloadForSubmission(data, editingService?.['uuid']);
- try {
- const response = await createBillableSerice(formPayload);
- if (response.ok) {
+ saveAction.then(
+ (resp) => {
showSnackbar({
- title: inEditMode
- ? t('serviceUpdatedSuccessfully', 'Service updated successfully')
- : t('serviceCreated', 'Service created successfully'),
+ title: t('chargeService', 'Charge Service'),
+ subtitle: editingService
+ ? t('updatedSuccessfully', 'Charge service updated successfully')
+ : t('createdSuccessfully', 'Charge service created successfully'),
kind: 'success',
- subtitle: inEditMode
- ? t('serviceUpdatedSuccessfully', 'Service updated successfully')
- : t('serviceCreatedSuccessfully', 'Service created successfully'),
- isLowContrast: true,
- timeoutInMs: 5000,
+ timeoutInMs: 3000,
});
- handleMutate(`${restBaseUrl}/billing/billableService?v`);
onClose();
- }
- } catch (e) {
- showSnackbar({
- title: t('error', 'Error'),
- kind: 'error',
- subtitle: inEditMode
- ? t('serviceUpdateFailed', 'Service failed to update')
- : t('serviceCreationFailed', 'Service creation failed'),
- isLowContrast: true,
- timeoutInMs: 5000,
- });
+ handleNavigateToServiceDashboard();
+ },
+ (error) => {
+ showSnackbar({ title: t('billPaymentError', 'Bill payment error'), kind: 'error', subtitle: error?.message });
+ },
+ );
+ };
+
+ const getPaymentErrorMessage = () => {
+ const paymentError = errors.payment;
+ if (paymentError && typeof paymentError.message === 'string') {
+ return paymentError.message;
}
+ return null;
};
- const renderServicePriceFields = useMemo(
- () =>
- servicePriceFields.map((field, index) => (
-
+ );
+ }
+
+ return (
+