-
Notifications
You must be signed in to change notification settings - Fork 103
(feat) Retrieve scheduled appointments in clinical forms workspace #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
bf00f56
5f84c8b
a9331be
61de4ea
93b8b9d
0098d6e
5d79f3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||
| import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; | ||||||||||||||||||||||||||||||||||||||||
| import { encounterRepresentation } from '../constants'; | ||||||||||||||||||||||||||||||||||||||||
| import type { FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types'; | ||||||||||||||||||||||||||||||||||||||||
| import type { Appointment, AppointmentsPayload, FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types'; | ||||||||||||||||||||||||||||||||||||||||
| import { isUuid } from '../utils/boolean-utils'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export function saveEncounter(abortController: AbortController, payload, encounterUuid?: string) { | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -18,6 +18,65 @@ export function saveEncounter(abortController: AbortController, payload, encount | |||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export function addFulfillingEncounters( | ||||||||||||||||||||||||||||||||||||||||
| abortController: AbortController, | ||||||||||||||||||||||||||||||||||||||||
| appointments: Array<Appointment>, | ||||||||||||||||||||||||||||||||||||||||
| encounterUuid?: string, | ||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||
| const url = `${restBaseUrl}/appointment`; | ||||||||||||||||||||||||||||||||||||||||
| const filteredAppointments = appointments.filter((appointment) => { | ||||||||||||||||||||||||||||||||||||||||
| return !appointment.fulfillingEncounters.includes(encounterUuid); | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| return filteredAppointments.map((appointment) => { | ||||||||||||||||||||||||||||||||||||||||
| return updateAppointment(url, appointment, encounterUuid, abortController); | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| function updateAppointment( | ||||||||||||||||||||||||||||||||||||||||
| url: string, | ||||||||||||||||||||||||||||||||||||||||
| appointment: Appointment, | ||||||||||||||||||||||||||||||||||||||||
| encounterUuid: string | undefined, | ||||||||||||||||||||||||||||||||||||||||
| abortController: AbortController | ||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const updatedFulfillingEncounters = [...(appointment.fulfillingEncounters ?? []), encounterUuid]; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const updatedAppointment: AppointmentsPayload = { | ||||||||||||||||||||||||||||||||||||||||
| fulfillingEncounters: updatedFulfillingEncounters, | ||||||||||||||||||||||||||||||||||||||||
| serviceUuid: appointment.service.uuid, | ||||||||||||||||||||||||||||||||||||||||
| locationUuid: appointment.location.uuid, | ||||||||||||||||||||||||||||||||||||||||
| patientUuid: appointment.patient.uuid, | ||||||||||||||||||||||||||||||||||||||||
| dateAppointmentScheduled: appointment.startDateTime, | ||||||||||||||||||||||||||||||||||||||||
| appointmentKind: appointment.appointmentKind, | ||||||||||||||||||||||||||||||||||||||||
| status: appointment.status, | ||||||||||||||||||||||||||||||||||||||||
| startDateTime: appointment.startDateTime, | ||||||||||||||||||||||||||||||||||||||||
| endDateTime: appointment.endDateTime.toString(), | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| providers: [{ uuid: appointment.providers[0]?.uuid }], | ||||||||||||||||||||||||||||||||||||||||
| comments: appointment.comments, | ||||||||||||||||||||||||||||||||||||||||
| uuid: appointment.uuid | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return openmrsFetch(`${url}`, { | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| return openmrsFetch(`${url}`, { | |
| return openmrsFetch(url, { |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be useful to have some kind of error handling here? Something along the lines of:
| export const getPatientAppointment = (appointmentUuid: string) => { | |
| return openmrsFetch( | |
| `${restBaseUrl}/appointments/${appointmentUuid}`, | |
| ).then(({ data }) => { | |
| if (data) { | |
| return data; | |
| } | |
| return null; | |
| }); | |
| }; | |
| export const getPatientAppointment = async (appointmentUuid: string) => { | |
| try { | |
| const response = await openmrsFetch(`${restBaseUrl}/appointments/${appointmentUuid}`); | |
| return response.data || null; | |
| } catch (error) { | |
| console.error('Error fetching appointment:', error); | |
| throw error; // Re-throw to allow caller to handle the error | |
| } | |
| }; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,16 +1,24 @@ | ||||||
| import React from 'react'; | ||||||
| import React, { useState } from 'react'; | ||||||
| import { useTranslation } from 'react-i18next'; | ||||||
| import { showSnackbar } from '@openmrs/esm-framework'; | ||||||
| import { formatDatetime, parseDate, showSnackbar } from '@openmrs/esm-framework'; | ||||||
| import { useLaunchWorkspaceRequiringVisit } from '@openmrs/esm-patient-common-lib'; | ||||||
| import { Button } from '@carbon/react'; | ||||||
| import { type FormFieldInputProps } from '../../../types'; | ||||||
| import { type Appointment, type FormFieldInputProps } from '../../../types'; | ||||||
| import { isTrue } from '../../../utils/boolean-utils'; | ||||||
| import styles from './workspace-launcher.scss'; | ||||||
| import { useFormFactory } from '../../../provider/form-factory-provider'; | ||||||
| import { getPatientAppointment } from '../../../api'; | ||||||
| import { DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '@carbon/react'; | ||||||
|
||||||
|
|
||||||
| const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => { | ||||||
| const { t } = useTranslation(); | ||||||
| const { appointments, addAppointmentForCurrentEncounter } = useFormFactory(); | ||||||
| const launchWorkspace = useLaunchWorkspaceRequiringVisit(field.questionOptions?.workspaceName); | ||||||
|
|
||||||
| const handleAfterCreateAppointment = async (appointmentUuid: string) => { | ||||||
| addAppointmentForCurrentEncounter(appointmentUuid); | ||||||
| }; | ||||||
|
|
||||||
| const handleLaunchWorkspace = () => { | ||||||
| if (!launchWorkspace) { | ||||||
| showSnackbar({ | ||||||
|
|
@@ -20,7 +28,57 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => { | |||||
| isLowContrast: true, | ||||||
| }); | ||||||
| } | ||||||
| launchWorkspace(); | ||||||
| if (field.questionOptions?.workspaceName === 'appointments-form-workspace') { | ||||||
| launchWorkspace({ handleAfterCreateAppointment }); | ||||||
| } else { | ||||||
| launchWorkspace(); | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| const AppointmentsTable = ({ appointments }) => { | ||||||
|
||||||
| const headers = [ | ||||||
| { key: 'startDateTime', header: 'Date & Time' }, | ||||||
| { key: 'location', header: 'Location' }, | ||||||
| { key: 'service', header: 'Service' }, | ||||||
| { key: 'status', header: 'Status' }, | ||||||
|
||||||
| ]; | ||||||
|
|
||||||
| const rows = appointments.map((appointment) => ({ | ||||||
| id: `${appointment.uuid}`, | ||||||
|
||||||
| id: `${appointment.uuid}`, | |
| id: appointment.uuid, |
Isn't appointment.uuid a string?
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,13 +12,14 @@ import { useFormCollapse } from './hooks/useFormCollapse'; | |
| import { useFormWorkspaceSize } from './hooks/useFormWorkspaceSize'; | ||
| import { usePageObserver } from './components/sidebar/usePageObserver'; | ||
| import { usePatientData } from './hooks/usePatientData'; | ||
| import type { FormField, FormSchema, SessionMode } from './types'; | ||
| import type { Appointment, FormField, FormSchema, SessionMode } from './types'; | ||
| import FormProcessorFactory from './components/processor-factory/form-processor-factory.component'; | ||
| import Loader from './components/loaders/loader.component'; | ||
| import MarkdownWrapper from './components/inputs/markdown/markdown-wrapper.component'; | ||
| import PatientBanner from './components/patient-banner/patient-banner.component'; | ||
| import Sidebar from './components/sidebar/sidebar.component'; | ||
| import styles from './form-engine.scss'; | ||
| import { usePatientAppointments } from './hooks/usePatientCheckedInAppointments'; | ||
|
|
||
| interface FormEngineProps { | ||
| patientUUID: string; | ||
|
|
@@ -34,7 +35,6 @@ interface FormEngineProps { | |
| handleConfirmQuestionDeletion?: (question: Readonly<FormField>) => Promise<void>; | ||
| markFormAsDirty?: (isDirty: boolean) => void; | ||
| } | ||
|
|
||
| const FormEngine = ({ | ||
| formJson, | ||
| patientUUID, | ||
|
|
@@ -57,6 +57,10 @@ const FormEngine = ({ | |
| }, []); | ||
| const workspaceSize = useFormWorkspaceSize(ref); | ||
| const { patient, isLoadingPatient } = usePatientData(patientUUID); | ||
| const { appointments, addAppointmentForCurrentEncounter } = usePatientAppointments( | ||
| patientUUID, | ||
| encounterUUID || null, | ||
| ); | ||
| const [isLoadingDependencies, setIsLoadingDependencies] = useState(false); | ||
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
| const [isFormDirty, setIsFormDirty] = useState(false); | ||
|
|
@@ -92,6 +96,17 @@ const FormEngine = ({ | |
| return !isFormWorkspaceTooNarrow && hasMultiplePages; | ||
| }, [isFormWorkspaceTooNarrow, isLoadingDependencies, hasMultiplePages]); | ||
|
|
||
| // useEffect(() => { | ||
| // if (initialPatientAppointments) { | ||
| // setAppointments((prevAppointments) => { | ||
| // const newAppointments = initialPatientAppointments.filter( | ||
| // (newAppt) => !prevAppointments.some((appt) => appt.uuid === newAppt.uuid), | ||
| // ); | ||
| // return [...prevAppointments, ...newAppointments]; | ||
| // }); | ||
| // } | ||
| // }, [initialPatientAppointments]); | ||
|
||
|
|
||
| useEffect(() => { | ||
| reportError(formError, t('errorLoadingFormSchema', 'Error loading form schema')); | ||
| }, [formError]); | ||
|
|
@@ -126,6 +141,8 @@ const FormEngine = ({ | |
| location={session?.sessionLocation} | ||
| provider={session?.currentProvider} | ||
| visit={visit} | ||
| appointments={appointments} | ||
| addAppointmentForCurrentEncounter={addAppointmentForCurrentEncounter} | ||
| handleConfirmQuestionDeletion={handleConfirmQuestionDeletion} | ||
| isFormExpanded={isFormExpanded} | ||
| formSubmissionProps={{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { openmrsFetch, restBaseUrl, useOpenmrsSWR } from '@openmrs/esm-framework'; | ||
| import dayjs from 'dayjs'; | ||
| import useSWR, { mutate, SWRResponse } from 'swr'; | ||
| import { type AppointmentsResponse } from '../types'; | ||
| import { useCallback, useMemo, useState } from 'react'; | ||
|
||
|
|
||
| export function usePatientAppointments(patientUuid: string, encounterUUID: string) { | ||
| const [appointmentUuids, setAppointmentUuids] = useState<Array<string>>([]); | ||
|
|
||
| const startDate = useMemo(() => dayjs().subtract(6, 'month').toISOString(), []); | ||
|
|
||
| // We need to fetch the appointments with the specified fulfilling encounter | ||
| const appointmentsSearchUrl = | ||
| encounterUUID || appointmentUuids.length > 0 ? `${restBaseUrl}/appointments/search` : null; | ||
|
|
||
| const swrResult = useOpenmrsSWR<AppointmentsResponse, Error>(appointmentsSearchUrl, { | ||
| fetchInit: { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: { | ||
| patientUuid: patientUuid, | ||
| startDate: startDate, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const addAppointmentForCurrentEncounter = useCallback( | ||
| (appointmentUuid: string) => { | ||
| setAppointmentUuids((prev) => (!prev.includes(appointmentUuid) ? [...prev, appointmentUuid] : prev)); | ||
| swrResult.mutate(); | ||
| }, | ||
| [swrResult.mutate, setAppointmentUuids], | ||
| ); | ||
|
|
||
| const results = useMemo( | ||
| () => ({ | ||
| appointments: (swrResult.data?.data ?? [])?.filter( | ||
| (appointment) => | ||
| appointment.fulfillingEncounters?.includes(encounterUUID) || appointmentUuids.includes(appointment.uuid), | ||
| ), | ||
| mutateAppointments: swrResult.mutate, | ||
| isLoading: swrResult.isLoading, | ||
| error: swrResult.error, | ||
| addAppointmentForCurrentEncounter, | ||
| }), | ||
| [swrResult, addAppointmentForCurrentEncounter, appointmentUuids, encounterUUID], | ||
| ); | ||
|
|
||
| return results; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { | |||||
| savePatientPrograms, | ||||||
| } from './encounter-processor-helper'; | ||||||
| import { | ||||||
| type Appointment, | ||||||
| type FormField, | ||||||
| type FormPage, | ||||||
| type FormProcessorContextProps, | ||||||
|
|
@@ -23,7 +24,7 @@ import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-r | |||||
| import { extractErrorMessagesFromResponse } from '../../utils/error-utils'; | ||||||
| import { extractObsValueAndDisplay } from '../../utils/form-helper'; | ||||||
| import { FormProcessor } from '../form-processor'; | ||||||
| import { getPreviousEncounter, saveEncounter } from '../../api'; | ||||||
| import { addFulfillingEncounters, getPreviousEncounter, saveEncounter } from '../../api'; | ||||||
| import { hasRendering } from '../../utils/common-utils'; | ||||||
| import { isEmpty } from '../../validators/form-validator'; | ||||||
| import { formEngineAppName } from '../../globals'; | ||||||
|
|
@@ -108,7 +109,7 @@ export class EncounterFormProcessor extends FormProcessor { | |||||
| return schema; | ||||||
| } | ||||||
|
|
||||||
| async processSubmission(context: FormContextProps, abortController: AbortController) { | ||||||
| async processSubmission(context: FormContextProps, appointments: Array<Appointment>, abortController: AbortController) { | ||||||
| const { encounterRole, encounterProvider, encounterDate, encounterLocation } = getMutableSessionProps(context); | ||||||
| const translateFn = (key, defaultValue?) => translateFrom(formEngineAppName, key, defaultValue); | ||||||
| const patientIdentifiers = preparePatientIdentifiers(context.formFields, encounterLocation); | ||||||
|
|
@@ -202,6 +203,26 @@ export class EncounterFormProcessor extends FormProcessor { | |||||
| critical: true, | ||||||
| }); | ||||||
| } | ||||||
| // handle appointments | ||||||
| try { | ||||||
| const {appointments: myAppointments} = context | ||||||
|
||||||
| const appointmentsResponse = await Promise.all(addFulfillingEncounters(abortController, appointments, savedEncounter.uuid)); | ||||||
| if (appointmentsResponse?.length) { | ||||||
| showSnackbar({ | ||||||
| title: translateFn('appointmentsSaved', 'Appointment(s) saved successfully'), | ||||||
|
||||||
| title: translateFn('appointmentsSaved', 'Appointment(s) saved successfully'), | |
| title: translateFn('appointmentsSaved', 'Appointments saved successfully'), |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we modify this so that we only proceed if there are appointments?
if (appointments && appointments.length > 0) {
// ...
}
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| title: translateFn('errorSavingAppointments', 'Error saving appointment(s)'), | |
| title: translateFn('errorSavingAppointments', 'Error saving appointments'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: This code would be more concise and easier to read without the explicit return statements