diff --git a/src/api/index.ts b/src/api/index.ts index 72fe0f44..c2624656 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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) { @@ -18,6 +25,51 @@ export function saveEncounter(abortController: AbortController, payload, encount }); } +export function addEncounterToAppointments( + appointments: Array, + 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, + }; + + 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`; diff --git a/src/components/inputs/workspace-launcher/workspace-launcher.component.tsx b/src/components/inputs/workspace-launcher/workspace-launcher.component.tsx index 4f3042b3..0ea04251 100644 --- a/src/components/inputs/workspace-launcher/workspace-launcher.component.tsx +++ b/src/components/inputs/workspace-launcher/workspace-launcher.component.tsx @@ -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 = ({ 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({ @@ -20,7 +30,11 @@ const WorkspaceLauncher: React.FC = ({ field }) => { isLowContrast: true, }); } - launchWorkspace(); + if (field.meta?.handleAppointmentCreation) { + launchWorkspace({ handleAfterCreateAppointment }); + } else { + launchWorkspace(); + } }; return ( @@ -32,9 +46,85 @@ const WorkspaceLauncher: React.FC = ({ field }) => { {t(field.questionOptions?.buttonLabel) ?? t('launchWorkspace', 'Launch Workspace')} + {field.meta?.handleAppointmentCreation && } ) ); }; +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 ( + + ); + } + + if (rows.length === 0) { + return null; + } + + return ( + + {({ rows, headers, getTableProps, getHeaderProps, getRowProps, getCellProps }) => ( + + + + {headers.map((header) => ( + + {header.header} + + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => ( + + {cell.value} + + ))} + + ))} + +
+ )} +
+ ); +}; + export default WorkspaceLauncher; diff --git a/src/components/processor-factory/form-processor-factory.component.tsx b/src/components/processor-factory/form-processor-factory.component.tsx index 34c5db46..8ef2be88 100644 --- a/src/components/processor-factory/form-processor-factory.component.tsx +++ b/src/components/processor-factory/form-processor-factory.component.tsx @@ -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]; @@ -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); diff --git a/src/form-engine.component.tsx b/src/form-engine.component.tsx index 7ad4def6..2108d8b6 100644 --- a/src/form-engine.component.tsx +++ b/src/form-engine.component.tsx @@ -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; @@ -34,7 +35,6 @@ interface FormEngineProps { handleConfirmQuestionDeletion?: (question: Readonly) => Promise; markFormAsDirty?: (isDirty: boolean) => void; } - const FormEngine = ({ formJson, patientUUID, @@ -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); @@ -126,6 +127,7 @@ const FormEngine = ({ location={session?.sessionLocation} provider={session?.currentProvider} visit={visit} + patientAppointments={patientAppointments} handleConfirmQuestionDeletion={handleConfirmQuestionDeletion} isFormExpanded={isFormExpanded} formSubmissionProps={{ diff --git a/src/hooks/usePatientAppointments.ts b/src/hooks/usePatientAppointments.ts new file mode 100644 index 00000000..c296bf75 --- /dev/null +++ b/src/hooks/usePatientAppointments.ts @@ -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; + /** + * The newly created appointments that doesn't have fulfilling encounters yet + */ + newlyCreatedAppointments: Array; + 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>([]); + + 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(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; +} diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 00cb6435..52206d2b 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -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 { addEncounterToAppointments, getPreviousEncounter, saveEncounter } from '../../api'; import { hasRendering } from '../../utils/common-utils'; import { isEmpty } from '../../validators/form-validator'; import { formEngineAppName } from '../../globals'; @@ -202,6 +203,30 @@ export class EncounterFormProcessor extends FormProcessor { critical: true, }); } + // handle appointments + try { + const { newlyCreatedAppointments } = context; + const appointmentsResponse = await addEncounterToAppointments( + newlyCreatedAppointments, + savedEncounter.uuid, + abortController, + ); + if (appointmentsResponse?.length) { + showSnackbar({ + title: translateFn('appointmentsSaved', 'Appointments saved successfully'), + kind: 'success', + isLowContrast: true, + }); + } + } catch (error) { + const errorMessages = Array.isArray(error) ? error.map((err) => err.message) : [error.message]; + return Promise.reject({ + title: translateFn('errorSavingAppointments', 'Error saving appointments'), + description: errorMessages.join(', '), + kind: 'error', + critical: true, + }); + } return savedEncounter; } catch (error) { const errorMessages = extractErrorMessagesFromResponse(error); diff --git a/src/processors/form-processor.ts b/src/processors/form-processor.ts index a58e5947..1516ba78 100644 --- a/src/processors/form-processor.ts +++ b/src/processors/form-processor.ts @@ -1,6 +1,6 @@ import { type OpenmrsResource } from '@openmrs/esm-framework'; import { type FormContextProps } from '../provider/form-provider'; -import { type ValueAndDisplay, type FormField, type FormSchema } from '../types'; +import { type ValueAndDisplay, type FormField, type FormSchema, type Appointment } from '../types'; import { type FormProcessorContextProps } from '../types'; export type FormProcessorConstructor = new (...args: ConstructorParameters) => FormProcessor; diff --git a/src/provider/form-factory-provider.tsx b/src/provider/form-factory-provider.tsx index 019387f8..1e8d78af 100644 --- a/src/provider/form-factory-provider.tsx +++ b/src/provider/form-factory-provider.tsx @@ -1,5 +1,5 @@ import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react'; -import { type FormField, type FormSchema, type SessionMode } from '../types'; +import type { Appointment, FormField, FormSchema, SessionMode } from '../types'; import { EncounterFormProcessor } from '../processors/encounter/encounter-form-processor'; import { type LayoutType, @@ -14,6 +14,7 @@ import { type FormContextProps } from './form-provider'; import { processPostSubmissionActions, validateForm } from './form-factory-helper'; import { useTranslation } from 'react-i18next'; import { usePostSubmissionActions } from '../hooks/usePostSubmissionActions'; +import { type UsePatientAppointmentsResults } from 'src/hooks/usePatientAppointments'; interface FormFactoryProviderContextProps { patient: fhir.Patient; @@ -24,6 +25,7 @@ interface FormFactoryProviderContextProps { layoutType: LayoutType; workspaceLayout: 'minimized' | 'maximized'; visit: OpenmrsResource; + patientAppointments: UsePatientAppointmentsResults; location: OpenmrsResource; provider: OpenmrsResource; isFormExpanded: boolean; @@ -41,6 +43,7 @@ interface FormFactoryProviderProps { location: OpenmrsResource; provider: OpenmrsResource; visit: OpenmrsResource; + patientAppointments: UsePatientAppointmentsResults; isFormExpanded: boolean; children: React.ReactNode; formSubmissionProps: { @@ -66,6 +69,7 @@ export const FormFactoryProvider: React.FC = ({ location, provider, visit, + patientAppointments, isFormExpanded = true, children, formSubmissionProps, @@ -163,6 +167,7 @@ export const FormFactoryProvider: React.FC = ({ layoutType, workspaceLayout, visit, + patientAppointments, location, provider, isFormExpanded, diff --git a/src/types/domain.ts b/src/types/domain.ts index 39f12d52..020359f7 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -223,3 +223,71 @@ export interface Diagnosis { formFieldNamespace?: string; formFieldPath?: string; } + +export type AppointmentsResponse = Array; + +export interface AppointmentService { + appointmentServiceId: number; + color: string; + creatorName: string; + description: string; + durationMins: string; + endTime: string; + initialAppointmentStatus: string; + location: OpenmrsResource; + maxAppointmentsLimit: number | null; + name: string; + speciality: OpenmrsResource; + startTime: string; + uuid: string; + serviceTypes: Array; +} + +export interface ServiceTypes { + duration: number; + name: string; + uuid: string; +} + +export interface Appointment { + appointmentKind: string; + appointmentNumber: string; + comments: string; + endDateTime: Date | number; + location: OpenmrsResource; + patient: { + uuid: string; + name: string; + identifier: string; + gender: string; + age: string; + phoneNumber: string; + }; + provider: OpenmrsResource; + providers: Array; + // recurring: boolean; + service: AppointmentService; + startDateTime: number | any; + status: string; + fulfillingEncounters: Array; + uuid: string; +} + +export interface AppointmentsPayload { + appointmentKind: string; + status: string; + serviceUuid: string; + startDateTime: string; + endDateTime: string; + locationUuid: string; + providers: [ + { + uuid: string; + }, + ]; + patientUuid: string; + comments: string; + uuid: string; + dateAppointmentScheduled: string; + fulfillingEncounters: Array; +} diff --git a/src/types/index.ts b/src/types/index.ts index 39434ae2..8dcbb38f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,7 @@ import { type LayoutType, type OpenmrsResource } from '@openmrs/esm-framework'; import { type FormProcessor } from '../processors/form-processor'; import { type FormContextProps } from '../provider/form-provider'; import { type FormField, type FormSchema } from './schema'; -import { type OpenmrsEncounter } from './domain'; +import { type Appointment, type OpenmrsEncounter } from './domain'; export type SessionMode = 'edit' | 'enter' | 'view' | 'embedded-view'; @@ -10,6 +10,7 @@ export interface FormProcessorContextProps { patient: fhir.Patient; formJson: FormSchema; visit: OpenmrsResource; + newlyCreatedAppointments?: Array; sessionMode: SessionMode; sessionDate: Date; location: OpenmrsResource;