-
Notifications
You must be signed in to change notification settings - Fork 85
(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(), | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need |
||||||||||||||||||||||||||||||||||||||||
providers: [{ uuid: appointment.providers[0]?.uuid }], | ||||||||||||||||||||||||||||||||||||||||
comments: appointment.comments, | ||||||||||||||||||||||||||||||||||||||||
uuid: appointment.uuid | ||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
return openmrsFetch(`${url}`, { | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
method: 'POST', | ||||||||||||||||||||||||||||||||||||||||
headers: { | ||||||||||||||||||||||||||||||||||||||||
'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
body: updatedAppointment, | ||||||||||||||||||||||||||||||||||||||||
signal: abortController.signal, | ||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
export const getPatientAppointment = (appointmentUuid: string) => { | ||||||||||||||||||||||||||||||||||||||||
return openmrsFetch( | ||||||||||||||||||||||||||||||||||||||||
`${restBaseUrl}/appointments/${appointmentUuid}`, | ||||||||||||||||||||||||||||||||||||||||
).then(({ data }) => { | ||||||||||||||||||||||||||||||||||||||||
if (data) { | ||||||||||||||||||||||||||||||||||||||||
return data; | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
return null; | ||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
export function saveAttachment(patientUuid, field, conceptUuid, date, encounterUUID, abortController) { | ||||||||||||||||||||||||||||||||||||||||
const url = `${restBaseUrl}/attachment`; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
|
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'; | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we clean up the unused imports here? Also, can we consolidate the |
||||||
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 = [ | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this component have empty and loading states? We're fetching appointments from the backend, right? So it's not always guaranteed that we'll have something to render from the get-go. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jnsereko , never create a component inside another component. |
||||||
{ key: 'startDateTime', header: 'Date & Time' }, | ||||||
{ key: 'location', header: 'Location' }, | ||||||
{ key: 'service', header: 'Service' }, | ||||||
{ key: 'status', header: 'Status' }, | ||||||
]; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These header titles should be translated. |
||||||
|
||||||
const rows = appointments.map((appointment) => ({ | ||||||
id: `${appointment.uuid}`, | ||||||
startDateTime: formatDatetime(parseDate(appointment.startDateTime)), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Isn't |
||||||
location: appointment?.location?.name ? appointment?.location?.name : '——', | ||||||
service: appointment.service.name, | ||||||
status: appointment.status, | ||||||
})); | ||||||
|
||||||
return ( | ||||||
<DataTable rows={rows} headers={headers}> | ||||||
{({ rows, headers, getTableProps, getHeaderProps, getRowProps, getCellProps }) => ( | ||||||
<Table {...getTableProps()}> | ||||||
<TableHead> | ||||||
<TableRow> | ||||||
{headers.map((header) => ( | ||||||
<TableHeader key={header.key} {...getHeaderProps({ header })}> | ||||||
{header.header} | ||||||
</TableHeader> | ||||||
))} | ||||||
</TableRow> | ||||||
</TableHead> | ||||||
<TableBody> | ||||||
{rows.map((row) => ( | ||||||
<TableRow key={row.id} {...getRowProps({ row })}> | ||||||
{row.cells.map((cell) => ( | ||||||
<TableCell key={cell.id} {...getCellProps({ cell })}> | ||||||
{cell.value} | ||||||
</TableCell> | ||||||
))} | ||||||
</TableRow> | ||||||
))} | ||||||
</TableBody> | ||||||
</Table> | ||||||
)} | ||||||
</DataTable> | ||||||
); | ||||||
}; | ||||||
|
||||||
return ( | ||||||
|
@@ -32,6 +90,11 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => { | |||||
{t(field.questionOptions?.buttonLabel) ?? t('launchWorkspace', 'Launch Workspace')} | ||||||
</Button> | ||||||
</div> | ||||||
{appointments.length !== 0 && ( | ||||||
<div> | ||||||
<AppointmentsTable appointments={appointments} /> | ||||||
</div> | ||||||
)} | ||||||
</div> | ||||||
) | ||||||
); | ||||||
|
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]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's remove this if it's not being used anywhere. |
||
|
||
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like we're not using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes @denniskigen, working on it! |
||
|
||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like we're not using this code |
||||||
const appointmentsResponse = await Promise.all(addFulfillingEncounters(abortController, appointments, savedEncounter.uuid)); | ||||||
if (appointmentsResponse?.length) { | ||||||
showSnackbar({ | ||||||
title: translateFn('appointmentsSaved', 'Appointment(s) saved successfully'), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does
Suggested change
|
||||||
kind: 'success', | ||||||
isLowContrast: true, | ||||||
}); | ||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) {
// ...
} |
||||||
} catch (error) { | ||||||
const errorMessages = Array.isArray(error) ? error.map((err) => err.message) : [error.message]; | ||||||
return Promise.reject({ | ||||||
title: translateFn('errorSavingAppointments', 'Error saving appointment(s)'), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
description: errorMessages.join(', '), | ||||||
kind: 'error', | ||||||
critical: true, | ||||||
}); | ||||||
} | ||||||
return savedEncounter; | ||||||
} catch (error) { | ||||||
const errorMessages = extractErrorMessagesFromResponse(error); | ||||||
|
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