Skip to content
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

(feat) Retrieve scheduled appointments in clinical forms workspace #471

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { fhirBaseUrl, openmrsFetch, restBaseUrl, toOmrsIsoString } 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) {
Expand All @@ -18,6 +25,51 @@ export function saveEncounter(abortController: AbortController, payload, encount
});
}

export function addEncounterToAppointments(
appointments: Array<Appointment>,
encounterUuid: string,
abortController: AbortController,
) {
const filteredAppointments = appointments.filter((appointment) => {
return !appointment.fulfillingEncounters.includes(encounterUuid);
});
return Promise.all(
filteredAppointments.map((appointment) => updateAppointment(appointment, encounterUuid, abortController)),
);
}

function updateAppointment(
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: toOmrsIsoString(appointment.endDateTime),
providers: [{ uuid: appointment.providers[0]?.uuid }],
comments: appointment.comments,
uuid: appointment.uuid,
Comment on lines +48 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this weird that for updating 1 value of the appointment, we need to pass the whole object?

Can this be worked on?
CC: @denniskigen @jnsereko @ibacher @mogoodrich

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be worked on?

Agree with you @vasharma05. A nice improvement to do in the backend

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jnsereko , can you create a Backend Ticket for this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree... we should likely have a REST endpoint just for adding a fulfilling encounter to an appointment.

};

