diff --git a/src/config-schema.ts b/src/config-schema.ts index 9d32cd0..34053ba 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -123,9 +123,24 @@ export const configSchema = { }, enableStockDispense: { _type: Type.Boolean, - _description: 'Enable or disable stock deduction during the dispensing process. Requires the stock management module to be installed and configured.', + _description: + 'Enable or disable stock deduction during the dispensing process. Requires the stock management module to be installed and configured.', _default: false, -}, + }, + endVisitOnDispense: { + enabled: { + _type: Type.Boolean, + _description: + 'Enables or disables the ending of the current visit upon medication dispensing. When set to true, the system will attempt to end the visit after a successful medication dispense, subject to the conditions specified in the "visitTypes" config.', + _default: false, + }, + visitTypesUuids: { + _type: Type.Array, + _description: + 'Specifies a list of visit type UUIDs that are eligible for ending upon medication dispensing. If enabled, only visits of these types will be closed. An empty array means no visit types are eligible for closure. This setting is only relevant when "enabled" is set to true.', + _default: [], + }, + }, }; export interface PharmacyConfig { @@ -168,4 +183,8 @@ export interface PharmacyConfig { }; }; enableStockDispense: boolean; + endVisitOnDispense?: { + enabled: boolean; + visitTypesUuids: Array; + }; } diff --git a/src/forms/dispense-form-handler.tsx b/src/forms/dispense-form-handler.tsx new file mode 100644 index 0000000..a1eab44 --- /dev/null +++ b/src/forms/dispense-form-handler.tsx @@ -0,0 +1,203 @@ +import { getVisitTypes, showSnackbar, updateVisit } from '@openmrs/esm-framework'; +import { saveMedicationDispense } from '../medication-dispense/medication-dispense.resource'; +import { updateMedicationRequestFulfillerStatus } from '../medication-request/medication-request.resource'; +import { type DispenseFormHandlerParams, MedicationDispenseStatus } from '../types'; +import { computeNewFulfillerStatusAfterDispenseEvent, getFulfillerStatus, getUuidFromReference } from '../utils'; +import { createStockDispenseRequestPayload, sendStockDispenseRequest } from './stock-dispense/stock.resource'; + +class Handler { + nextHandler: Handler | null; + + constructor() { + this.nextHandler = null; + } + + setNext(handler) { + this.nextHandler = handler; + return handler; + } + + handle(request: DispenseFormHandlerParams) { + if (this.nextHandler) { + return this.nextHandler.handle(request); + } + return Promise.resolve(request); + } +} + +class MedicationDispenseHandler extends Handler { + handle(request) { + const { medicationDispensePayload, medicationRequestBundle, config, abortController } = request; + return saveMedicationDispense(medicationDispensePayload, MedicationDispenseStatus.completed, abortController) + .then((response) => { + if (response.ok) { + const newFulfillerStatus = computeNewFulfillerStatusAfterDispenseEvent( + medicationDispensePayload, + medicationRequestBundle, + config.dispenseBehavior.restrictTotalQuantityDispensed, + ); + if (getFulfillerStatus(medicationRequestBundle.request) !== newFulfillerStatus) { + return updateMedicationRequestFulfillerStatus( + getUuidFromReference(medicationDispensePayload.authorizingPrescription[0].reference), + newFulfillerStatus, + ); + } + } + return response; + }) + .then((response) => { + request.response = response; + return super.handle(request); + }); + } +} + +class StockDispenseHandler extends Handler { + async handle(request) { + const { + response, + config, + inventoryItem, + patientUuid, + encounterUuid, + medicationDispensePayload, + abortController, + t, + } = request; + const { status } = response; + + if (config.enableStockDispense && (status === 201 || status === 200)) { + try { + const stockDispenseRequestPayload = createStockDispenseRequestPayload( + inventoryItem, + patientUuid, + encounterUuid, + medicationDispensePayload, + ); + await sendStockDispenseRequest(stockDispenseRequestPayload, abortController); + showSnackbar({ + isLowContrast: true, + title: t('stockDispensed', 'Stock dispensed'), + kind: 'success', + subtitle: t('stockDispensedSuccessfully', 'Stock dispensed successfully and batch level updated.'), + }); + } catch (error) { + showSnackbar({ + title: t('stockDispensedError', 'Stock dispensed error'), + kind: 'error', + isLowContrast: true, + timeoutInMs: 5000, + subtitle: error?.message, + }); + } + } + + return super.handle(request); + } +} + +class EndCurrentVisitHandler extends Handler { + async handle(request) { + const { currentVisit, abortController, closeVisitOnDispense, response, t, config } = request; + + try { + const visitTypes = await getVisitTypes().toPromise(); + const shouldCloseVisit = + shouldEndVisitOnDispense(currentVisit, visitTypes, response.status, config) && closeVisitOnDispense; + + if (shouldCloseVisit) { + const updateResponse = await updateVisit( + currentVisit.uuid, + { + stopDatetime: new Date(), + location: currentVisit.location.uuid, + startDatetime: undefined, + visitType: currentVisit?.visitType.uuid, + }, + abortController, + ).toPromise(); + + showSnackbar({ + title: t('visitClose', 'Visit closed'), + kind: 'success', + subtitle: t('visitClosedSuccessfully', 'Visit closed successfully.'), + }); + request.response = updateResponse; + } + } catch (error) { + showSnackbar({ title: 'Close visit error', kind: 'error', subtitle: error?.message }); + } + + return super.handle(request); + } +} + +class FinalResponseHandler extends Handler { + handle(request) { + const { response, closeOverlay, revalidate, encounterUuid, setIsSubmitting, mode, t } = request; + const { status } = response; + + if (status === 201 || status === 200) { + closeOverlay(); + revalidate(encounterUuid); + showSnackbar({ + isLowContrast: true, + kind: 'success', + subtitle: t('medicationListUpdated', 'Medication dispense list has been updated.'), + title: t( + mode === 'enter' ? 'medicationDispensed' : 'medicationDispenseUpdated', + mode === 'enter' ? 'Medication successfully dispensed.' : 'Dispense record successfully updated.', + ), + timeoutInMs: 5000, + }); + } else { + showSnackbar({ + title: t( + mode === 'enter' ? 'medicationDispenseError' : 'medicationDispenseUpdatedError', + mode === 'enter' ? 'Error dispensing medication.' : 'Error updating dispense record', + ), + kind: 'error', + isLowContrast: true, + subtitle: response.error?.message, + }); + setIsSubmitting(false); + } + + return super.handle(request); + } +} + +const setupChain = () => { + const medicationDispenseHandler = new MedicationDispenseHandler(); + const stockDispenseHandler = new StockDispenseHandler(); + const closeActiveVisitHandler = new EndCurrentVisitHandler(); + const finalResponseHandler = new FinalResponseHandler(); + + medicationDispenseHandler + .setNext(stockDispenseHandler) + .setNext(closeActiveVisitHandler) + .setNext(finalResponseHandler); + + return medicationDispenseHandler; +}; + +export const executeMedicationDispenseChain = (params) => { + const chain = setupChain(); + return chain.handle(params); +}; + +/** + * Determines whether the current visit should be closed based on the provided parameters. + * + * @param {object} currentVisit - The current visit object. + * @param {Array} visitTypes - An array of allowed visit types. + * @param {number} status - The status code of the request. + * @param {object} config - The configuration object. + * @returns {boolean} - Returns true if the current visit should be closed, false otherwise. + */ +function shouldEndVisitOnDispense(currentVisit, visitTypes, status, config): boolean { + if (!currentVisit || !visitTypes) return false; + + const hasAllowedVisitType = visitTypes.some((vt) => vt.uuid === currentVisit.visitType.uuid); + return hasAllowedVisitType && config.closeVisitOnDispense.enabled && (status === 200 || status === 201); +} diff --git a/src/forms/dispense-form.component.tsx b/src/forms/dispense-form.component.tsx index ee3832b..aca7af0 100644 --- a/src/forms/dispense-form.component.tsx +++ b/src/forms/dispense-form.component.tsx @@ -2,33 +2,27 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ExtensionSlot, - showNotification, - showToast, + formatDatetime, + showSnackbar, useConfig, useLayoutType, usePatient, + useVisit, } from '@openmrs/esm-framework'; -import { Button, FormLabel, InlineLoading } from '@carbon/react'; +import { Button, FormLabel, InlineLoading, Layer, Checkbox } from '@carbon/react'; import styles from './forms.scss'; import { closeOverlay } from '../hooks/useOverlay'; import { type MedicationDispense, - MedicationDispenseStatus, type MedicationRequestBundle, type InventoryItem, + type DispenseFormHandlerParams, } from '../types'; -import { saveMedicationDispense } from '../medication-dispense/medication-dispense.resource'; import MedicationDispenseReview from './medication-dispense-review.component'; -import { - computeNewFulfillerStatusAfterDispenseEvent, - getFulfillerStatus, - getUuidFromReference, - revalidate, -} from '../utils'; -import { updateMedicationRequestFulfillerStatus } from '../medication-request/medication-request.resource'; +import { revalidate } from '../utils'; import { type PharmacyConfig } from '../config-schema'; import StockDispense from './stock-dispense/stock-dispense.component'; -import { createStockDispenseRequestPayload, sendStockDispenseRequest } from './stock-dispense/stock.resource'; +import { executeMedicationDispenseChain } from './dispense-form-handler'; interface DispenseFormProps { medicationDispense: MedicationDispense; @@ -50,11 +44,13 @@ const DispenseForm: React.FC = ({ const { t } = useTranslation(); const isTablet = useLayoutType() === 'tablet'; const { patient, isLoading } = usePatient(patientUuid); + const { currentVisit } = useVisit(patientUuid); const config = useConfig(); // Keep track of inventory item const [inventoryItem, setInventoryItem] = useState(); - + // Keep track of whether to close visit on dispense + const [closeVisitOnDispense, setCloseVisitOnDispense] = useState(false); // Keep track of medication dispense payload const [medicationDispensePayload, setMedicationDispensePayload] = useState(); @@ -69,79 +65,36 @@ const DispenseForm: React.FC = ({ if (!isSubmitting) { setIsSubmitting(true); const abortController = new AbortController(); - saveMedicationDispense(medicationDispensePayload, MedicationDispenseStatus.completed, abortController) - .then((response) => { - if (response.ok) { - const newFulfillerStatus = computeNewFulfillerStatusAfterDispenseEvent( - medicationDispensePayload, - medicationRequestBundle, - config.dispenseBehavior.restrictTotalQuantityDispensed, - ); - if (getFulfillerStatus(medicationRequestBundle.request) !== newFulfillerStatus) { - return updateMedicationRequestFulfillerStatus( - getUuidFromReference( - medicationDispensePayload.authorizingPrescription[0].reference, // assumes authorizing prescription exist - ), - newFulfillerStatus, - ); - } - } - return response; - }) - .then((response) => { - const { status } = response; - if (config.enableStockDispense && (status === 201 || status === 200)) { - const stockDispenseRequestPayload = createStockDispenseRequestPayload( - inventoryItem, - patientUuid, - encounterUuid, - medicationDispensePayload, - ); - sendStockDispenseRequest(stockDispenseRequestPayload, abortController).then( - () => { - showToast({ - critical: true, - title: t('stockDispensed', 'Stock dispensed'), - kind: 'success', - description: t('stockDispensedSuccessfully', 'Stock dispensed successfully and batch level updated.'), - }); - }, - (error) => { - showToast({ title: 'Stock dispense error', kind: 'error', description: error?.message }); - }, - ); - } - return response; - }) - .then( - ({ status }) => { - if (status === 201 || status === 200) { - closeOverlay(); - revalidate(encounterUuid); - showToast({ - critical: true, - kind: 'success', - description: t('medicationListUpdated', 'Medication dispense list has been updated.'), - title: t( - mode === 'enter' ? 'medicationDispensed' : 'medicationDispenseUpdated', - mode === 'enter' ? 'Medication successfully dispensed.' : 'Dispense record successfully updated.', - ), - }); - } - }, - (error) => { - showNotification({ - title: t( - mode === 'enter' ? 'medicationDispenseError' : 'medicationDispenseUpdatedError', - mode === 'enter' ? 'Error dispensing medication.' : 'Error updating dispense record', - ), - kind: 'error', - critical: true, - description: error?.message, - }); - setIsSubmitting(false); - }, - ); + + const dispenseFormHandlerParams: DispenseFormHandlerParams = { + medicationDispensePayload, + medicationRequestBundle, + config, + inventoryItem, + patientUuid, + encounterUuid, + abortController, + closeOverlay, + revalidate, + closeVisitOnDispense, + setIsSubmitting, + mode, + t, + currentVisit, + }; + + executeMedicationDispenseChain(dispenseFormHandlerParams).catch((error) => { + showSnackbar({ + title: t( + mode === 'enter' ? 'medicationDispenseError' : 'medicationDispenseUpdatedError', + mode === 'enter' ? 'Error dispensing medication.' : 'Error updating dispense record', + ), + kind: 'error', + isLowContrast: true, + subtitle: error?.message, + }); + setIsSubmitting(false); + }); } }; @@ -172,6 +125,13 @@ const DispenseForm: React.FC = ({ useEffect(checkIsValid, [medicationDispensePayload, quantityRemaining, inventoryItem]); const isButtonDisabled = (config.enableStockDispense ? !inventoryItem : false) || !isValid || isSubmitting; + const shouldEndCurrentVisitCheckbox = useMemo(() => { + return ( + config.endVisitOnDispense && + currentVisit && + config.endVisitOnDispense.visitTypesUuids.includes(currentVisit.visitType.uuid) + ); + }, [config.endVisitOnDispense, currentVisit]); const bannerState = useMemo(() => { if (patient) { @@ -198,8 +158,8 @@ const DispenseForm: React.FC = ({
{t( - config.dispenseBehavior.allowModifyingPrescription ? 'drugHelpText' : 'drugHelpTextNoEdit', - config.dispenseBehavior.allowModifyingPrescription + config.dispenseBehavior?.allowModifyingPrescription ? 'drugHelpText' : 'drugHelpTextNoEdit', + config.dispenseBehavior?.allowModifyingPrescription ? 'You may edit the formulation and quantity dispensed here' : 'You may edit quantity dispensed here', )} @@ -218,6 +178,21 @@ const DispenseForm: React.FC = ({ updateInventoryItem={setInventoryItem} /> )} + {shouldEndCurrentVisitCheckbox && ( + + setCloseVisitOnDispense(checked)} + /> + + )} ) : null}
diff --git a/src/forms/forms.scss b/src/forms/forms.scss index 71a79df..fbb1fab 100644 --- a/src/forms/forms.scss +++ b/src/forms/forms.scss @@ -1,6 +1,7 @@ @use '@carbon/styles/scss/spacing'; @use '@carbon/styles/scss/type'; -@import '~@openmrs/esm-styleguide/src/vars'; +@use '@carbon/colors'; +@use '~@openmrs/esm-styleguide/src/vars' as *; // TO DO Move this styles to style - guide // https://github.com/openmrs/openmrs-esm-core/blob/master/packages/framework/esm-styleguide/src/_vars.scss @@ -33,7 +34,7 @@ $color-blue-30: #a6c8ff; flex: 3; } -.formGroup span { +.formGroup>span { @extend .productiveHeading02; } @@ -152,3 +153,11 @@ $color-blue-30: #a6c8ff; .reviewContainer { margin: 0.8rem 0 0 0 !important; } + +.closeVisitCheckBox { + & label { + @include type.type-style('label-01'); + margin-top: spacing.$spacing-05; + color: colors.$gray-70; + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b73ef30..7e0d6e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ -import { type OpenmrsResource } from '@openmrs/esm-framework'; +import { type OpenmrsFetchError, type Visit, type OpenmrsResource } from '@openmrs/esm-framework'; +import { type PharmacyConfig } from './config-schema'; +import { type TFunction } from 'react-i18next'; export interface AllergyIntolerance { resourceType: string; @@ -510,3 +512,23 @@ export type StockDispenseRequest = { stockItemPackagingUOM: string; quantity: number; }; + +export type DispenseFormHandlerParams = { + medicationDispensePayload: MedicationDispense; + medicationRequestBundle: MedicationRequestBundle; + config: PharmacyConfig; + inventoryItem: InventoryItem | undefined; + patientUuid: string | undefined; + encounterUuid: string; + abortController: AbortController; + closeOverlay: () => void; + revalidate: (encounterUuid: string) => void; + closeVisitOnDispense: boolean; + setIsSubmitting: (isSubmitting: boolean) => void; + mode: 'enter' | 'edit'; + t: TFunction<'translation', undefined>; + currentVisit: Visit; + response?: Record; + errors?: Array; + [key: string]: any; +};