diff --git a/packages/esm-billing-app/package.json b/packages/esm-billing-app/package.json index 765f5c4..18ac882 100644 --- a/packages/esm-billing-app/package.json +++ b/packages/esm-billing-app/package.json @@ -1,6 +1,6 @@ { "name": "@ehospital/esm-billing-app", - "version": "1.2.2", + "version": "1.2.4", "description": "Billing frontend module for use in O3", "browser": "dist/ehospital-esm-billing-app.js", "main": "src/index.ts", @@ -121,5 +121,5 @@ "*.{js,jsx,ts,tsx}": "eslint --cache --fix" }, "packageManager": "yarn@4.1.1", - "gitHead": "578616b07289665afcb7569f0390d1c0c569a7cf" + "gitHead": "caab2f747b2171410773637bb6fe0a1923dab97e" } diff --git a/packages/esm-billing-app/src/billable-services/billables/commodity/commodity-form.workspace.tsx b/packages/esm-billing-app/src/billable-services/billables/commodity/commodity-form.workspace.tsx index 75caba2..227f258 100644 --- a/packages/esm-billing-app/src/billable-services/billables/commodity/commodity-form.workspace.tsx +++ b/packages/esm-billing-app/src/billable-services/billables/commodity/commodity-form.workspace.tsx @@ -15,6 +15,8 @@ import { formatBillableServicePayloadForSubmission, mapInputToPayloadSchema } fr import { createBillableSerice } from '../../billable-service.resource'; import { handleMutate } from '../../utils'; +import LeftPanel from '../../../left-panel/left-panel.component'; + const CommodityForm: React.FC<{editingService?: any; onClose: () => void}> = ({ editingService, onClose @@ -96,6 +98,8 @@ const CommodityForm: React.FC<{editingService?: any; onClose: () => void}> = ({ }; return ( + <> +
@@ -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 ( +
+

+ {editingService + ? t('editChargeService', 'Edit Charge Service') + : t('addChargeService', 'Add Charge Service')} +

+ + {!editingService && } +
+ + { + const newName = e.target.value.substring(0); + setBillableServicePayload({ + ...billableServicePayload, + name: newName, + }); + }} + placeholder="Enter service name" + /> + +
+
+ + { + const newShortName = e.target.value.substring(0); + setBillableServicePayload({ + ...billableServicePayload, + shortName: newShortName, + }); + }} + placeholder="Enter service short name" + /> + +
+
+ Associated Concept + ( + + { + setSearchTerm(e.target.value); + onChange(e); + handleSearchTermChange(e); + }} + renderIcon={errors?.search && } + onBlur={onBlur} + onClear={() => { + setSearchTerm(''); + setSelectedConcept(null); + }} + value={(() => { + if (selectedConcept) { + return selectedConcept.display; + } + if (debouncedSearchTerm) { + return value; + } + })()} + /> + + )} /> - )), - [servicePriceFields, control, removeServicePrice, errors], - ); - const handleError = (err) => { - console.error(JSON.stringify(err, null, 2)); - showSnackbar({ - title: t('serviceCreationFailed', 'Service creation failed'), - subtitle: t( - 'serviceCreationFailedSubtitle', - 'The service creation failed, view browser console for more details', - ), - kind: 'error', - isLowContrast: true, - timeoutInMs: 5000, - }); - }; + {(() => { + if (!debouncedSearchTerm || selectedConcept) return null; + if (isSearching) + return ; + if (searchResults && searchResults.length) { + return ( +
    + {/*TODO: use uuid instead of index as the key*/} + {searchResults?.map((searchResult, index) => ( +
  • handleConceptChange(searchResult)}> + {searchResult.display} +
  • + ))} +
+ ); + } + return ( + + + + {t('noResultsFor', 'No results for')} "{debouncedSearchTerm}" + + + + ); + })()} +
+
+ + item?.display || ''} + selectedItem={billableServicePayload.serviceType || null} + onChange={({ selectedItem }) => { + setBillableServicePayload({ + ...billableServicePayload, + display: selectedItem?.display, + serviceType: selectedItem, + }); + }} + placeholder="Select service type" + required + /> + +
- return ( - - -
- - +
+
+ {fields.map((field, index) => ( +
( - + + field.onChange(selectedItem.uuid)} + titleText={t('paymentMode', 'Payment Mode')} + label={t('selectPaymentMethod', 'Select payment method')} + items={paymentModes ?? []} + itemToString={(item) => (item ? item.name : '')} + selectedItem={paymentModes.find((mode) => mode.uuid === field.value)} + invalid={!!errors?.payment?.[index]?.paymentMode} + invalidText={errors?.payment?.[index]?.paymentMode?.message} + /> + )} /> - - ( - - )} - /> - - - - - - { - return ( - field.onChange(selectedItem)} - titleText={t('serviceType', 'Service type')} - items={serviceTypes ?? []} - itemToString={(item) => (item ? item.display : '')} - placeholder={t('selectServiceType', 'Select service type')} - disabled={isLoadingServiceTypes} - initialSelectedItem={field.value} - invalid={!!errors.serviceType} - invalidText={errors?.serviceType?.message} + + - ); - }} - /> - - - ( - (value ? field.onChange('ENABLED') : field.onChange('DISABLED'))} - /> + )} /> - - {renderServicePriceFields} - - {!!errors.servicePrices && ( - - )} - -
- - -
+ ))} + - - - + {getPaymentErrorMessage() &&
{getPaymentErrorMessage()}
} +
+ + +
+ + +
+ ); }; -export default AddServiceForm; +function ResponsiveWrapper({ children, isTablet }: { children: React.ReactNode; isTablet: boolean }) { + return isTablet ? {children} : <>{children}; +} + +export default AddServiceForm; \ No newline at end of file diff --git a/packages/esm-billing-app/src/billable-services/dashboard/charge-items-dashboard.component.tsx b/packages/esm-billing-app/src/billable-services/dashboard/charge-items-dashboard.component.tsx index 6d6c7b6..868e82b 100644 --- a/packages/esm-billing-app/src/billable-services/dashboard/charge-items-dashboard.component.tsx +++ b/packages/esm-billing-app/src/billable-services/dashboard/charge-items-dashboard.component.tsx @@ -2,20 +2,21 @@ import React from "react"; import { useTranslation } from "react-i18next"; import BillingHeader from "../../billing-header/billing-header.component"; import ClinicalCharges from "../clinical-charges.component"; -import LeftPanel from "../../left-panel/left-panel.component"; import styles from './dashboard.scss' - +import LeftPanel from "../../left-panel/left-panel.component"; export const ChargeItemsDashboard = () => { const { t } = useTranslation(); return ( + <> +
-
+ ); }; \ No newline at end of file