return openmrsFetch(`${restBaseUrl}/appointment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: updatedAppointment,
signal: abortController.signal,
});
}

export function saveAttachment(patientUuid, field, conceptUuid, date, encounterUUID, abortController) {
const url = `${restBaseUrl}/attachment`;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import React from 'react';
import React, { useMemo, 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 { DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '@carbon/react';
import { InlineNotification } from '@carbon/react';

const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
const { t } = useTranslation();
const {
patientAppointments: { addAppointmentForCurrentEncounter },
} = useFormFactory();
const launchWorkspace = useLaunchWorkspaceRequiringVisit(field.questionOptions?.workspaceName);

const handleAfterCreateAppointment = async (appointmentUuid: string) => {
addAppointmentForCurrentEncounter(appointmentUuid);
};

const handleLaunchWorkspace = () => {
if (!launchWorkspace) {
showSnackbar({
Expand All @@ -20,7 +30,11 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
isLowContrast: true,
});
}
launchWorkspace();
if (field.meta?.handleAppointmentCreation) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirupius @jnsereko, instead of having the check by the workspace name, since workspace names can be changed, I added a check by adding new prop handleAppointmentCreation in the field meta

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirupius @jnsereko, instead of having the check by the workspace name, since workspace names can be changed, I added a check by adding new prop handleAppointmentCreation in the field meta

Should it really be optional though @vasharma05

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirupius , did you have a look here?

launchWorkspace({ handleAfterCreateAppointment });
} else {
launchWorkspace();
}
};

return (
Expand All @@ -32,9 +46,85 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
{t(field.questionOptions?.buttonLabel) ?? t('launchWorkspace', 'Launch Workspace')}
</Button>
</div>
{field.meta?.handleAppointmentCreation && <AppointmentsTable />}
</div>
)
);
};

const AppointmentsTable: React.FC = () => {
const { t } = useTranslation();
const {
patientAppointments: { appointments, errorFetchingAppointments },
} = useFormFactory();

const headers = useMemo(
() => [
{ key: 'startDateTime', header: t('appointmentDatetime', 'Date & time') },
{ key: 'location', header: t('location', 'Location') },
{ key: 'service', header: t('service', 'Service') },
{ key: 'status', header: t('status', 'Status') },
],
[t],
);

const rows = useMemo(
() =>
appointments.map((appointment) => ({
id: appointment.uuid,
startDateTime: formatDatetime(parseDate(appointment.startDateTime), {
mode: 'standard',
}),
location: appointment?.location?.name ? appointment?.location?.name : '——',
service: appointment.service.name,
status: appointment.status,
})),
[appointments],
);

if (errorFetchingAppointments) {
return (
<InlineNotification
kind="error"
title={t('errorFetchingAppointments', 'Error fetching appointments')}
subtitle={errorFetchingAppointments?.message}
lowContrast={false}
/>
);
}

if (rows.length === 0) {
return null;
}

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>
);
};

export default WorkspaceLauncher;
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,17 @@ const FormProcessorFactory = ({
isSubForm = false,
setIsLoadingFormDependencies,
}: FormProcessorFactoryProps) => {
const { patient, sessionMode, formProcessors, layoutType, location, provider, sessionDate, visit } = useFormFactory();
const {
patient,
sessionMode,
formProcessors,
layoutType,
location,
provider,
sessionDate,
visit,
patientAppointments: { newlyCreatedAppointments },
} = useFormFactory();

const processor = useMemo(() => {
const ProcessorClass = formProcessors[formJson.processor];
Expand All @@ -48,10 +58,12 @@ const FormProcessorFactory = ({
processor,
sessionDate,
visit,
newlyCreatedAppointments,
formFields: [],
formFieldAdapters: {},
formFieldValidators: {},
});

const { t } = useTranslation();
const { formFields: rawFormFields, conceptReferences } = useFormFields(formJson);
const { concepts: formFieldsConcepts, isLoading: isLoadingConcepts } = useConcepts(conceptReferences);
Expand Down
6 changes: 4 additions & 2 deletions src/form-engine.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/usePatientAppointments';

interface FormEngineProps {
patientUUID: string;
Expand All @@ -34,7 +35,6 @@ interface FormEngineProps {
handleConfirmQuestionDeletion?: (question: Readonly<FormField>) => Promise<void>;
markFormAsDirty?: (isDirty: boolean) => void;
}

const FormEngine = ({
formJson,
patientUUID,
Expand All @@ -57,6 +57,7 @@ const FormEngine = ({
}, []);
const workspaceSize = useFormWorkspaceSize(ref);
const { patient, isLoadingPatient } = usePatientData(patientUUID);
const patientAppointments = usePatientAppointments(patientUUID, encounterUUID);
const [isLoadingDependencies, setIsLoadingDependencies] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isFormDirty, setIsFormDirty] = useState(false);
Expand Down Expand Up @@ -126,6 +127,7 @@ const FormEngine = ({
location={session?.sessionLocation}
provider={session?.currentProvider}
visit={visit}
patientAppointments={patientAppointments}
handleConfirmQuestionDeletion={handleConfirmQuestionDeletion}
isFormExpanded={isFormExpanded}
formSubmissionProps={{
Expand Down
100 changes: 100 additions & 0 deletions src/hooks/usePatientAppointments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { restBaseUrl, useOpenmrsSWR } from '@openmrs/esm-framework';
import dayjs from 'dayjs';
import type { Appointment, AppointmentsResponse } from '../types';
import { useCallback, useMemo, useState } from 'react';

export interface UsePatientAppointmentsResults {
/**
* All the appointments for the patient and encounter, including the newly created appointments
*/
appointments: Array<Appointment>;
/**
* The newly created appointments that doesn't have fulfilling encounters yet
*/
newlyCreatedAppointments: Array<Appointment>;
isLoadingAppointments: boolean;
errorFetchingAppointments: Error;
isValidatingAppointments: boolean;
/**
* When new appointments are created, they need to be added to the list of newly created appointments
* @param appointmentUuid The UUID of the appointment to add
*/
addAppointmentForCurrentEncounter: (appointmentUuid: string) => void;
}

/**
* Returns the appointments for the specified patient and encounter.
*
* This hook filters the appointments either the specified encounter UUID,
* or the newly created appointments that doesn't have fulfilling encounters yet
* @param patientUuid The UUID of the patient
* @param encounterUUID The encounter UUID to filter the appointments by their fulfilling encounters
*/
export function usePatientAppointments(patientUuid: string, encounterUUID: string): UsePatientAppointmentsResults {
const [newlyCreatedAppointmentUuids, setNewlyCreatedAppointmentUuids] = 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 || newlyCreatedAppointmentUuids.length > 0 ? `${restBaseUrl}/appointments/search` : null;

const {
data,
isLoading: isLoadingAppointments,
error: errorFetchingAppointments,
isValidating: isValidatingAppointments,
mutate: refetchAppointments,
} = useOpenmrsSWR<AppointmentsResponse, Error>(appointmentsSearchUrl, {
fetchInit: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
patientUuid: patientUuid,
startDate: startDate,
},
},
});

const addAppointmentForCurrentEncounter = useCallback(
(appointmentUuid: string) => {
setNewlyCreatedAppointmentUuids((prev) => (!prev.includes(appointmentUuid) ? [...prev, appointmentUuid] : prev));
refetchAppointments();
},
[refetchAppointments, setNewlyCreatedAppointmentUuids],
);

const results = useMemo(() => {
const appointmentsWithEncounter = [];
const newlyCreatedAppointments = [];

(data?.data ?? [])?.forEach((appointment) => {
if (appointment.fulfillingEncounters?.includes(encounterUUID)) {
appointmentsWithEncounter.push(appointment);
} else if (newlyCreatedAppointmentUuids.includes(appointment.uuid)) {
newlyCreatedAppointments.push(appointment);
}
});

return {
appointments: [...newlyCreatedAppointments, ...appointmentsWithEncounter],
newlyCreatedAppointments,
isLoadingAppointments,
errorFetchingAppointments,
isValidatingAppointments,
addAppointmentForCurrentEncounter,
};
}, [
addAppointmentForCurrentEncounter,
data,
encounterUUID,
errorFetchingAppointments,
isLoadingAppointments,
isValidatingAppointments,
newlyCreatedAppointmentUuids,
]);

return results;
}
Loading