diff --git a/packages/esm-commons-app/src/hooks/useEncounters.ts b/packages/esm-commons-app/src/hooks/useEncounters.ts new file mode 100644 index 0000000..ee1162f --- /dev/null +++ b/packages/esm-commons-app/src/hooks/useEncounters.ts @@ -0,0 +1,22 @@ +import { restBaseUrl, useOpenmrsFetchAll } from '@openmrs/esm-framework'; +import { type Encounter } from '../service-queues-app/types'; + +interface EncounterSearchParams { + patient: string; + visit?: string; + encounterType?: string; +} + +export function useEncounters(params: EncounterSearchParams) { + const url = `${restBaseUrl}/encounter?`; + const searchParams = new URLSearchParams(); + searchParams.append('patient', params.patient); + if (params.visit) { + searchParams.append('visit', params.visit); + } + if (params.encounterType) { + searchParams.append('encounterType', params.encounterType); + } + + return useOpenmrsFetchAll(url + searchParams.toString()); +} diff --git a/packages/esm-commons-app/src/index.ts b/packages/esm-commons-app/src/index.ts index 0c07a3a..2ba9716 100644 --- a/packages/esm-commons-app/src/index.ts +++ b/packages/esm-commons-app/src/index.ts @@ -8,10 +8,6 @@ import { getAsyncLifecycle } from '@openmrs/esm-framework'; const moduleName = '@pih/esm-commons-app'; -const options = { - featureName: 'commons', - moduleName, -}; export const importTranslation = require.context('../translations', false, /.json$/, 'lazy'); @@ -19,17 +15,27 @@ export const importTranslation = require.context('../translations', false, /.jso export const o2VisitSummaryWorkspaceSideRailIcon = getAsyncLifecycle( () => import('./ward-app/o2-visit-summary-action-button.extension'), - options, + { featureName: 'o2VisitSummaryWorkspaceSideRailIcon', moduleName }, ); export const o2VisitSummaryWorkspace = getAsyncLifecycle( () => import('./ward-app/o2-visit-summary-workspace.component'), - options, + { featureName: 'o2VisitSummaryWorkspace', moduleName }, ); export const o2PregnancyInfantDashboard = getAsyncLifecycle( () => import('./ward-app/o2-pregnancy-infant-dashboard.extension'), - options, + { featureName: 'o2PregnancyInfantDashboard', moduleName }, +); + +export const maternalTriageFormWorkspace = getAsyncLifecycle( + () => import('./service-queues-app/maternal-triage-form.workspace'), + { featureName: 'maternalTriageFormWorkspace', moduleName }, +); + +export const triageWaitingQueueActions = getAsyncLifecycle( + () => import('./service-queues-app/maternal-triage-queue-actions.extension'), + { featureName: 'triageWaitingQueueActions', moduleName }, ); export function startupApp() {} diff --git a/packages/esm-commons-app/src/routes.json b/packages/esm-commons-app/src/routes.json index 7149a75..ea2145c 100644 --- a/packages/esm-commons-app/src/routes.json +++ b/packages/esm-commons-app/src/routes.json @@ -12,6 +12,11 @@ "name": "o2-pregnancy-infant-dashboard", "component": "o2PregnancyInfantDashboard", "slot": "ward-patient-workspace-content-slot" + }, + { + "name": "triage-waiting-queue-actions", + "component": "triageWaitingQueueActions", + "slot": "queue-table-triage-waiting-queue-actions-slot" } ], "workspaces": [ @@ -25,6 +30,14 @@ "width": "extra-wide", "groups": ["ward-patient"], "canMaximize": true + }, + { + "name": "maternal-triage-form-workspace", + "component": "maternalTriageFormWorkspace", + "title": "maternalTriageForm", + "type": "maternal-triage-form", + "width": "extra-wide", + "canMaximize": true } ] } diff --git a/packages/esm-commons-app/src/service-queues-app/maternal-triage-form.workspace.tsx b/packages/esm-commons-app/src/service-queues-app/maternal-triage-form.workspace.tsx new file mode 100644 index 0000000..dc24e35 --- /dev/null +++ b/packages/esm-commons-app/src/service-queues-app/maternal-triage-form.workspace.tsx @@ -0,0 +1,103 @@ +import { type DefaultWorkspaceProps, type Patient, showSnackbar } from '@openmrs/esm-framework'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import O2IFrame from '../ward-app/o2-iframe.component'; +import { updateQueueEntry, useMutateQueueEntries } from './maternal-triage-queue-actions.resource'; +import { type QueueEntry } from './types'; +import { useEncounters } from '../hooks/useEncounters'; +import { Loading } from '@carbon/react'; +import { InlineNotification } from '@carbon/react'; + +interface MaternalTriageFormWorkspaceProps extends DefaultWorkspaceProps { + queueEntry: QueueEntry; + patient: Patient; +} + +const MATERNAL_TRIAGE_FORM_ENCOUNTER_TYPE = '41911448-71a1-43d7-bba8-dc86339850da'; + +/** + * Workspace to display the Maternal Triage HTML Form, rendered in a iframe + */ +const MaternalTriageFormWorkspace: React.FC = ({ + queueEntry, + patient, + closeWorkspace, +}) => { + const { t } = useTranslation(); + const { mutateQueueEntries } = useMutateQueueEntries(); + const { + data: filledOutTriageForms, + isLoading, + error, + } = useEncounters({ + patient: patient.uuid, + visit: queueEntry.visit.uuid, + encounterType: MATERNAL_TRIAGE_FORM_ENCOUNTER_TYPE, + }); + + useEffect(() => { + const onMessage = async (event: MessageEvent) => { + if (event.data == 'triageFormSubmitted') { + const endQueueEntry = () => { + return updateQueueEntry(queueEntry.uuid, { + endedAt: new Date().toISOString(), + }); + }; + + await endQueueEntry(); + await mutateQueueEntries(); + closeWorkspace(); + showSnackbar({ + isLowContrast: true, + kind: 'success', + title: t('triageFormSubmitted', 'Triage form successfully submitted for {{patient}}.', { + patient: patient.person.display, + }), + }); + } + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + const patientUuid = patient.uuid; + const visitUuid = queueEntry.visit.id; + + const elementsToHide = [ + 'header', + '#breadcrumbs', + + // prevent O2 success toast from showing; After submitting form for a patient, + // the success toast for that patient shows when opening form for another patient + '.toast-type-success', + ]; + + const customJavaScript = ` + htmlForm.setSuccessFunction(() => window.top.postMessage('triageFormSubmitted')) + `; + + if (isLoading) { + return ; + } else if (error) { + return ( + + ); + } else { + // If patient does not have triage form filled out already, we load a new one, + // else, we load the last filled out form to edit + const src = + filledOutTriageForms.length == 0 + ? `${window.openmrsBase}/htmlformentryui/htmlform/enterHtmlFormWithStandardUi.page?patientId=${patientUuid}&visitId=${visitUuid}&definitionUiResource=file:configuration/pih/htmlforms/triage.xml&returnUrl=%2Fopenmrs%2Fcoreapps%2Fclinicianfacing%2Fpatient.page%3FpatientId%3D${patientUuid}%26` + : `${window.openmrsBase}/htmlformentryui/htmlform/editHtmlFormWithStandardUi.page?patientId=${patientUuid}&encounterId=${filledOutTriageForms[filledOutTriageForms.length - 1].uuid}`; + + return ; + } +}; + +export default MaternalTriageFormWorkspace; diff --git a/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.extension.tsx b/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.extension.tsx new file mode 100644 index 0000000..4e6abcb --- /dev/null +++ b/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.extension.tsx @@ -0,0 +1,131 @@ +import { Button, OverflowMenu, OverflowMenuItem } from '@carbon/react'; +import { isDesktop, launchWorkspace, showModal, useConfig, useLayoutType } from '@openmrs/esm-framework'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './maternal-trial-queue-actions.scss'; +import { type QueueEntry } from './types'; +import { transitionQueueEntry, useMutateQueueEntries } from './maternal-triage-queue-actions.resource'; + +// types taken from esm-service-queues-app +interface QueueTableCellComponentProps { + queueEntry: QueueEntry; +} + +interface ConfigObject { + concepts: { + defaultTransitionStatus: string; + }; + // other fields not shown +} + +/** + * This extension provides an extra "Triage" action, along with the other standard actions for + * queue entries in the queues table. The Triage action opens the maternal triage form, and also + * has the side effect of putting the patient's queue entry into the "in service" status, if they have + * not yet already + * @param param0 + * @returns + */ +const MaternalTriageQueueActions: React.FC = ({ queueEntry }) => { + const { t } = useTranslation(); + const layout = useLayoutType(); + + const { mutateQueueEntries } = useMutateQueueEntries(); + const { concepts } = useConfig({ externalModuleName: '@openmrs/esm-service-queues-app' }); + const inServiceStatus = concepts.defaultTransitionStatus; + + const { patient } = queueEntry; + + return ( +
+ + + + { + const dispose = showModal('transition-queue-entry-modal', { + closeModal: () => dispose(), + queueEntry, + }); + }} + itemText={t('transition', 'Transition')} + /> + { + const dispose = showModal('edit-queue-entry-modal', { + closeModal: () => dispose(), + queueEntry, + }); + }} + itemText={t('edit', 'Edit')} + /> + { + const dispose = showModal('end-queue-entry-modal', { + closeModal: () => dispose(), + queueEntry, + }); + }} + itemText={t('removePatient', 'Remove patient')} + /> + {queueEntry.previousQueueEntry == null ? ( + { + const dispose = showModal('void-queue-entry-modal', { + closeModal: () => dispose(), + queueEntry, + }); + }} + itemText={t('delete', 'Delete')} + /> + ) : ( + { + const dispose = showModal('undo-transition-queue-entry-modal', { + closeModal: () => dispose(), + queueEntry, + }); + }} + itemText={t('undoTransition', 'Undo transition')} + /> + )} + +
+ ); +}; + +export default MaternalTriageQueueActions; diff --git a/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.resource.ts b/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.resource.ts new file mode 100644 index 0000000..4731e5f --- /dev/null +++ b/packages/esm-commons-app/src/service-queues-app/maternal-triage-queue-actions.resource.ts @@ -0,0 +1,80 @@ +import { type Concept, type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { useSWRConfig } from 'swr'; +import { type QueueEntry } from './types'; + +// Hooks here copied from esm-service-queues-app + +export function useMutateQueueEntries() { + const { mutate } = useSWRConfig(); + + return { + mutateQueueEntries: () => { + return mutate((key) => { + return ( + typeof key === 'string' && + (key.includes(`${restBaseUrl}/queue-entry`) || key.includes(`${restBaseUrl}/visit-queue-entry`)) + ); + }).then(() => { + window.dispatchEvent(new CustomEvent('queue-entry-updated')); + }); + }, + }; +} + +interface TransitionQueueEntryParams { + queueEntryToTransition: string; + transitionDate?: string; + newQueue?: string; + newStatus?: string; + newPriority?: string; + newPriorityComment?: string; +} + +/** + * A transition is defined as an action that ends a current queue entry and immediately starts a new one + * with (slightly) different values. For now, this could be used to transition the queue entry's status, + * priority or queue. This allows us to keep a history of queue entries through a patient's visit. + * Note that there are some use cases (like RDE or data correction) where a transition is NOT appropriate. + * @param params + * @param abortController + * @returns + */ +export function transitionQueueEntry( + params: TransitionQueueEntryParams, + abortController?: AbortController, +): Promise> { + return openmrsFetch(`${restBaseUrl}/queue-entry/transition`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: abortController?.signal, + body: params, + }); +} + +interface UpdateQueueEntryParams { + status?: Concept; + priority?: Concept; + priorityComment?: string; + sortWeight?: number; + startedAt?: string; + endedAt?: string; + loationWaitingFor?: Location; + providerWaitingFor?: Location; +} + +export function updateQueueEntry( + queueEntryUuid: string, + params: UpdateQueueEntryParams, + abortController?: AbortController, +) { + return openmrsFetch(`${restBaseUrl}/queue-entry/${queueEntryUuid}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: abortController?.signal, + body: params, + }); +} diff --git a/packages/esm-commons-app/src/service-queues-app/maternal-trial-queue-actions.scss b/packages/esm-commons-app/src/service-queues-app/maternal-trial-queue-actions.scss new file mode 100644 index 0000000..302d15c --- /dev/null +++ b/packages/esm-commons-app/src/service-queues-app/maternal-trial-queue-actions.scss @@ -0,0 +1,14 @@ +.actionCellContainer { + white-space: nowrap; +} + +.menuItem { + max-width: none; +} + +.actionsCell { + display: flex; + justify-content: flex-end; + height: 100%; + align-items: center; +} diff --git a/packages/esm-commons-app/src/service-queues-app/types.ts b/packages/esm-commons-app/src/service-queues-app/types.ts new file mode 100644 index 0000000..6f2b1ab --- /dev/null +++ b/packages/esm-commons-app/src/service-queues-app/types.ts @@ -0,0 +1,58 @@ +import { type Concept, type OpenmrsResource, type Patient, type Visit } from '@openmrs/esm-framework'; + +export interface Queue { + uuid: string; + display: string; + name: string; + description: string; + location: Location; + service: Concept; + allowedPriorities: Array; + allowedStatuses: Array; +} + +export interface QueueEntry { + uuid: string; + display: string; + endedAt: string; + locationWaitingFor: Location; + patient: Patient; + priority: Concept; + priorityComment: string | null; + providerWaitingFor: Provider; + queue: Queue; + startedAt: string; + status: Concept; + visit: Visit; + sortWeight: number; + queueComingFrom: Queue; + previousQueueEntry: QueueEntry; +} + +export interface Provider extends OpenmrsResource {} + +export interface Encounter { + uuid: string; + encounterDateTime: string; + encounterProviders: Array<{ + uuid: string; + display: string; + encounterRole: { + uuid: string; + display: string; + }; + provider: { + uuid: string; + person: { + uuid: string; + display: string; + }; + }; + }>; + encounterType: { + uuid: string; + display: string; + }; + obs: Array; + orders: Array; +} diff --git a/packages/esm-commons-app/src/ward-app/o2-iframe.component.tsx b/packages/esm-commons-app/src/ward-app/o2-iframe.component.tsx index 9b633d2..45c5831 100644 --- a/packages/esm-commons-app/src/ward-app/o2-iframe.component.tsx +++ b/packages/esm-commons-app/src/ward-app/o2-iframe.component.tsx @@ -1,9 +1,9 @@ import { ArrowLeft } from '@carbon/react/icons'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import styles from './o2-iframe.scss'; import { IconButton, InlineLoading } from '@carbon/react'; +import React, { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import styles from './o2-iframe.scss'; interface O2IFrame { src: string; diff --git a/packages/esm-commons-app/translations/en.json b/packages/esm-commons-app/translations/en.json index b47197c..68a0b59 100644 --- a/packages/esm-commons-app/translations/en.json +++ b/packages/esm-commons-app/translations/en.json @@ -1,8 +1,16 @@ { "back": "Back", + "delete": "Delete", + "edit": "Edit", + "errorLoadingPatientForm": "Error loading patient form", "fullPatientChart": "Full patient chart", "patientHasNoActiveVisit": "Patient has no active visit", "patientNotEnrolledInInfantOrPregnancyProgram": "Patient not enrolled in either Infant or Pregnancy Program", "patientVisitSummary": "Patient visit summary", + "removePatient": "Remove patient", + "transition": "Transition", + "triage": "Triage", + "triageFormSubmitted": "Triage form successfully submitted for {{patient}}.", + "undoTransition": "Undo transition", "visitSummary": "Visit summary" }