diff --git a/apps/ehr/src/components/AppointmentTableRow.tsx b/apps/ehr/src/components/AppointmentTableRow.tsx index 05c1be4984..0542fd5d7e 100644 --- a/apps/ehr/src/components/AppointmentTableRow.tsx +++ b/apps/ehr/src/components/AppointmentTableRow.tsx @@ -1,4 +1,5 @@ import { progressNoteIcon, startIntakeIcon } from '@ehrTheme/icons'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; import ChatOutlineIcon from '@mui/icons-material/ChatOutlined'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; @@ -30,6 +31,7 @@ import { enqueueSnackbar } from 'notistack'; import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { FEATURE_FLAGS } from 'src/constants/feature-flags'; +import { getInPersonUrlByAppointmentType } from 'src/features/visits/in-person/routing/helpers'; import { ROUTER_PATH } from 'src/features/visits/in-person/routing/routesInPerson'; import { VitalsIconTooltip } from 'src/features/visits/shared/components/VitalsIconTooltip'; import { TrackingBoardTableButton } from 'src/features/visits/telemed/components/tracking-board/TrackingBoardTableButton'; @@ -577,7 +579,7 @@ export default function AppointmentTableRow({ }, oystehrZambda ); - navigate(`/in-person/${appointment.id}/patient-info`); + navigate(getInPersonUrlByAppointmentType(appointment, 'patient-info')); } catch (error) { console.error(error); enqueueSnackbar('An error occurred. Please try again.', { variant: 'error' }); @@ -630,7 +632,7 @@ export default function AppointmentTableRow({ const handleProgressNoteButton = async (): Promise => { setProgressNoteButtonLoading(true); try { - navigate(`/in-person/${appointment.id}/${ROUTER_PATH.REVIEW_AND_SIGN}`); + navigate(getInPersonUrlByAppointmentType(appointment, ROUTER_PATH.REVIEW_AND_SIGN)); } catch (error) { console.error(error); enqueueSnackbar('An error occurred. Please try again.', { variant: 'error' }); @@ -893,15 +895,22 @@ export default function AppointmentTableRow({ - - - {patientName} - - + + + + {patientName} + + + {appointment.isFollowUp && ( + + + + )} + {appointment.needsDOBConfirmation ? ( @@ -1050,7 +1059,7 @@ export default function AppointmentTableRow({ navigate(`/visit/${appointment.id}`)} + onClick={() => navigate(getInPersonUrlByAppointmentType(appointment, 'review-and-sign'))} dataTestId={dataTestIds.dashboard.visitDetailsButton} > diff --git a/apps/ehr/src/components/AppointmentTableRowMobile.tsx b/apps/ehr/src/components/AppointmentTableRowMobile.tsx index b890dc5f39..1270dd9a99 100644 --- a/apps/ehr/src/components/AppointmentTableRowMobile.tsx +++ b/apps/ehr/src/components/AppointmentTableRowMobile.tsx @@ -3,6 +3,7 @@ import { Box, capitalize, Grid, Modal, TableCell, TableRow, Typography } from '@ import { CSSProperties, ReactElement, useState } from 'react'; import { Link } from 'react-router-dom'; import { RecordAudioContainer } from 'src/features/visits/in-person/components/progress-note/RecordAudioContainer'; +import { getInPersonUrlByAppointmentType } from 'src/features/visits/in-person/routing/helpers'; import { InPersonAppointmentInformation } from 'utils'; import { MOBILE_MODAL_STYLE } from '../constants'; import { ApptTab } from './AppointmentTabs'; @@ -50,7 +51,7 @@ export default function AppointmentTableRowMobile({ }} > - + = (props) => return encounter.office ? encounter.office : '-'; case 'status': { if (!encounter.status) return null; - const statusVal = encounter.status === 'in-progress' ? 'OPEN' : 'RESOLVED'; - return getFollowupStatusChip(statusVal); + // Scheduled follow-ups use encounter status directly (planned, arrived, etc.) + // Annotation follow-ups use OPEN/RESOLVED + if (encounter.followupSubtype === 'scheduled') { + return {encounter.status}; + } + return getFollowupStatusChip(getAnnotationFollowupStatusLabel(encounter.status)); } case 'note': { - const { encounterId, originalAppointmentId } = encounter; - if (!originalAppointmentId) return '-'; - const to = `/in-person/${originalAppointmentId}/follow-up-note${ - encounterId ? `?encounterId=${encounterId}` : '' - }`; - + const { encounterId, originalAppointmentId, followupSubtype } = encounter; + const pathSegment = getFollowUpProgressNotePathSegment(followupSubtype, encounter.status); + if (!pathSegment || !originalAppointmentId) return '-'; + const to = getInPersonUrlByAppointmentType( + { id: originalAppointmentId, encounterId, isFollowUp: true }, + pathSegment + ); return Progress Note; } default: diff --git a/apps/ehr/src/features/external-labs/components/labs-orders/LabBreadcrumbs.tsx b/apps/ehr/src/features/external-labs/components/labs-orders/LabBreadcrumbs.tsx index decdfad406..89a2a718b6 100644 --- a/apps/ehr/src/features/external-labs/components/labs-orders/LabBreadcrumbs.tsx +++ b/apps/ehr/src/features/external-labs/components/labs-orders/LabBreadcrumbs.tsx @@ -1,6 +1,6 @@ import { FC, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { BaseBreadcrumbs } from 'src/components/BaseBreadcrumbs'; -import { useAppointmentData } from 'src/features/visits/shared/stores/appointment/appointment.store'; interface LabBreadcrumbsProps { sectionName: string; @@ -8,14 +8,14 @@ interface LabBreadcrumbsProps { } export const LabBreadcrumbs: FC = ({ sectionName, children }) => { - const { appointment } = useAppointmentData(); + const { id: appointmentIdFromUrl } = useParams(); const baseCrumb = useMemo( () => ({ label: 'External Labs', - path: appointment?.id ? `/in-person/${appointment.id}/external-lab-orders` : null, + path: appointmentIdFromUrl ? `/in-person/${appointmentIdFromUrl}/external-lab-orders` : null, }), - [appointment?.id] + [appointmentIdFromUrl] ); return ( diff --git a/apps/ehr/src/features/external-labs/pages/CreateExternalLabOrder.tsx b/apps/ehr/src/features/external-labs/pages/CreateExternalLabOrder.tsx index 0d164afe96..351efe90da 100644 --- a/apps/ehr/src/features/external-labs/pages/CreateExternalLabOrder.tsx +++ b/apps/ehr/src/features/external-labs/pages/CreateExternalLabOrder.tsx @@ -18,7 +18,7 @@ import { import Oystehr from '@oystehr/sdk'; import { enqueueSnackbar } from 'notistack'; import React, { ReactElement, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import { ActionsList } from 'src/components/ActionsList'; import { DeleteIconButton } from 'src/components/DeleteIconButton'; import DetailPageContainer from 'src/features/common/DetailPageContainer'; @@ -65,16 +65,11 @@ export const CreateExternalLabOrder: React.FC = () const theme = useTheme(); const { oystehrZambda } = useApiClients(); const navigate = useNavigate(); + const { id: appointmentIdFromUrl } = useParams(); const [error, setError] = useState<(string | ReactElement)[] | undefined>(undefined); const [submitting, setSubmitting] = useState(false); const apiClient = useOystehrAPIClient(); - const { - encounter, - appointment, - patient, - location: apptLocation, - followUpOriginEncounter: mainEncounter, - } = useAppointmentData(); + const { encounter, patient, location: apptLocation, followUpOriginEncounter: mainEncounter } = useAppointmentData(); const { chartData, setPartialChartData } = useChartData(); const { mutate: saveCPTChartData } = useSaveChartData(); const { visitType } = useGetAppointmentAccessibility(); @@ -212,7 +207,7 @@ export const CreateExternalLabOrder: React.FC = () selectedPaymentMethod: selectedPaymentMethod, clinicalInfoNoteByUser: clinicalInfoNotes, }); - navigate(`/in-person/${appointment?.id}/external-lab-orders`); + navigate(`/in-person/${appointmentIdFromUrl}/external-lab-orders`); } catch (e) { const sdkError = e as Oystehr.OystehrSdkError; console.log('error creating external lab order', sdkError.code, sdkError.message); @@ -221,7 +216,7 @@ export const CreateExternalLabOrder: React.FC = () setError([ <> Information necessary to submit labs is missing. Please navigate to{' '} - visit details and complete all Worker's Compensation + visit details and complete all Worker's Compensation Information. , ]); @@ -611,7 +606,7 @@ export const CreateExternalLabOrder: React.FC = () variant="outlined" sx={{ borderRadius: '50px', textTransform: 'none', fontWeight: 600 }} onClick={() => { - navigate(`/in-person/${appointment?.id}/external-lab-orders`); + navigate(`/in-person/${appointmentIdFromUrl}/external-lab-orders`); }} > Cancel diff --git a/apps/ehr/src/features/in-house-labs/components/InHouseLabsBreadcrumbs.tsx b/apps/ehr/src/features/in-house-labs/components/InHouseLabsBreadcrumbs.tsx index 6a6e99eaef..5c3d3e4275 100644 --- a/apps/ehr/src/features/in-house-labs/components/InHouseLabsBreadcrumbs.tsx +++ b/apps/ehr/src/features/in-house-labs/components/InHouseLabsBreadcrumbs.tsx @@ -1,21 +1,20 @@ import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { BaseBreadcrumbs } from 'src/components/BaseBreadcrumbs'; import { getInHouseLabsUrl } from 'src/features/visits/in-person/routing/helpers'; -import { useAppointmentData } from 'src/features/visits/shared/stores/appointment/appointment.store'; export const InHouseLabsBreadcrumbs: React.FC<{ children: React.ReactNode; pageName: string }> = ({ children, pageName, }) => { - const { appointment } = useAppointmentData(); - const appointmentId = appointment?.id; + const { id: appointmentIdFromUrl } = useParams(); const baseCrumb = useMemo(() => { return { label: 'In-House Labs', - path: appointmentId ? getInHouseLabsUrl(appointmentId) : null, + path: appointmentIdFromUrl ? getInHouseLabsUrl(appointmentIdFromUrl) : null, }; - }, [appointmentId]); + }, [appointmentIdFromUrl]); return ( diff --git a/apps/ehr/src/features/in-house-labs/pages/InHouseLabOrderCreatePage.tsx b/apps/ehr/src/features/in-house-labs/pages/InHouseLabOrderCreatePage.tsx index 8bf926ee5d..d7991df9b4 100644 --- a/apps/ehr/src/features/in-house-labs/pages/InHouseLabOrderCreatePage.tsx +++ b/apps/ehr/src/features/in-house-labs/pages/InHouseLabOrderCreatePage.tsx @@ -25,7 +25,7 @@ import { import Oystehr from '@oystehr/sdk'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ActionsList } from 'src/components/ActionsList'; import { DeleteIconButton } from 'src/components/DeleteIconButton'; import { dataTestIds } from 'src/constants/data-test-ids'; @@ -59,6 +59,7 @@ export const InHouseLabOrderCreatePage: React.FC = () => { const theme = useTheme(); const { oystehrZambda } = useApiClients(); const navigate = useNavigate(); + const { id: appointmentIdFromUrl } = useParams(); const location = useLocation(); const [loading, setLoading] = useState(false); const [selectedTests, setSelectedTests] = useState([]); @@ -73,7 +74,7 @@ export const InHouseLabOrderCreatePage: React.FC = () => { type?: 'repeat' | 'reflex'; }; - const { encounter, appointment } = useAppointmentData(); + const { encounter } = useAppointmentData(); const { chartData, setPartialChartData } = useChartData(); const didPrimaryDiagnosisInit = useRef(false); const didPrefillInit = useRef(false); @@ -220,9 +221,9 @@ export const InHouseLabOrderCreatePage: React.FC = () => { if (res.serviceRequestIds.length === 1) { // we will only nav forward if one test was created, else we will direct the user back to the table - navigate(`/in-person/${appointment?.id}/in-house-lab-orders/${res.serviceRequestIds[0]}/order-details`); + navigate(`/in-person/${appointmentIdFromUrl}/in-house-lab-orders/${res.serviceRequestIds[0]}/order-details`); } else { - navigate(`/in-person/${appointment?.id}/in-house-lab-orders`); + navigate(`/in-person/${appointmentIdFromUrl}/in-house-lab-orders`); } } catch (e) { const sdkError = e as Oystehr.OystehrSdkError; diff --git a/apps/ehr/src/features/in-house-labs/pages/InHouseLabsPage.tsx b/apps/ehr/src/features/in-house-labs/pages/InHouseLabsPage.tsx index 9eb0a4c804..feda170a1b 100644 --- a/apps/ehr/src/features/in-house-labs/pages/InHouseLabsPage.tsx +++ b/apps/ehr/src/features/in-house-labs/pages/InHouseLabsPage.tsx @@ -1,6 +1,6 @@ import { Box, Stack } from '@mui/material'; import React, { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import ListViewContainer from 'src/features/common/ListViewContainer'; import { getInHouseLabOrderCreateUrl } from 'src/features/visits/in-person/routing/helpers'; import { useGetAppointmentAccessibility } from 'src/features/visits/shared/hooks/useGetAppointmentAccessibility'; @@ -14,8 +14,9 @@ const inHouseLabsColumns: InHouseLabsTableColumn[] = ['testType', 'orderAdded', export const InHouseLabsPage: React.FC = () => { const navigate = useNavigate(); - const { appointment, encounter } = useAppointmentData(); - const appointmentId = appointment?.id; + const { id: appointmentIdFromUrl } = useParams(); + const { encounter } = useAppointmentData(); + const appointmentId = appointmentIdFromUrl; const encounterId = encounter?.id; const { isAppointmentReadOnly: isReadOnly } = useGetAppointmentAccessibility(); diff --git a/apps/ehr/src/features/nursing-orders/components/BreadCrumbs.tsx b/apps/ehr/src/features/nursing-orders/components/BreadCrumbs.tsx index 002d02ea82..72e6f7200f 100644 --- a/apps/ehr/src/features/nursing-orders/components/BreadCrumbs.tsx +++ b/apps/ehr/src/features/nursing-orders/components/BreadCrumbs.tsx @@ -1,14 +1,14 @@ import { FC } from 'react'; +import { useParams } from 'react-router-dom'; import CustomBreadcrumbs from 'src/components/CustomBreadcrumbs'; -import { useAppointmentData } from 'src/features/visits/shared/stores/appointment/appointment.store'; export const BreadCrumbs: FC = () => { - const { appointment } = useAppointmentData(); + const { id: appointmentIdFromUrl } = useParams(); return ( diff --git a/apps/ehr/src/features/nursing-orders/pages/NursingOrdersPage.tsx b/apps/ehr/src/features/nursing-orders/pages/NursingOrdersPage.tsx index 5675d64c93..3aca04b5cf 100644 --- a/apps/ehr/src/features/nursing-orders/pages/NursingOrdersPage.tsx +++ b/apps/ehr/src/features/nursing-orders/pages/NursingOrdersPage.tsx @@ -1,6 +1,6 @@ import { Box, Stack } from '@mui/material'; import React, { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { dataTestIds } from 'src/constants/data-test-ids'; import { getNursingOrderCreateUrl } from 'src/features/visits/in-person/routing/helpers'; import { useGetAppointmentAccessibility } from 'src/features/visits/shared/hooks/useGetAppointmentAccessibility'; @@ -13,8 +13,9 @@ const nursingOrdersColumns: NursingOrdersTableColumn[] = ['order', 'orderAdded', export const NursingOrdersPage: React.FC = () => { const navigate = useNavigate(); - const { appointment, encounter } = useAppointmentData(); - const appointmentId = appointment?.id; + const { id: appointmentIdFromUrl } = useParams(); + const { encounter } = useAppointmentData(); + const appointmentId = appointmentIdFromUrl; const encounterId = encounter?.id; const { isAppointmentReadOnly: isReadOnly } = useGetAppointmentAccessibility(); diff --git a/apps/ehr/src/features/radiology/components/RadiologyBreadcrumbs.tsx b/apps/ehr/src/features/radiology/components/RadiologyBreadcrumbs.tsx index a637447a79..1af01d45a1 100644 --- a/apps/ehr/src/features/radiology/components/RadiologyBreadcrumbs.tsx +++ b/apps/ehr/src/features/radiology/components/RadiologyBreadcrumbs.tsx @@ -1,9 +1,8 @@ import { Box, Link as MuiLink, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { FC } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { getRadiologyUrl } from 'src/features/visits/in-person/routing/helpers'; -import { useAppointmentData } from 'src/features/visits/shared/stores/appointment/appointment.store'; interface RadiologyBreadcrumbsProps { sectionName: string; @@ -35,13 +34,13 @@ export const WithRadiologyBreadcrumbs: FC = ({ disableLabsLink = false, children, }) => { - const { appointment } = useAppointmentData(); + const { id: appointmentIdFromUrl } = useParams(); return ( - {!disableLabsLink && appointment?.id ? ( - + {!disableLabsLink && appointmentIdFromUrl ? ( + Radiology ) : ( diff --git a/apps/ehr/src/features/radiology/pages/CreateRadiologyOrder.tsx b/apps/ehr/src/features/radiology/pages/CreateRadiologyOrder.tsx index 61995dd972..08966885c3 100644 --- a/apps/ehr/src/features/radiology/pages/CreateRadiologyOrder.tsx +++ b/apps/ehr/src/features/radiology/pages/CreateRadiologyOrder.tsx @@ -28,7 +28,7 @@ import { import { ClearIcon } from '@mui/x-date-pickers'; import { enqueueSnackbar } from 'notistack'; import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { dataTestIds } from 'src/constants/data-test-ids'; import DetailPageContainer from 'src/features/common/DetailPageContainer'; import { getRadiologyUrl } from 'src/features/visits/in-person/routing/helpers'; @@ -71,10 +71,11 @@ export const CreateRadiologyOrder: React.FC = () => const theme = useTheme(); const { oystehrZambda } = useApiClients(); const navigate = useNavigate(); + const { id: appointmentIdFromUrl } = useParams(); const [error, setError] = useState(undefined); const [submitting, setSubmitting] = useState(false); const { mutate: saveChartData } = useSaveChartData(); - const { encounter, appointment } = useAppointmentData(); + const { encounter } = useAppointmentData(); const { chartData, setPartialChartData } = useChartData(); const { diagnosis } = chartData || {}; const primaryDiagnosis = diagnosis?.find((d) => d.isPrimary); @@ -227,7 +228,7 @@ export const CreateRadiologyOrder: React.FC = () => }); } - navigate(getRadiologyUrl(appointment?.id || '')); + navigate(getRadiologyUrl(appointmentIdFromUrl || '')); } catch (e) { const error = e as any; console.log('error', JSON.stringify(error)); @@ -465,7 +466,7 @@ export const CreateRadiologyOrder: React.FC = () => variant="outlined" sx={{ borderRadius: '50px', textTransform: 'none', fontWeight: 600 }} onClick={() => { - navigate(`/in-person/${appointment?.id}/radiology`); + navigate(`/in-person/${appointmentIdFromUrl}/radiology`); }} > Cancel diff --git a/apps/ehr/src/features/visits/in-person/components/EncounterSwitcher.tsx b/apps/ehr/src/features/visits/in-person/components/EncounterSwitcher.tsx index 4f1d18d8c7..1c0c7ed03f 100644 --- a/apps/ehr/src/features/visits/in-person/components/EncounterSwitcher.tsx +++ b/apps/ehr/src/features/visits/in-person/components/EncounterSwitcher.tsx @@ -1,10 +1,19 @@ import AssignmentIcon from '@mui/icons-material/Assignment'; import { Box, Button, Collapse, List, ListItem, ListItemButton, ListItemText, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; import { Encounter } from 'fhir/r4b'; import { DateTime } from 'luxon'; -import { FC, useState } from 'react'; +import { FC, useMemo, useState } from 'react'; +import { CHART_DATA_QUERY_KEY } from 'src/constants'; import { formatISOStringToDateAndTime } from 'src/helpers/formatDateTime'; +import { + buildAppointmentStartMap, + getEncounterDateTime, + getEncounterDisplayName, + getInteractionModeForEncounter, +} from 'utils'; import { useAppointmentData } from '../../shared/stores/appointment/appointment.store'; +import { resetExamObservationsStore } from '../../shared/stores/appointment/reset-exam-observations'; import { useInPersonNavigationContext } from '../context/InPersonNavigationContext'; type EncounterSwitcherProps = { @@ -12,36 +21,36 @@ type EncounterSwitcherProps = { }; export const EncounterSwitcher: FC = ({ open }) => { - const { followUpOriginEncounter, followupEncounters, selectedEncounterId, setSelectedEncounter } = + const { followUpOriginEncounter, followupEncounters, selectedEncounterId, setSelectedEncounter, rawResources } = useAppointmentData(); const [isExpanded, setIsExpanded] = useState(true); const { setInteractionMode } = useInPersonNavigationContext(); + const queryClient = useQueryClient(); + + const appointmentStartMap = useMemo(() => buildAppointmentStartMap(rawResources ?? []), [rawResources]); const sortedFollowupEncounters = [...(followupEncounters || [])].filter(Boolean).sort((a, b) => { - return DateTime.fromISO(a.period?.start ?? '').diff(DateTime.fromISO(b.period?.start ?? ''), 'milliseconds') - .milliseconds; + return DateTime.fromISO(getEncounterDateTime(a, appointmentStartMap) ?? '').diff( + DateTime.fromISO(getEncounterDateTime(b, appointmentStartMap) ?? ''), + 'milliseconds' + ).milliseconds; }); const allEncounters = [followUpOriginEncounter, ...sortedFollowupEncounters].filter(Boolean) as Encounter[]; const handleEncounterSelect = (encounterId: string): void => { + // Reset exam observations and invalidate chart data cache so it refetches + // and repopulates the exam store for the new encounter + resetExamObservationsStore(); + void queryClient.invalidateQueries({ + queryKey: [CHART_DATA_QUERY_KEY, encounterId], + exact: false, + }); setSelectedEncounter(encounterId); - if (encounterId === followUpOriginEncounter?.id) { - setInteractionMode('main', true); - } else { - setInteractionMode('follow-up', true); - } - }; - - const getEncounterDisplayName = (encounter: Encounter): string => { - if (!encounter.partOf) { - return 'Main Visit'; + const selectedEnc = allEncounters.find((e) => e.id === encounterId); + if (selectedEnc) { + setInteractionMode(getInteractionModeForEncounter(selectedEnc, followUpOriginEncounter?.id), true); } - - const typeText = encounter.type?.[0]?.text || 'Follow-up'; - const date = encounter.period?.start ? formatISOStringToDateAndTime(encounter.period.start) : ''; - - return `${typeText}${date ? ` - ${date}` : ''}`; }; if (allEncounters.length <= 1) { @@ -101,7 +110,7 @@ export const EncounterSwitcher: FC = ({ open }) => { }} > { location, locations, encounter, + followUpOriginEncounter, appointmentRefetch, selectedEncounterId, } = useAppointmentData(); @@ -172,11 +176,9 @@ export const Header = (): JSX.Element => { let optionalVisitLabel = ''; if (isFollowup) { - const locationRef = encounter?.location?.[0]?.location?.reference; - if (locationRef) { - const locationId = locationRef.split('/')[1]; + const locationId = getEncounterLocationId(encounter); + if (locationId) { const matchedLocation = locations.find((location) => location?.id === locationId); - optionalVisitLabel = matchedLocation?.name ?? ''; } } else { @@ -369,7 +371,7 @@ export const Header = (): JSX.Element => { {isFollowup ? ( - getFollowupStatusChip(encounter?.status === 'in-progress' ? 'OPEN' : 'RESOLVED') + getFollowupStatusChip(getAnnotationFollowupStatusLabel(encounter?.status)) ) : ( { onClick={() => { setHeaderMenuAnchorEl(null); if (patient?.id) { - navigate(`/patient/${patient.id}/followup/add`); + const initialEncounterId = getInitialEncounterIdForFollowUp(encounter, followUpOriginEncounter); + navigate(`/patient/${patient.id}/followup/add`, { + state: { initialEncounterId }, + }); } }} disabled={!patient?.id} diff --git a/apps/ehr/src/features/visits/in-person/components/progress-note/VisitDetailsContainer.tsx b/apps/ehr/src/features/visits/in-person/components/progress-note/VisitDetailsContainer.tsx index 3643799619..8cf561870f 100644 --- a/apps/ehr/src/features/visits/in-person/components/progress-note/VisitDetailsContainer.tsx +++ b/apps/ehr/src/features/visits/in-person/components/progress-note/VisitDetailsContainer.tsx @@ -1,6 +1,6 @@ import { Box, Stack, Typography } from '@mui/material'; import { FC } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { ActionsList } from 'src/components/ActionsList'; import { VisitNoteItem } from 'src/features/visits/shared/components/VisitNoteItem'; import { useChartFields } from 'src/features/visits/shared/hooks/useChartFields'; @@ -16,6 +16,7 @@ import { ButtonRounded } from '../RoundedButton'; export const VisitDetailsContainer: FC = () => { const navigate = useNavigate(); + const { id: appointmentIdFromUrl } = useParams(); const { appointment, location, questionnaireResponse, encounter } = useAppointmentData(); const { data: chartFields } = useChartFields({ @@ -60,7 +61,7 @@ export const VisitDetailsContainer: FC = () => { Visit information navigate(`/visit/${appointment?.id}`)} + onClick={() => navigate(`/visit/${appointmentIdFromUrl}`)} variant="outlined" sx={{ whiteSpace: 'nowrap', diff --git a/apps/ehr/src/features/visits/in-person/context/InPersonNavigationContext.tsx b/apps/ehr/src/features/visits/in-person/context/InPersonNavigationContext.tsx index ed5772cfc8..aba78543b6 100644 --- a/apps/ehr/src/features/visits/in-person/context/InPersonNavigationContext.tsx +++ b/apps/ehr/src/features/visits/in-person/context/InPersonNavigationContext.tsx @@ -110,7 +110,8 @@ export const InPersonNavigationProvider: React.FC<{ children: ReactNode }> = ({ return; } - const isEncounterLoadedToStore = appointmentIdReferenceFromEncounter === appointmentIdFromUrl; + const isEncounterLoadedToStore = + appointmentIdReferenceFromEncounter === appointmentIdFromUrl || !!encounter?.partOf; if (!isEncounterLoadedToStore) { return; @@ -133,6 +134,7 @@ export const InPersonNavigationProvider: React.FC<{ children: ReactNode }> = ({ }, [ encounter?.id, encounter?.participant, + encounter?.partOf, setInteractionMode, interactionMode, isModeInitialized, diff --git a/apps/ehr/src/features/visits/in-person/routing/helpers.ts b/apps/ehr/src/features/visits/in-person/routing/helpers.ts index 878ec6b4ab..34b7603333 100644 --- a/apps/ehr/src/features/visits/in-person/routing/helpers.ts +++ b/apps/ehr/src/features/visits/in-person/routing/helpers.ts @@ -1,3 +1,5 @@ +import { InPersonAppointmentInformation } from 'utils'; + export const getNewOrderUrl = (appointmentId: string): string => { return `/in-person/${appointmentId}/in-house-medication/order/new`; }; @@ -78,6 +80,20 @@ export const getInPersonVisitDetailsUrl = (appointmentId: string): string => { return `/visit/${appointmentId}`; }; +export const getInPersonUrlByAppointmentType = ( + appointment: Pick, + targetUrl?: string +): string => { + let baseVisitUrl = `/in-person/${appointment.parentAppointmentId || appointment.id}`; + if (targetUrl) { + baseVisitUrl = `${baseVisitUrl}/${targetUrl}`; + } + if (appointment.isFollowUp && appointment.encounterId) { + baseVisitUrl = `${baseVisitUrl}?encounterId=${appointment.encounterId}`; + } + return baseVisitUrl; +}; + export const getChiefComplaintUrl = (appointmentId: string): string => { return `/in-person/${appointmentId}/cc-and-intake-notes`; }; diff --git a/apps/ehr/src/features/visits/shared/components/OrdersIconsToolTip.tsx b/apps/ehr/src/features/visits/shared/components/OrdersIconsToolTip.tsx index 16d5958862..18d8d5705c 100644 --- a/apps/ehr/src/features/visits/shared/components/OrdersIconsToolTip.tsx +++ b/apps/ehr/src/features/visits/shared/components/OrdersIconsToolTip.tsx @@ -55,6 +55,8 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const ordersExistForAppointment = hasAtLeastOneOrder(orders); if (!ordersExistForAppointment) return null; + const navAppointmentId = appointment.parentAppointmentId || appointment.id; + const { externalLabOrders, inHouseLabOrders, @@ -74,14 +76,14 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const externalLabOrderConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['External Labs'], title: 'External Labs', - tableUrl: getExternalLabOrdersUrl(appointment.id), + tableUrl: getExternalLabOrdersUrl(navAppointmentId), unreadBadge: Boolean( externalLabOrders.find((ord) => EXTERNAL_LAB_ORDERS_PENDING_BADGE_STATUSES.includes(ord.orderStatus)) ), orders: externalLabOrders.map((order) => ({ fhirResourceId: order.serviceRequestId, itemDescription: order.testItem, - detailPageUrl: getExternalLabOrderEditUrl(appointment.id, order.serviceRequestId), + detailPageUrl: getExternalLabOrderEditUrl(navAppointmentId, order.serviceRequestId), statusChip: , unreadBadge: EXTERNAL_LAB_ORDERS_PENDING_BADGE_STATUSES.includes(order.orderStatus), })), @@ -93,14 +95,14 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const inHouseLabOrderConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['In-House Labs'], title: 'In-House Labs', - tableUrl: getInHouseLabsUrl(appointment.id), + tableUrl: getInHouseLabsUrl(navAppointmentId), unreadBadge: Boolean( inHouseLabOrders.find((ord) => IN_HOUSE_LAB_ORDERS_PENDING_BADGE_STATUSES.includes(ord.status)) ), orders: inHouseLabOrders.map((order) => ({ fhirResourceId: order.serviceRequestId, itemDescription: order.testItemName, - detailPageUrl: getInHouseLabOrderDetailsUrl(appointment.id, order.serviceRequestId), + detailPageUrl: getInHouseLabOrderDetailsUrl(navAppointmentId, order.serviceRequestId), statusChip: , unreadBadge: IN_HOUSE_LAB_ORDERS_PENDING_BADGE_STATUSES.includes(order.status), })), @@ -112,14 +114,14 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const nursingOrdersConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['Nursing Orders'], title: 'Nursing Orders', - tableUrl: getNursingOrdersUrl(appointment.id), + tableUrl: getNursingOrdersUrl(navAppointmentId), unreadBadge: Boolean(nursingOrders.find((ord) => NURSING_ORDERS_PENDING_BADGE_STATUSES.includes(ord.status))), orders: nursingOrders .filter((order) => order.status !== NursingOrdersStatus.cancelled) .map((order) => ({ fhirResourceId: order.serviceRequestId, itemDescription: order.note, - detailPageUrl: getNursingOrderDetailsUrl(appointment.id, order.serviceRequestId), + detailPageUrl: getNursingOrderDetailsUrl(navAppointmentId, order.serviceRequestId), statusChip: , unreadBadge: NURSING_ORDERS_PENDING_BADGE_STATUSES.includes(order.status), })), @@ -131,7 +133,7 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const inHouseMedicationConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['Med. Administration'], title: 'In-House Medications', - tableUrl: getInHouseMedicationMARUrl(appointment.id), + tableUrl: getInHouseMedicationMARUrl(navAppointmentId), unreadBadge: Boolean( filteredInHouseMedications.find((ord) => FILTERED_IN_HOUSE_MEDICATIONS_PENDING_BADGE_STATUSES.includes(ord.status as MedicationOrderStatuses) @@ -140,8 +142,8 @@ export const OrdersIconsToolTip: React.FC = ({ appointm orders: filteredInHouseMedications.map((med) => { const isPending = med.status === 'pending'; const targetUrl = isPending - ? `${getInHouseMedicationDetailsUrl(appointment.id)}?scrollTo=${med.id}` - : `${getInHouseMedicationMARUrl(appointment.id)}?scrollTo=${med.id}`; + ? `${getInHouseMedicationDetailsUrl(navAppointmentId)}?scrollTo=${med.id}` + : `${getInHouseMedicationMARUrl(navAppointmentId)}?scrollTo=${med.id}`; return { fhirResourceId: med.id, @@ -161,12 +163,12 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const radiologyOrdersConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['Radiology'], title: 'Radiology Orders', - tableUrl: getRadiologyUrl(appointment.id), + tableUrl: getRadiologyUrl(navAppointmentId), unreadBadge: Boolean(radiologyOrders.find((ord) => RADIOLOGY_ORDERS_PENDING_BADGE_STATUSES.includes(ord.status))), orders: radiologyOrders.map((order) => ({ fhirResourceId: order.serviceRequestId, itemDescription: order.studyType, - detailPageUrl: getRadiologyOrderEditUrl(appointment.id, order.serviceRequestId), + detailPageUrl: getRadiologyOrderEditUrl(navAppointmentId, order.serviceRequestId), statusChip: , unreadBadge: RADIOLOGY_ORDERS_PENDING_BADGE_STATUSES.includes(order.status), })), @@ -178,11 +180,11 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const ordersConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['eRX'], title: 'eRx', - tableUrl: getErxUrl(appointment.id), + tableUrl: getErxUrl(navAppointmentId), orders: erxOrders.map((order) => ({ fhirResourceId: order.resourceId ?? '', itemDescription: order.name ?? '', - detailPageUrl: getErxUrl(appointment.id), + detailPageUrl: getErxUrl(navAppointmentId), statusChip: , })), }; @@ -193,13 +195,13 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const proceduresConfig: OrderToolTipConfig = { icon: sidebarMenuIcons['Procedures'], title: 'Procedures', - tableUrl: getProceduresUrl(appointment.id), + tableUrl: getProceduresUrl(navAppointmentId), orders: procedures.map((procedure) => ({ fhirResourceId: procedure.resourceId ?? '', itemDescription: procedure.procedureType ?? '', detailPageUrl: procedure.resourceId - ? getProcedureDetailsUrl(appointment.id, procedure.resourceId) - : getProceduresUrl(appointment.id), + ? getProcedureDetailsUrl(navAppointmentId, procedure.resourceId) + : getProceduresUrl(navAppointmentId), statusChip: <>, })), }; @@ -210,14 +212,14 @@ export const OrdersIconsToolTip: React.FC = ({ appointm const config: OrderToolTipConfig = { icon: sidebarMenuIcons['Immunization'], title: 'Immunization', - tableUrl: getImmunizationMARUrl(appointment.id), + tableUrl: getImmunizationMARUrl(navAppointmentId), orders: immunizationOrders .filter((order) => order.status !== 'cancelled') .map((order) => { const isPending = order.status === 'pending'; const targetUrl = isPending - ? `${getImmunizationVaccineDetailsUrl(appointment.id)}?scrollTo=${order.id}` - : `${getImmunizationMARUrl(appointment.id)}?scrollTo=${order.id}`; + ? `${getImmunizationVaccineDetailsUrl(navAppointmentId)}?scrollTo=${order.id}` + : `${getImmunizationMARUrl(navAppointmentId)}?scrollTo=${order.id}`; return { fhirResourceId: order.id ?? '', itemDescription: order.details.medication.name, diff --git a/apps/ehr/src/features/visits/shared/components/VitalsIconTooltip.tsx b/apps/ehr/src/features/visits/shared/components/VitalsIconTooltip.tsx index d27d6d52a3..ca9d2d5084 100644 --- a/apps/ehr/src/features/visits/shared/components/VitalsIconTooltip.tsx +++ b/apps/ehr/src/features/visits/shared/components/VitalsIconTooltip.tsx @@ -92,7 +92,7 @@ export const VitalsIconTooltip: React.FC = ({ appointmen > diff --git a/apps/ehr/src/features/visits/shared/components/patient/AddPatientFollowup.tsx b/apps/ehr/src/features/visits/shared/components/patient/AddPatientFollowup.tsx index 15896bd43c..e315ddf094 100644 --- a/apps/ehr/src/features/visits/shared/components/patient/AddPatientFollowup.tsx +++ b/apps/ehr/src/features/visits/shared/components/patient/AddPatientFollowup.tsx @@ -1,14 +1,20 @@ -import { CircularProgress, Grid, Typography } from '@mui/material'; -import { useParams } from 'react-router-dom'; +import { CircularProgress, FormControlLabel, Grid, Paper, Radio, RadioGroup, Typography } from '@mui/material'; +import { useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import CustomBreadcrumbs from 'src/components/CustomBreadcrumbs'; import { useGetPatient } from 'src/hooks/useGetPatient'; import PageContainer from 'src/layout/PageContainer'; -import { getFullName } from 'utils'; +import { FollowupSubtype, getFullName } from 'utils'; import PatientFollowupForm from './PatientFollowupForm'; +import ScheduledFollowupParentSelector from './ScheduledFollowupParentSelector'; export default function AddPatientFollowup(): JSX.Element { const { id } = useParams(); const { patient } = useGetPatient(id); + const [followupSubtype, setFollowupSubtype] = useState('annotation'); + const location = useLocation(); + const routerState = location.state as { initialEncounterId?: string } | undefined; + const initialEncounterId = routerState?.initialEncounterId; const fullName = patient ? getFullName(patient) : ''; @@ -32,10 +38,27 @@ export default function AddPatientFollowup(): JSX.Element { }, ]} /> - + Add Follow-up Visit - + + + setFollowupSubtype(e.target.value as FollowupSubtype)} + sx={{ mb: 2 }} + > + } label="Annotation" /> + } label="Scheduled Visit" /> + + + {followupSubtype === 'annotation' ? ( + + ) : ( + + )} + )} diff --git a/apps/ehr/src/features/visits/shared/components/patient/PatientFollowupForm.tsx b/apps/ehr/src/features/visits/shared/components/patient/PatientFollowupForm.tsx index 38b53d43ad..38da87617c 100644 --- a/apps/ehr/src/features/visits/shared/components/patient/PatientFollowupForm.tsx +++ b/apps/ehr/src/features/visits/shared/components/patient/PatientFollowupForm.tsx @@ -1,31 +1,27 @@ import { LoadingButton } from '@mui/lab'; -import { Autocomplete, Box, Button, Grid, Paper, TextField } from '@mui/material'; +import { Autocomplete, Box, Button, Grid, TextField } from '@mui/material'; import { DatePicker, TimePicker } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { LocalizationProvider } from '@mui/x-date-pickers-pro'; import Oystehr from '@oystehr/sdk'; -import { Appointment, Encounter, Patient } from 'fhir/r4b'; +import { Appointment, Encounter, Location, Patient } from 'fhir/r4b'; import { DateTime } from 'luxon'; import { enqueueSnackbar } from 'notistack'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { getEmployees, saveFollowup } from 'src/api/api'; import LocationSelect from 'src/components/LocationSelect'; +import { getInPersonUrlByAppointmentType } from 'src/features/visits/in-person/routing/helpers'; import { formatISOStringToDateAndTime } from 'src/helpers/formatDateTime'; import { useApiClients } from 'src/hooks/useAppClients'; +import useEvolveUser from 'src/hooks/useEvolveUser'; import { LocationWithWalkinSchedule } from 'src/pages/AddPatient'; -import { - FOLLOWUP_REASONS, - FOLLOWUP_SYSTEMS, - FollowupReason, - PatientFollowupDetails, - PRACTITIONER_CODINGS, - ProviderDetails, -} from 'utils'; +import { FOLLOWUP_REASONS, FollowupReason, isFollowupEncounter, PatientFollowupDetails, ProviderDetails } from 'utils'; interface PatientFollowupFormProps { patient: Patient | undefined; followupDetails?: PatientFollowupDetails; + initialEncounterId?: string; } interface EncounterRow { @@ -34,13 +30,14 @@ interface EncounterRow { dateTime: string | undefined; appointment: Appointment; encounter: Encounter; + location?: Location; } interface FormData { provider: ProviderDetails | null; reason: FollowupReason | undefined; otherReason: string; - initialVisit: EncounterRow | undefined; + initialVisit: EncounterRow | null; followupDate: DateTime; followupTime: DateTime; location: LocationWithWalkinSchedule | undefined; @@ -56,9 +53,14 @@ interface FormErrors { location?: string; } -export default function PatientFollowupForm({ patient, followupDetails }: PatientFollowupFormProps): JSX.Element { +export default function PatientFollowupForm({ + patient, + followupDetails, + initialEncounterId, +}: PatientFollowupFormProps): JSX.Element { const navigate = useNavigate(); const { oystehrZambda } = useApiClients(); + const currentUser = useEvolveUser(); const patientId = patient?.id; @@ -66,13 +68,13 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien const [providers, setProviders] = useState([]); const [previousEncounters, setPreviousEncounters] = useState([]); const [errors, setErrors] = useState({}); - const [locations, setLocations] = useState([]); + const [_locations, setLocations] = useState([]); const [formData, setFormData] = useState({ provider: followupDetails?.provider || null, reason: followupDetails?.reason || undefined, otherReason: followupDetails?.otherReason || '', - initialVisit: undefined, + initialVisit: null, followupDate: followupDetails?.start ? DateTime.fromISO(followupDetails.start) : DateTime.now(), followupTime: followupDetails?.start ? DateTime.fromISO(followupDetails.start) : DateTime.now(), location: followupDetails?.location as LocationWithWalkinSchedule | undefined, @@ -94,10 +96,10 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien newErrors.initialVisit = 'Initial visit is required'; } if (!formData.followupDate) { - newErrors.followupDate = 'Follow-up date is required'; + newErrors.followupDate = 'Annotation date is required'; } if (!formData.followupTime) { - newErrors.followupTime = 'Follow-up time is required'; + newErrors.followupTime = 'Annotation time is required'; } if (!formData.location) { newErrors.location = 'Location is required'; @@ -151,6 +153,10 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien name: '_include', value: 'Encounter:appointment', }, + { + name: '_include', + value: 'Encounter:location', + }, { name: '_sort', value: '-date', @@ -161,16 +167,9 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien const encounters = resources.filter((resource) => resource.resourceType === 'Encounter') as Encounter[]; const appointments = resources.filter((resource) => resource.resourceType === 'Appointment') as Appointment[]; + const fhirLocations = resources.filter((resource) => resource.resourceType === 'Location') as Location[]; - const nonFollowupEncounters = encounters.filter((encounter) => { - const isFollowup = encounter.type?.some( - (type) => - type.coding?.some( - (coding) => coding.system === FOLLOWUP_SYSTEMS.type.url && coding.code === FOLLOWUP_SYSTEMS.type.code - ) - ); - return !isFollowup; - }); + const nonFollowupEncounters = encounters.filter((encounter) => !isFollowupEncounter(encounter)); const encounterRows: EncounterRow[] = nonFollowupEncounters .map((encounter) => { @@ -178,12 +177,16 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien ? appointments.find((app) => `Appointment/${app.id}` === encounter.appointment?.[0]?.reference) : undefined; + const locationRef = encounter.location?.[0]?.location?.reference?.replace('Location/', ''); + const encounterLocation = locationRef ? fhirLocations.find((loc) => loc.id === locationRef) : undefined; + return { id: encounter.id, typeLabel: appointment?.appointmentType?.text || 'Visit', dateTime: appointment?.start, appointment: appointment!, encounter: encounter, + location: encounterLocation, }; }) .filter((row) => row.id) @@ -195,12 +198,25 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien setPreviousEncounters(encounterRows); - if (followupDetails?.initialEncounterID && encounterRows.length > 0) { - const matchingVisit = encounterRows.find((row) => row.encounter?.id === followupDetails.initialEncounterID); - if (matchingVisit) { - setFormData((prev) => ({ ...prev, initialVisit: matchingVisit })); - } - } + const preselectedEncounterId = followupDetails?.initialEncounterID || initialEncounterId; + const preselectedVisit = preselectedEncounterId + ? encounterRows.find((row) => row.encounter?.id === preselectedEncounterId) + : undefined; + + // Pre-populate provider from the logged-in user + const userProfile = currentUser?.profileResource; + const userProvider = userProfile?.id + ? { + practitionerId: userProfile.id, + name: [userProfile.name?.[0]?.given?.[0], userProfile.name?.[0]?.family].filter(Boolean).join(' '), + } + : undefined; + + setFormData((prev) => ({ + ...prev, + ...(preselectedVisit ? { initialVisit: preselectedVisit } : {}), + ...(userProvider ? { provider: userProvider } : {}), + })); } catch (error) { console.error('Error fetching previous encounters:', error); } @@ -209,49 +225,21 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien if (oystehrZambda && patientId) { void getPreviousEncounters(oystehrZambda); } - }, [oystehrZambda, patientId, followupDetails?.initialEncounterID]); + }, [oystehrZambda, patientId, followupDetails?.initialEncounterID, initialEncounterId, currentUser]); useEffect(() => { - if (previousEncounters.length > 0 && providers.length > 0) { + if (previousEncounters.length > 0) { const latestInitialVisit = previousEncounters[0]; - const provider = providers.find( - (provider) => - latestInitialVisit.encounter.participant?.find( - (participant) => - participant.individual?.reference?.split('/')[1] === provider.practitionerId && - participant.type?.find( - (type) => - type.coding?.some( - (coding) => - coding.system === PRACTITIONER_CODINGS.Attender[0].system && - coding.code === PRACTITIONER_CODINGS.Attender[0].code - ) - ) - ) - ); - if (provider) { - setFormData((prev) => ({ ...prev, provider: provider })); - } - } - }, [previousEncounters, providers]); - - useEffect(() => { - if (previousEncounters.length > 0 && locations.length > 0) { - const latestInitialVisit = previousEncounters[0]; - const location = locations.find( - (location) => - latestInitialVisit.encounter.location?.find( - (latestLocation) => location.id === latestLocation.location?.reference?.split('/')[1] - ) - ); - const selectedLocation = localStorage.getItem('selectedLocation'); - if (location) { - setFormData((prev) => ({ ...prev, location: location })); - } else if (selectedLocation) { - setFormData((prev) => ({ ...prev, location: JSON.parse(selectedLocation) })); + if (latestInitialVisit.location) { + setFormData((prev) => ({ ...prev, location: latestInitialVisit.location as LocationWithWalkinSchedule })); + } else { + const selectedLocation = localStorage.getItem('selectedLocation'); + if (selectedLocation) { + setFormData((prev) => ({ ...prev, location: JSON.parse(selectedLocation) })); + } } } - }, [previousEncounters, locations]); + }, [previousEncounters]); const handleFormSubmit = async (e: React.FormEvent): Promise => { e.preventDefault(); @@ -298,9 +286,14 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien const followup = await saveFollowup(oystehrZambda, { encounterDetails }); navigate( - `/in-person/${formData.initialVisit?.appointment?.id}/follow-up-note${ - followup.encounterId ? `?encounterId=${followup.encounterId}` : '' - }` + getInPersonUrlByAppointmentType( + { + id: formData.initialVisit?.appointment?.id || '', + encounterId: followup.encounterId, + isFollowUp: true, + }, + 'follow-up-note' + ) ); } catch (error) { console.error(`Failed to add patient followup: ${error}`); @@ -344,191 +337,189 @@ export default function PatientFollowupForm({ patient, followupDetails }: Patien }; return ( - -
- - - `${option.name}`} - isOptionEqualToValue={(option, value) => option.practitionerId === value.practitionerId} - value={formData.provider} - onChange={(_, newVal) => updateFormData('provider', newVal || null)} - renderOption={(props, option) => { - return ( -
  • - {option.name} -
  • - ); - }} - renderInput={(params) => ( - - )} - /> -
    - - - { - updateFormData('reason', newVal || undefined); - - if (newVal !== 'Other') { - updateFormData('otherReason', ''); - } - }} - size="small" - fullWidth - value={formData.reason} - renderInput={(params) => ( - - )} - /> - + + + + `${option.name}`} + isOptionEqualToValue={(option, value) => option.practitionerId === value.practitionerId} + value={formData.provider} + onChange={(_, newVal) => updateFormData('provider', newVal || null)} + renderOption={(props, option) => { + return ( +
  • + {option.name} +
  • + ); + }} + renderInput={(params) => ( + + )} + /> +
    - {formData.reason === 'Other' && ( - + + { + updateFormData('reason', newVal || undefined); + + if (newVal !== 'Other') { + updateFormData('otherReason', ''); + } + }} + size="small" + fullWidth + value={formData.reason} + renderInput={(params) => ( updateFormData('otherReason', e.target.value)} - placeholder="Please specify the reason" - error={!!errors.otherReason} - helperText={errors.otherReason} + placeholder="Select reason" + name="reason" + {...params} + label="Reason *" + error={!!errors.reason} + helperText={errors.reason} /> - - )} + )} + /> + + {formData.reason === 'Other' && ( - { - const dateTime = option.dateTime ? formatISOStringToDateAndTime(option.dateTime) : 'Unknown date/time'; - const type = option.typeLabel || 'Visit'; - return `${dateTime} - ${type}`; - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - value={formData.initialVisit} - onChange={(_, newVal) => updateFormData('initialVisit', newVal || undefined)} - renderInput={(params) => ( - - )} + id="otherReason" + label="Other reason *" + variant="outlined" + value={formData.otherReason} + onChange={(e) => updateFormData('otherReason', e.target.value)} + placeholder="Please specify the reason" + error={!!errors.otherReason} + helperText={errors.otherReason} /> - - - - val && handleDateChange(val)} - label="Follow-up date" - format="MM/dd/yyyy" - value={formData.followupDate} - minDate={DateTime.now().startOf('day')} - slotProps={{ - textField: { - id: 'followup-date', - label: 'Follow-up date *', - fullWidth: true, - size: 'small', - error: !!errors.followupDate, - helperText: errors.followupDate, - }, - }} + )} + + + { + const dateTime = option.dateTime ? formatISOStringToDateAndTime(option.dateTime) : 'Unknown date/time'; + const type = option.typeLabel || 'Visit'; + return `${dateTime} - ${type}`; + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={formData.initialVisit} + onChange={(_, newVal) => updateFormData('initialVisit', newVal || null)} + renderInput={(params) => ( + - - + )} + /> +
    - - - val && updateFormData('followupTime', val)} - value={formData.followupTime} - label="Follow-up time *" - minTime={formData.followupDate.hasSame(DateTime.now(), 'day') ? DateTime.now() : undefined} - slotProps={{ - textField: { - fullWidth: true, - size: 'small', - error: !!errors.followupTime, - helperText: errors.followupTime, - }, - }} - /> - - + + + val && handleDateChange(val)} + label="Annotation date" + format="MM/dd/yyyy" + value={formData.followupDate} + minDate={DateTime.now().startOf('day')} + slotProps={{ + textField: { + id: 'followup-date', + label: 'Annotation date *', + fullWidth: true, + size: 'small', + error: !!errors.followupDate, + helperText: errors.followupDate, + }, + }} + /> + + - - updateFormData('location', location)} - setLocations={setLocations} - updateURL={false} - renderInputProps={{ size: 'small' }} + + + val && updateFormData('followupTime', val)} + value={formData.followupTime} + label="Annotation time *" + minTime={formData.followupDate.hasSame(DateTime.now(), 'day') ? DateTime.now() : undefined} + slotProps={{ + textField: { + fullWidth: true, + size: 'small', + error: !!errors.followupTime, + helperText: errors.followupTime, + }, + }} /> - {errors.location && {errors.location}} - + + - - - - - - Create follow-up - - - + + updateFormData('location', location)} + setLocations={setLocations} + updateURL={false} + renderInputProps={{ size: 'small' }} + /> + {errors.location && {errors.location}} + + + + + + + + Create follow-up + + - -
    +
    + ); } diff --git a/apps/ehr/src/features/visits/shared/components/patient/ScheduledFollowupParentSelector.tsx b/apps/ehr/src/features/visits/shared/components/patient/ScheduledFollowupParentSelector.tsx new file mode 100644 index 0000000000..872cda0139 --- /dev/null +++ b/apps/ehr/src/features/visits/shared/components/patient/ScheduledFollowupParentSelector.tsx @@ -0,0 +1,111 @@ +import { LoadingButton } from '@mui/lab'; +import { Autocomplete, Box, Button, Grid, TextField } from '@mui/material'; +import { Patient } from 'fhir/r4b'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatISOStringToDateAndTime } from 'src/helpers/formatDateTime'; +import { useParentEncounters } from './useParentEncounters'; + +interface ScheduledFollowupParentSelectorProps { + patient: Patient; + initialEncounterId?: string; +} + +export default function ScheduledFollowupParentSelector({ + patient, + initialEncounterId, +}: ScheduledFollowupParentSelectorProps): JSX.Element { + const navigate = useNavigate(); + const patientId = patient?.id; + + const { previousEncounters, selectedParentEncounter, setSelectedParentEncounter } = useParentEncounters( + patientId, + initialEncounterId + ); + const [error, setError] = useState(); + + const handleContinue = (): void => { + if (!selectedParentEncounter) { + setError('Please select a initial visit'); + return; + } + + // Navigate to the standard Add Visit page with parent encounter context + console.log('[ScheduledFollowup] Navigating with parentEncounterId:', selectedParentEncounter.encounter.id); + navigate('/visits/add', { + state: { + parentEncounterId: selectedParentEncounter.encounter.id, + parentLocation: selectedParentEncounter.location, + patientId: patientId, + patientInfo: { + id: patient.id, + newPatient: false, + firstName: patient.name?.[0]?.given?.[0] || '', + lastName: patient.name?.[0]?.family || '', + dateOfBirth: patient.birthDate || '', + sex: patient.gender, + phoneNumber: patient.telecom?.find((t) => t.system === 'phone')?.value?.replace('+1', '') || '', + }, + }, + }); + }; + + const handleCancel = (): void => { + if (patientId) { + navigate(`/patient/${patientId}`, { state: { defaultTab: 'encounters' } }); + } else { + navigate('/visits'); + } + }; + + return ( + + + { + const dateTime = option.dateTime ? formatISOStringToDateAndTime(option.dateTime) : 'Unknown date/time'; + const type = option.typeLabel || 'Visit'; + return `${dateTime} - ${type}`; + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={selectedParentEncounter ?? null} + onChange={(_, newVal) => { + setSelectedParentEncounter(newVal || undefined); + setError(undefined); + }} + renderInput={(params) => ( + + )} + /> + + + + + + + Continue to Add Visit + + + + + ); +} diff --git a/apps/ehr/src/features/visits/shared/components/patient/useParentEncounters.ts b/apps/ehr/src/features/visits/shared/components/patient/useParentEncounters.ts new file mode 100644 index 0000000000..f5eec91aea --- /dev/null +++ b/apps/ehr/src/features/visits/shared/components/patient/useParentEncounters.ts @@ -0,0 +1,102 @@ +import { Appointment, Encounter, Location } from 'fhir/r4b'; +import { DateTime } from 'luxon'; +import { useEffect, useState } from 'react'; +import { useApiClients } from 'src/hooks/useAppClients'; +import { isFollowupEncounter } from 'utils'; + +export interface EncounterRow { + id: string | undefined; + typeLabel: string; + dateTime: string | undefined; + appointment: Appointment; + encounter: Encounter; + location?: Location; +} + +interface UseParentEncountersResult { + previousEncounters: EncounterRow[]; + selectedParentEncounter: EncounterRow | undefined; + setSelectedParentEncounter: (encounter: EncounterRow | undefined) => void; +} + +export function useParentEncounters( + patientId: string | undefined, + initialEncounterId?: string +): UseParentEncountersResult { + const { oystehrZambda } = useApiClients(); + const [previousEncounters, setPreviousEncounters] = useState([]); + const [selectedParentEncounter, setSelectedParentEncounter] = useState(undefined); + + useEffect(() => { + const getPreviousEncounters = async (): Promise => { + if (!oystehrZambda || !patientId) return; + try { + const resources = ( + await oystehrZambda.fhir.search({ + resourceType: 'Encounter', + params: [ + { name: 'patient', value: patientId }, + { name: '_include', value: 'Encounter:appointment' }, + { name: '_include', value: 'Encounter:location' }, + { name: '_sort', value: '-date' }, + ], + }) + ).unbundle(); + + const encounters = resources.filter((r) => r.resourceType === 'Encounter') as Encounter[]; + const appointments = resources.filter((r) => r.resourceType === 'Appointment') as Appointment[]; + const locations = resources.filter((r) => r.resourceType === 'Location') as Location[]; + + // Only show non-followup (top-level) encounters as parent options + const nonFollowupEncounters = encounters.filter((encounter) => !isFollowupEncounter(encounter)); + + const encounterRows: EncounterRow[] = nonFollowupEncounters + .map((encounter) => { + const appointment = encounter.appointment?.[0]?.reference + ? appointments.find((app) => `Appointment/${app.id}` === encounter.appointment?.[0]?.reference) + : undefined; + + const locationRef = encounter.location?.[0]?.location?.reference?.replace('Location/', ''); + const encounterLocation = locationRef ? locations.find((loc) => loc.id === locationRef) : undefined; + + return { + id: encounter.id, + typeLabel: appointment?.appointmentType?.text || 'Visit', + dateTime: appointment?.start, + appointment: appointment!, + encounter: encounter, + location: encounterLocation, + }; + }) + .filter((row) => row.id) + .sort((a, b) => { + const dateA = DateTime.fromISO(a.dateTime ?? ''); + const dateB = DateTime.fromISO(b.dateTime ?? ''); + return dateB.diff(dateA).milliseconds; + }); + + setPreviousEncounters(encounterRows); + + if (initialEncounterId && encounterRows.length > 0) { + console.log( + '[ScheduledSelector] Looking for initialEncounterId:', + initialEncounterId, + 'in', + encounterRows.map((r) => r.id) + ); + const matchingVisit = encounterRows.find((row) => row.encounter?.id === initialEncounterId); + console.log('[ScheduledSelector] Match:', matchingVisit?.id); + if (matchingVisit) { + setSelectedParentEncounter(matchingVisit); + } + } + } catch (err) { + console.error('Error fetching previous encounters:', err); + } + }; + + void getPreviousEncounters(); + }, [oystehrZambda, patientId, initialEncounterId]); + + return { previousEncounters, selectedParentEncounter, setSelectedParentEncounter }; +} diff --git a/apps/ehr/src/features/visits/shared/components/review-tab/MissingCard.tsx b/apps/ehr/src/features/visits/shared/components/review-tab/MissingCard.tsx index 30b9d6bcea..98e4c09c11 100644 --- a/apps/ehr/src/features/visits/shared/components/review-tab/MissingCard.tsx +++ b/apps/ehr/src/features/visits/shared/components/review-tab/MissingCard.tsx @@ -2,7 +2,7 @@ import { otherColors } from '@ehrTheme/colors'; import { WarningAmber } from '@mui/icons-material'; import { Avatar, Box, Link, Typography } from '@mui/material'; import { FC, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { AccordionCard } from 'src/components/AccordionCard'; import { LoadingScreen } from 'src/components/LoadingScreen'; import { dataTestIds } from 'src/constants/data-test-ids'; @@ -10,11 +10,11 @@ import { getAssessmentUrl, getChiefComplaintUrl, getHPIUrl } from 'src/features/ import { TelemedAppointmentVisitTabs } from 'utils'; import { useChartFields } from '../../hooks/useChartFields'; import { useAiSuggestionNotes } from '../../stores/appointment/appointment.queries'; -import { useAppointmentData, useAppTelemedLocalStore, useChartData } from '../../stores/appointment/appointment.store'; +import { useAppTelemedLocalStore, useChartData } from '../../stores/appointment/appointment.store'; import { useAppFlags } from '../../stores/contexts/useAppFlags'; export const MissingCard: FC = () => { - const { appointment } = useAppointmentData(); + const { id: appointmentIdFromUrl } = useParams(); const { chartData } = useChartData(); const { data: chartFields, isFetching } = useChartFields({ @@ -68,9 +68,9 @@ export const MissingCard: FC = () => { const navigateTo = (target: 'chief-complaint' | 'hpi' | 'assessment'): void => { if (isInPerson) { const inPersonRoutes: Record<'chief-complaint' | 'hpi' | 'assessment', string> = { - 'chief-complaint': getChiefComplaintUrl(appointment?.id || ''), - hpi: getHPIUrl(appointment?.id || ''), - assessment: getAssessmentUrl(appointment?.id || ''), + 'chief-complaint': getChiefComplaintUrl(appointmentIdFromUrl || ''), + hpi: getHPIUrl(appointmentIdFromUrl || ''), + assessment: getAssessmentUrl(appointmentIdFromUrl || ''), }; requestAnimationFrame(() => { diff --git a/apps/ehr/src/features/visits/shared/stores/appointment/appointment.queries.ts b/apps/ehr/src/features/visits/shared/stores/appointment/appointment.queries.ts index c20b2cc662..81e22061d7 100644 --- a/apps/ehr/src/features/visits/shared/stores/appointment/appointment.queries.ts +++ b/apps/ehr/src/features/visits/shared/stores/appointment/appointment.queries.ts @@ -97,7 +97,7 @@ export const useGetReviewAndSignData = ( if (!data || !onSuccess) { return; } - const reviewAndSignData = extractReviewAndSignAppointmentData(data); + const reviewAndSignData = extractReviewAndSignAppointmentData(data, { appointmentId }); onSuccess(reviewAndSignData); }); diff --git a/apps/ehr/src/features/visits/shared/stores/appointment/appointment.store.tsx b/apps/ehr/src/features/visits/shared/stores/appointment/appointment.store.tsx index 7c661d7abf..41997a599e 100644 --- a/apps/ehr/src/features/visits/shared/stores/appointment/appointment.store.tsx +++ b/apps/ehr/src/features/visits/shared/stores/appointment/appointment.store.tsx @@ -56,7 +56,7 @@ import { create } from 'zustand'; import { OystehrTelemedAPIClient } from '../../api/oystehrApi'; import { useGetAppointmentAccessibility } from '../../hooks/useGetAppointmentAccessibility'; import { useOystehrAPIClient } from '../../hooks/useOystehrAPIClient'; -import { getEncounterValues } from './parser/extractors'; +import { getAppointmentValues, getEncounterValues } from './parser/extractors'; import { parseBundle } from './parser/parser'; import { VisitMappedData, VisitResources } from './parser/types'; import { resetExamObservationsStore } from './reset-exam-observations'; @@ -287,17 +287,50 @@ export const useAppointmentData = ( const selectedEncounter = getSelectedEncounter(); const encounterToUse = selectedEncounter || state.followUpOriginEncounter; + let appointmentToUse = state.appointment; + if (encounterToUse?.appointment?.[0]?.reference) { + const encApptId = encounterToUse.appointment[0].reference.replace('Appointment/', ''); + if (encApptId !== appointmentToUse?.id) { + const matchingAppointment = state.rawResources?.find( + (r): r is Appointment => r.resourceType === 'Appointment' && r.id === encApptId + ); + if (matchingAppointment) { + appointmentToUse = matchingAppointment; + } + } + } + + let questionnaireResponseToUse = state.questionnaireResponse; + if (encounterToUse?.id) { + const matchingQR = state.rawResources?.find( + (r): r is QuestionnaireResponse => + r.resourceType === 'QuestionnaireResponse' && r.encounter?.reference === `Encounter/${encounterToUse.id}` + ); + if (matchingQR) { + questionnaireResponseToUse = matchingQR; + } + } + return { ...state, + appointment: appointmentToUse, encounter: encounterToUse, + questionnaireResponse: questionnaireResponseToUse, visitState: { ...state.visitState, + appointment: appointmentToUse, encounter: encounterToUse, + questionnaireResponse: questionnaireResponseToUse, }, resources: { ...state.resources, + appointment: getAppointmentValues(appointmentToUse), encounter: getEncounterValues(encounterToUse), }, + reviewAndSignData: extractReviewAndSignAppointmentData(state.rawResources || [], { + appointmentId: appointmentToUse?.id, + encounterId: encounterToUse?.id, + }), isAppointmentLoading: isLoading, appointmentRefetch: refetch, appointmentSetState: setState, @@ -337,16 +370,27 @@ export type AppointmentResources = const selectAppointmentData = ( data: AppointmentResources[] | undefined, - preserveSelectedEncounterId?: string + preserveSelectedEncounterId?: string, + appointmentId?: string ): (AppointmentTelemedState & InPersonAppointmentState & AppointmentRawResourcesState) | null => { if (!data) return null; - const questionnaireResponse = data?.find( - (resource: FhirResource) => resource.resourceType === 'QuestionnaireResponse' - ) as QuestionnaireResponse; - const parsed = parseBundle(data); - const appointment = data?.find((resource: FhirResource) => resource.resourceType === 'Appointment') as Appointment; + const appointments = data.filter( + (resource: FhirResource) => resource.resourceType === 'Appointment' + ) as Appointment[]; + const allEncountersForSelection = data.filter( + (resource: FhirResource) => resource.resourceType === 'Encounter' + ) as Encounter[]; + const appointment = ( + appointmentId + ? appointments.find((resource) => resource.id === appointmentId) + : appointments.find((apt) => + allEncountersForSelection.some( + (enc) => !enc.partOf && enc.appointment?.some((ref) => ref.reference === `Appointment/${apt.id}`) + ) + ) ?? appointments[0] + ) as Appointment; const patient = data?.find((resource: FhirResource) => resource.resourceType === 'Patient') as Patient; const appointmentLocationRef = appointment?.participant?.find((p) => p.actor?.reference?.startsWith('Location/')) @@ -357,12 +401,22 @@ const selectAppointmentData = ( resource.resourceType === 'Location' && resource.id === appointmentLocationId && !isLocationVirtual(resource) ); - const followUpOriginEncounter = data?.find( - (resource: FhirResource) => resource.resourceType === 'Encounter' && !resource.partOf + const allEncountersRaw = data?.filter( + (resource: FhirResource) => resource.resourceType === 'Encounter' + ) as Encounter[]; + const appointmentRef = `Appointment/${appointment?.id}`; + let followUpOriginEncounter = allEncountersRaw?.find( + (e) => !e.partOf && e.appointment?.some((encAppointmentRef) => encAppointmentRef.reference === appointmentRef) ) as Encounter; - const followupEncounters = data?.filter( - (resource: FhirResource) => resource.resourceType === 'Encounter' && resource.partOf - ) as Encounter[] | undefined; + const followupEncounters = allEncountersRaw?.filter((e) => e.partOf) as Encounter[] | undefined; + + // For scheduled follow-up appointments, the encounter has partOf set. + // If no non-partOf encounter exists, use the encounter that references this appointment. + if (!followUpOriginEncounter && allEncountersRaw?.length > 0) { + followUpOriginEncounter = allEncountersRaw.find( + (e) => e.appointment?.some((encAppointmentRef) => encAppointmentRef.reference === appointmentRef) + ) as Encounter; + } // Preserve the selected encounter ID if it exists and is valid, otherwise default to main encounter const allEncounters = [followUpOriginEncounter, ...(followupEncounters || [])].filter(Boolean); @@ -370,6 +424,14 @@ const selectAppointmentData = ( preserveSelectedEncounterId && allEncounters.some((enc) => enc.id === preserveSelectedEncounterId) ? preserveSelectedEncounterId : followUpOriginEncounter?.id; + const selectedEncounter = allEncounters.find((enc) => enc.id === validSelectedEncounterId) ?? followUpOriginEncounter; + const questionnaireResponse = data + ?.filter((resource: FhirResource) => resource.resourceType === 'QuestionnaireResponse') + .find( + (resource) => + (resource as QuestionnaireResponse).encounter?.reference === `Encounter/${selectedEncounter?.id}` || + (resource as QuestionnaireResponse).encounter?.reference === `Encounter/${followUpOriginEncounter?.id}` + ) as QuestionnaireResponse; return { rawResources: data, @@ -397,7 +459,10 @@ const selectAppointmentData = ( ) .flatMap((docRef: FhirResource) => (docRef as DocumentReference).content.map((cnt) => cnt.attachment.url)) .filter(Boolean) as string[]) || [], - reviewAndSignData: extractReviewAndSignAppointmentData(data), + reviewAndSignData: extractReviewAndSignAppointmentData(data, { + appointmentId: appointment?.id, + encounterId: selectedEncounter?.id, + }), resources: parsed.resources, mappedData: parsed.mappedData, @@ -475,6 +540,10 @@ const useGetAppointment = ( name: '_revinclude:iterate', value: 'Encounter:part-of', }, + { + name: '_include:iterate', + value: 'Encounter:appointment', + }, { name: '_revinclude:iterate', value: 'QuestionnaireResponse:encounter', @@ -491,7 +560,48 @@ const useGetAppointment = ( resource.questionnaire?.includes('https://ottehr.com/FHIR/Questionnaire/intake-paperwork-virtual') ); - return selectAppointmentData(data, currentSelectedEncounterId); + // For scheduled follow-ups: the encounter has partOf referencing a parent encounter. + // We need to fetch the parent encounter's full tree to show all encounters in the sidebar. + const scheduledFollowupEncounter = data.find( + (r) => r.resourceType === 'Encounter' && (r as Encounter).partOf + ) as Encounter | undefined; + + if (scheduledFollowupEncounter?.partOf?.reference) { + const parentEncounterId = scheduledFollowupEncounter.partOf.reference.replace('Encounter/', ''); + // Fetch the parent encounter to find its appointment, then fetch the full tree + const parentEncounter = await oystehr.fhir.get({ + resourceType: 'Encounter', + id: parentEncounterId, + }); + const parentAppointmentId = parentEncounter.appointment?.[0]?.reference?.replace('Appointment/', ''); + + if (parentAppointmentId && parentAppointmentId !== appointmentId) { + const parentData = ( + await oystehr.fhir.search({ + resourceType: 'Appointment', + params: [ + { name: '_id', value: parentAppointmentId }, + { name: '_revinclude:iterate', value: 'Encounter:appointment' }, + { name: '_revinclude:iterate', value: 'Encounter:part-of' }, + { name: '_include:iterate', value: 'Encounter:appointment' }, + { name: '_revinclude:iterate', value: 'QuestionnaireResponse:encounter' }, + ], + }) + ).unbundle(); + + // Merge parent tree data, avoiding duplicates + const existingIds = new Set(data.map((r) => `${r.resourceType}/${r.id}`)); + for (const resource of parentData) { + const key = `${resource.resourceType}/${resource.id}`; + if (!existingIds.has(key)) { + data.push(resource); + existingIds.add(key); + } + } + } + } + + return selectAppointmentData(data, currentSelectedEncounterId || scheduledFollowupEncounter?.id, appointmentId); } throw new Error('fhir client not defined or appointmentId not provided'); }, diff --git a/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.test.ts b/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.test.ts new file mode 100644 index 0000000000..4d2945628c --- /dev/null +++ b/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.test.ts @@ -0,0 +1,114 @@ +import { Appointment, Encounter, QuestionnaireResponse } from 'fhir/r4b'; +import { describe, expect, it } from 'vitest'; +import { getResources } from './extractors'; + +describe('getResources', () => { + it('selects appointment and questionnaire by explicit context IDs', () => { + const parentAppointment: Appointment = { + resourceType: 'Appointment', + id: 'parent-appointment', + status: 'booked', + participant: [], + }; + const followupAppointment: Appointment = { + resourceType: 'Appointment', + id: 'followup-appointment', + status: 'booked', + participant: [], + }; + const parentEncounter: Encounter = { + resourceType: 'Encounter', + id: 'parent-encounter', + status: 'in-progress', + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + appointment: [{ reference: 'Appointment/parent-appointment' }], + }; + const followupEncounter: Encounter = { + resourceType: 'Encounter', + id: 'followup-encounter', + status: 'planned', + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + partOf: { reference: 'Encounter/parent-encounter' }, + appointment: [{ reference: 'Appointment/followup-appointment' }], + }; + const parentQuestionnaire: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + id: 'parent-qr', + status: 'completed', + encounter: { reference: 'Encounter/parent-encounter' }, + }; + const followupQuestionnaire: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + id: 'followup-qr', + status: 'completed', + encounter: { reference: 'Encounter/followup-encounter' }, + }; + + const result = getResources( + [ + parentAppointment, + followupQuestionnaire, + parentQuestionnaire, + followupEncounter, + parentEncounter, + followupAppointment, + ], + { + appointmentId: 'followup-appointment', + encounterId: 'followup-encounter', + } + ); + + expect(result.appointment?.id).toBe('followup-appointment'); + expect(result.encounter?.id).toBe('followup-encounter'); + expect(result.questionnaireResponse?.id).toBe('followup-qr'); + }); + + it('prefers the main appointment when no context is provided and multiple appointments exist', () => { + const parentAppointment: Appointment = { + resourceType: 'Appointment', + id: 'parent-appointment', + status: 'booked', + participant: [], + }; + const followupAppointment: Appointment = { + resourceType: 'Appointment', + id: 'followup-appointment', + status: 'booked', + participant: [], + }; + const parentEncounter: Encounter = { + resourceType: 'Encounter', + id: 'parent-encounter', + status: 'in-progress', + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + appointment: [{ reference: 'Appointment/parent-appointment' }], + }; + const followupEncounter: Encounter = { + resourceType: 'Encounter', + id: 'followup-encounter', + status: 'planned', + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + partOf: { reference: 'Encounter/parent-encounter' }, + appointment: [{ reference: 'Appointment/followup-appointment' }], + }; + const parentQuestionnaire: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + id: 'parent-qr', + status: 'completed', + encounter: { reference: 'Encounter/parent-encounter' }, + }; + + const result = getResources([ + followupAppointment, + parentAppointment, + followupEncounter, + parentEncounter, + parentQuestionnaire, + ]); + + expect(result.appointment?.id).toBe('parent-appointment'); + expect(result.encounter?.id).toBe('parent-encounter'); + expect(result.questionnaireResponse?.id).toBe('parent-qr'); + }); +}); diff --git a/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.ts b/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.ts index 7aaab0ff76..11c519ff91 100644 --- a/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.ts +++ b/apps/ehr/src/features/visits/shared/stores/appointment/parser/extractors.ts @@ -192,8 +192,17 @@ export const extractUrlsFromAppointmentData = (resourceBundle: FhirResource[], d ); }; +const findMainAppointment = (appointments: Appointment[], encounters: Encounter[]): Appointment | undefined => { + return ( + appointments.find((apt) => + encounters.some((enc) => !enc.partOf && enc.appointment?.some((ref) => ref.reference === `Appointment/${apt.id}`)) + ) ?? appointments[0] + ); +}; + export const getResources = ( - resourceBundle: FhirResource[] | null + resourceBundle: FhirResource[] | null, + context?: { appointmentId?: string; encounterId?: string } ): Partial<{ appointment: Appointment; patient: Patient; @@ -208,16 +217,32 @@ export const getResources = ( resourceBundle.filter((resource: FhirResource) => resource.resourceType === resourceType) as T[] | undefined; const locations = findResources('Location'); + const appointments = findResources('Appointment') ?? []; + const encounters = findResources('Encounter') ?? []; + const questionnaireResponses = findResources('QuestionnaireResponse') ?? []; const virtualLocation = locations?.find(isLocationVirtual); const physicalLocation = locations?.find((location) => !isLocationVirtual(location)); + const appointment = context?.appointmentId + ? appointments.find((resource) => resource.id === context.appointmentId) + : findMainAppointment(appointments, encounters); + const encounter = context?.encounterId + ? encounters.find((resource) => resource.id === context.encounterId) + : encounters.find( + (resource) => + !resource.partOf && + resource.appointment?.some((appointmentRef) => appointmentRef.reference === `Appointment/${appointment?.id}`) + ) ?? encounters.find((resource) => !resource.partOf); + const questionnaireResponse = + questionnaireResponses.find((resource) => resource.encounter?.reference === `Encounter/${encounter?.id}`) ?? + questionnaireResponses[0]; return { - appointment: findResources('Appointment')?.[0], + appointment, patient: findResources('Patient')?.[0], location: physicalLocation, locationVirtual: virtualLocation, - encounter: findResources('Encounter')?.find((encounter) => !encounter.partOf), - questionnaireResponse: findResources('QuestionnaireResponse')?.[0], + encounter, + questionnaireResponse, }; }; diff --git a/apps/ehr/src/features/visits/shared/utils/appointment-accessibility.helper.ts b/apps/ehr/src/features/visits/shared/utils/appointment-accessibility.helper.ts index f6f55df805..d2b830f2bf 100644 --- a/apps/ehr/src/features/visits/shared/utils/appointment-accessibility.helper.ts +++ b/apps/ehr/src/features/visits/shared/utils/appointment-accessibility.helper.ts @@ -65,10 +65,11 @@ export const getAppointmentAccessibilityData = ({ const isAppointmentLockedByMetaTag = appointment ? isAppointmentLocked(appointment) : false; const visitType = getEncounterVisitType(encounter); const isFollowup = visitType === 'follow-up'; + const isScheduledFollowup = visitType === 'scheduled-follow-up'; const isAppointmentReadOnly = (() => { if (appFlags.isInPerson) { - return isAppointmentLockedByMetaTag && !isFollowup; + return isAppointmentLockedByMetaTag && !isFollowup && !isScheduledFollowup; } return ( diff --git a/apps/ehr/src/features/visits/telemed/utils/appointments.test.ts b/apps/ehr/src/features/visits/telemed/utils/appointments.test.ts new file mode 100644 index 0000000000..f27a4da09d --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/utils/appointments.test.ts @@ -0,0 +1,85 @@ +import { Appointment, Encounter } from 'fhir/r4b'; +import { describe, expect, it } from 'vitest'; +import { extractReviewAndSignAppointmentData } from './appointments'; + +describe('extractReviewAndSignAppointmentData', () => { + it('matches encounter by appointment reference instead of first bundle order', () => { + const targetAppointment: Appointment = { + resourceType: 'Appointment', + id: 'target-appointment', + status: 'fulfilled', + start: '2026-03-23T10:00:00.000Z', + participant: [], + }; + const parentAppointment: Appointment = { + resourceType: 'Appointment', + id: 'parent-appointment', + status: 'fulfilled', + start: '2026-03-20T10:00:00.000Z', + participant: [], + }; + const parentEncounter: Encounter = { + resourceType: 'Encounter', + id: 'parent-encounter', + status: 'finished', + appointment: [{ reference: 'Appointment/parent-appointment' }], + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + statusHistory: [{ status: 'finished', period: { end: '2026-03-20T10:45:00.000Z' } }], + }; + const targetEncounter: Encounter = { + resourceType: 'Encounter', + id: 'target-encounter', + status: 'finished', + appointment: [{ reference: 'Appointment/target-appointment' }], + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + statusHistory: [{ status: 'finished', period: { end: '2026-03-23T10:30:00.000Z' } }], + }; + + const result = extractReviewAndSignAppointmentData( + [parentAppointment, targetAppointment, parentEncounter, targetEncounter], + { appointmentId: 'target-appointment' } + ); + + expect(result).toEqual({ signedOnDate: '2026-03-23T10:30:00.000Z' }); + }); + + it('prefers main appointment when no context is provided and follow-up appointment appears first', () => { + const followupAppointment: Appointment = { + resourceType: 'Appointment', + id: 'followup-appointment', + status: 'booked', + participant: [], + }; + const mainAppointment: Appointment = { + resourceType: 'Appointment', + id: 'main-appointment', + status: 'fulfilled', + participant: [], + }; + const mainEncounter: Encounter = { + resourceType: 'Encounter', + id: 'main-encounter', + status: 'finished', + appointment: [{ reference: 'Appointment/main-appointment' }], + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + statusHistory: [{ status: 'finished', period: { end: '2026-03-20T12:00:00.000Z' } }], + }; + const followupEncounter: Encounter = { + resourceType: 'Encounter', + id: 'followup-encounter', + status: 'planned', + partOf: { reference: 'Encounter/main-encounter' }, + appointment: [{ reference: 'Appointment/followup-appointment' }], + class: { system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', code: 'VR', display: 'virtual' }, + }; + + const result = extractReviewAndSignAppointmentData([ + followupAppointment, + mainAppointment, + followupEncounter, + mainEncounter, + ]); + + expect(result).toEqual({ signedOnDate: '2026-03-20T12:00:00.000Z' }); + }); +}); diff --git a/apps/ehr/src/features/visits/telemed/utils/appointments.ts b/apps/ehr/src/features/visits/telemed/utils/appointments.ts index c48630c9b2..5ce5552885 100644 --- a/apps/ehr/src/features/visits/telemed/utils/appointments.ts +++ b/apps/ehr/src/features/visits/telemed/utils/appointments.ts @@ -180,10 +180,25 @@ export const extractPhotoUrlsFromAppointmentData = (appointment: AppointmentReso ); }; -export const extractReviewAndSignAppointmentData = (data: AppointmentResources[]): ReviewAndSignData | undefined => { - const appointment = data?.find( +const findMainAppointment = (appointments: Appointment[], encounters: Encounter[]): Appointment | undefined => { + return ( + appointments.find((apt) => + encounters.some((enc) => !enc.partOf && enc.appointment?.some((ref) => ref.reference === `Appointment/${apt.id}`)) + ) ?? appointments[0] + ); +}; + +export const extractReviewAndSignAppointmentData = ( + data: AppointmentResources[], + context?: { appointmentId?: string; encounterId?: string } +): ReviewAndSignData | undefined => { + const appointments = data.filter( (resource: FhirResource) => resource.resourceType === 'Appointment' - ) as unknown as Appointment; + ) as Appointment[]; + const encounters = data.filter((resource: FhirResource) => resource.resourceType === 'Encounter') as Encounter[]; + const appointment = context?.appointmentId + ? appointments.find((resource) => resource.id === context.appointmentId) + : findMainAppointment(appointments, encounters); if (!appointment) { return; @@ -191,9 +206,12 @@ export const extractReviewAndSignAppointmentData = (data: AppointmentResources[] const appointmentStatus = appointment.status; - const encounter = data?.find( - (resource: FhirResource) => resource.resourceType === 'Encounter' - ) as unknown as Encounter; + const encounter = context?.encounterId + ? encounters.find((resource) => resource.id === context.encounterId) + : encounters.find( + (resource) => + resource.appointment?.some((appointmentRef) => appointmentRef.reference === `Appointment/${appointment.id}`) + ) ?? encounters[0]; if (!encounter) { return; diff --git a/apps/ehr/src/pages/AddPatient.tsx b/apps/ehr/src/pages/AddPatient.tsx index 74a7ee39d7..306ec48862 100644 --- a/apps/ehr/src/pages/AddPatient.tsx +++ b/apps/ehr/src/pages/AddPatient.tsx @@ -17,7 +17,7 @@ import { Location, Schedule, Slot } from 'fhir/r4b'; import { DateTime } from 'luxon'; import { enqueueSnackbar } from 'notistack'; import { useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { AddVisitPatientInformationCard } from 'src/features/visits/shared/components/staff-add-visit/AddVisitPatientInformationCard'; import { BOOKING_CONFIG, @@ -82,9 +82,28 @@ enum VisitType { } export default function AddPatient(): JSX.Element { - const [selectedLocation, setSelectedLocation] = useState(); - const [birthDate, setBirthDate] = useState(null); // i would love to not have to handle this state but i think the date search component would have to change and i dont want to touch that right now - const [patientInfo, setPatientInfo] = useState(undefined); + const location = useLocation(); + const followUpState = location.state as + | { + parentEncounterId?: string; + parentLocation?: LocationWithWalkinSchedule; + patientId?: string; + patientInfo?: AddVisitPatientInfo; + } + | undefined; + const parentEncounterId = followUpState?.parentEncounterId; + const isScheduledFollowUp = !!parentEncounterId; + console.log('[AddPatient] location.state:', location.state, 'parentEncounterId:', parentEncounterId); + + const [selectedLocation, setSelectedLocation] = useState( + followUpState?.parentLocation + ); + const [birthDate, setBirthDate] = useState( + followUpState?.patientInfo?.dateOfBirth ? DateTime.fromISO(followUpState.patientInfo.dateOfBirth) : null + ); + const [patientInfo, setPatientInfo] = useState( + followUpState?.patientInfo || undefined + ); const [reasonForVisit, setReasonForVisit] = useState(''); const [reasonForVisitAdditional, setReasonForVisitAdditional] = useState(''); const [visitType, setVisitType] = useState(); @@ -102,7 +121,9 @@ export default function AddPatient(): JSX.Element { const [validDate, setValidDate] = useState(true); const [selectSlotDialogOpen, setSelectSlotDialogOpen] = useState(false); const [validReasonForVisit, setValidReasonForVisit] = useState(true); - const [showFields, setShowFields] = useState('initialPatientSearch'); + const [showFields, setShowFields] = useState( + isScheduledFollowUp ? 'existingPatientSelected' : 'initialPatientSearch' + ); useEffect(() => { setReasonForVisit(''); @@ -233,6 +254,7 @@ export default function AddPatient(): JSX.Element { reasonAdditional: reasonForVisitAdditional !== '' ? reasonForVisitAdditional : undefined, }, slotId: persistedSlot.id!, + parentEncounterId, }; let response; @@ -261,7 +283,7 @@ export default function AddPatient(): JSX.Element { @@ -273,7 +295,7 @@ export default function AddPatient(): JSX.Element { color={'primary.dark'} data-testid={dataTestIds.addPatientPage.pageTitle} > - Add Visit + {isScheduledFollowUp ? 'Add Scheduled Follow-up Visit' : 'Add Visit'} {/* form content */} @@ -341,17 +363,24 @@ export default function AddPatient(): JSX.Element { } /> - + {!isScheduledFollowUp && ( + + )} + {isScheduledFollowUp && patientInfo && ( + + Patient: {patientInfo.firstName} {patientInfo.lastName} + + )} {/* Visit Information */} {shouldShowReasonForVisitFields && ( diff --git a/apps/ehr/tests/component/AddPatientFollowup.test.tsx b/apps/ehr/tests/component/AddPatientFollowup.test.tsx new file mode 100644 index 0000000000..83706dccb8 --- /dev/null +++ b/apps/ehr/tests/component/AddPatientFollowup.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AddPatientFollowup from '../../src/features/visits/shared/components/patient/AddPatientFollowup'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ id: 'test-patient-123' }), + useNavigate: vi.fn().mockReturnValue(vi.fn()), + useLocation: vi.fn().mockReturnValue({ state: null, pathname: '', search: '', hash: '', key: '' }), + }; +}); + +vi.mock('../../src/hooks/useGetPatient', () => ({ + useGetPatient: () => ({ + patient: { + resourceType: 'Patient', + id: 'test-patient-123', + name: [{ given: ['Test'], family: 'Patient' }], + birthDate: '1990-01-01', + gender: 'male', + }, + }), +})); + +// Mock the child form components to avoid deep dependency issues +vi.mock('../../src/features/visits/shared/components/patient/PatientFollowupForm', () => ({ + default: () =>
    Annotation Form
    , +})); + +vi.mock('../../src/features/visits/shared/components/patient/ScheduledFollowupParentSelector', () => ({ + default: () =>
    Scheduled Form
    , +})); + +vi.mock('../../src/layout/PageContainer', () => ({ + default: ({ children }: { children: ReactNode }) =>
    {children}
    , +})); + +vi.mock('../../src/components/CustomBreadcrumbs', () => ({ + default: () =>
    Breadcrumbs
    , +})); + +describe('AddPatientFollowup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the page title', () => { + render( + + + + ); + expect(screen.getByText('Add Follow-up Visit')).toBeVisible(); + }); + + it('renders Annotation and Scheduled Visit radio buttons', () => { + render( + + + + ); + expect(screen.getByLabelText('Annotation')).toBeInTheDocument(); + expect(screen.getByLabelText('Scheduled Visit')).toBeInTheDocument(); + // Labels should be visible even though the radio input itself is visually hidden by MUI + expect(screen.getByText('Annotation')).toBeVisible(); + expect(screen.getByText('Scheduled Visit')).toBeVisible(); + }); + + it('defaults to Annotation mode and shows annotation form', () => { + render( + + + + ); + expect(screen.getByLabelText('Annotation')).toBeChecked(); + expect(screen.getByTestId('annotation-form')).toBeVisible(); + expect(screen.queryByTestId('scheduled-form')).not.toBeInTheDocument(); + }); + + it('switches to Scheduled form when Scheduled Visit is selected', async () => { + const user = userEvent.setup(); + render( + + + + ); + + await user.click(screen.getByLabelText('Scheduled Visit')); + + expect(screen.getByTestId('scheduled-form')).toBeVisible(); + expect(screen.queryByTestId('annotation-form')).not.toBeInTheDocument(); + }); + + it('switches back to Annotation form when Annotation is re-selected', async () => { + const user = userEvent.setup(); + render( + + + + ); + + await user.click(screen.getByLabelText('Scheduled Visit')); + expect(screen.getByTestId('scheduled-form')).toBeVisible(); + + await user.click(screen.getByLabelText('Annotation')); + expect(screen.getByTestId('annotation-form')).toBeVisible(); + expect(screen.queryByTestId('scheduled-form')).not.toBeInTheDocument(); + }); + + it('renders breadcrumbs', () => { + render( + + + + ); + expect(screen.getByTestId('breadcrumbs')).toBeVisible(); + }); +}); diff --git a/apps/ehr/tests/e2e/specs/in-person/scheduled-followup.spec.ts b/apps/ehr/tests/e2e/specs/in-person/scheduled-followup.spec.ts new file mode 100644 index 0000000000..ea4bb8d41f --- /dev/null +++ b/apps/ehr/tests/e2e/specs/in-person/scheduled-followup.spec.ts @@ -0,0 +1,156 @@ +import { BrowserContext, expect, Page, test } from '@playwright/test'; +import { dataTestIds } from 'src/constants/data-test-ids'; +import { ResourceHandler } from '../../../e2e-utils/resource-handler'; + +const DEFAULT_TIMEOUT = { timeout: 15000 }; +const LONG_TIMEOUT = { timeout: 35000 }; + +let context: BrowserContext; +let page: Page; +const resourceHandler = new ResourceHandler('in-person'); + +test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await resourceHandler.setResources({ skipPaperwork: true }); +}); + +test.afterAll(async () => { + await page.close(); + await context.close(); + await resourceHandler.cleanupResources(); +}); + +test.describe.serial('Scheduled Follow-up Visit E2E', () => { + test('Follow-up page shows Annotation and Scheduled Visit toggle', async () => { + const patientId = resourceHandler.patient?.id; + expect(patientId).toBeTruthy(); + + await page.goto(`/patient/${patientId}/followup/add`); + + await test.step('Page title and radio buttons are visible', async () => { + await expect(page.getByText('Add Follow-up Visit')).toBeVisible(DEFAULT_TIMEOUT); + await expect(page.getByText('Annotation', { exact: true })).toBeVisible(DEFAULT_TIMEOUT); + await expect(page.getByText('Scheduled Visit', { exact: true })).toBeVisible(DEFAULT_TIMEOUT); + }); + + await test.step('Annotation is selected by default with annotation-specific fields', async () => { + await expect(page.getByLabel('Annotation', { exact: true })).toBeChecked(); + await expect(page.locator('label:has-text("Annotation provider")')).toBeVisible(DEFAULT_TIMEOUT); + }); + + await test.step('Switching to Scheduled shows initial visit selector', async () => { + await page.getByLabel('Scheduled Visit', { exact: true }).click(); + await expect(page.getByRole('combobox', { name: /initial visit/i })).toBeVisible(DEFAULT_TIMEOUT); + await expect(page.getByText('Continue to Add Visit')).toBeVisible(DEFAULT_TIMEOUT); + await expect(page.locator('label:has-text("Annotation provider")')).not.toBeVisible(); + }); + + await test.step('Switching back to Annotation restores annotation fields', async () => { + await page.getByLabel('Annotation', { exact: true }).click(); + await expect(page.locator('label:has-text("Annotation provider")')).toBeVisible(DEFAULT_TIMEOUT); + await expect(page.getByText('Continue to Add Visit')).not.toBeVisible(); + }); + }); + + test('Create a scheduled follow-up, verify on tracking board and patient record', async () => { + const patientId = resourceHandler.patient?.id; + expect(patientId).toBeTruthy(); + + // Step 1: Navigate to follow-up creation page + await test.step('Navigate to follow-up page and select Scheduled Visit', async () => { + await page.goto(`/patient/${patientId}/followup/add`); + await expect(page.getByText('Add Follow-up Visit')).toBeVisible(DEFAULT_TIMEOUT); + await page.getByLabel('Scheduled Visit', { exact: true }).click(); + }); + + // Step 2: Select parent encounter + await test.step('Select parent encounter', async () => { + const combobox = page.getByRole('combobox', { name: /initial visit/i }); + await expect(combobox).toBeVisible(DEFAULT_TIMEOUT); + await combobox.click(); + await page.getByRole('option').first().waitFor(DEFAULT_TIMEOUT); + await page.getByRole('option').first().click(); + }); + + // Step 3: Continue to Add Visit page + await test.step('Continue to Add Visit page', async () => { + await page.getByText('Continue to Add Visit').click(); + await expect(page.getByText('Add Scheduled Follow-up Visit')).toBeVisible(LONG_TIMEOUT); + await expect(page.getByText(/Patient:/)).toBeVisible(DEFAULT_TIMEOUT); + }); + + // Step 4: Select visit type + await test.step('Select walk-in visit type', async () => { + await page.getByTestId(dataTestIds.addPatientPage.visitTypeDropdown).click(); + await page.getByText('Walk-in In Person Visit').click(); + }); + + // Step 5: Select service category if not disabled (auto-selected when only one) + await test.step('Select service category', async () => { + const serviceCategoryDropdown = page.getByTestId(dataTestIds.addPatientPage.serviceCategoryDropdown); + const isDisabled = + (await serviceCategoryDropdown.locator('[aria-disabled="true"]').count()) > 0 || + (await serviceCategoryDropdown.locator('input[disabled]').count()) > 0; + if (!isDisabled) { + await serviceCategoryDropdown.click(); + await page.getByRole('option').first().waitFor(DEFAULT_TIMEOUT); + await page.getByRole('option').first().click(); + } + }); + + // Step 6: Select location from dropdown (must use dropdown to get walkinSchedule) + await test.step('Select location', async () => { + const locationSelect = page.getByTestId(dataTestIds.dashboard.locationSelect); + // Clear and re-select to ensure walkinSchedule data is loaded + await locationSelect.click(); + await page.locator('li[role="option"]').first().waitFor(DEFAULT_TIMEOUT); + await page.locator('li[role="option"]').first().click(); + }); + + // Step 7: Select reason for visit and submit + await test.step('Fill reason for visit and submit', async () => { + const reasonDropdown = page.getByTestId(dataTestIds.addPatientPage.reasonForVisitDropdown); + await expect(reasonDropdown).toBeVisible(LONG_TIMEOUT); + // Click the dropdown's combobox element to open it + await reasonDropdown.locator('[role="combobox"]').click(); + // Wait for menu and select Fever + const feverOption = page.getByRole('option', { name: 'Fever' }); + await expect(feverOption).toBeVisible(DEFAULT_TIMEOUT); + await feverOption.click(); + // Wait for menu to close + await page.waitForTimeout(500); + + // Submit the form — use force click to bypass any overlays + const addButton = page.getByTestId(dataTestIds.addPatientPage.addButton); + await expect(addButton).toBeEnabled(DEFAULT_TIMEOUT); + + // Log current form state for debugging + const currentUrl = page.url(); + console.log('Before submit, URL:', currentUrl); + + await addButton.click({ force: true }); + + // Wait for either navigation or loading state to complete + // The submission triggers createSlot + createAppointment which can take time + await page.waitForURL((url) => url.pathname === '/visits', { timeout: 60000, waitUntil: 'domcontentloaded' }); + console.log('After submit, URL:', page.url()); + }); + + // Step 7: Verify on tracking board — visit was created and we redirected + await test.step('Verify on tracking board', async () => { + await expect(page).toHaveURL(/\/visits/); + }); + + // Step 8: Verify on patient record with indentation + await test.step('Verify indented follow-up on patient record', async () => { + await page.goto(`/patient/${patientId}`); + const tableRows = page.locator('table tbody tr'); + await expect(tableRows.first()).toBeVisible(LONG_TIMEOUT); + + // Verify indentation icon exists (SubdirectoryArrowRightIcon) + const indentedRows = page.locator('svg[data-testid="SubdirectoryArrowRightIcon"]'); + await expect(indentedRows.first()).toBeVisible(DEFAULT_TIMEOUT); + }); + }); +}); diff --git a/packages/utils/lib/fhir/encounter.test.ts b/packages/utils/lib/fhir/encounter.test.ts new file mode 100644 index 0000000000..399203481b --- /dev/null +++ b/packages/utils/lib/fhir/encounter.test.ts @@ -0,0 +1,143 @@ +import { Encounter } from 'fhir/r4b'; +import { describe, expect, it } from 'vitest'; +import { + EncounterVisitType, + FOLLOWUP_SUBTYPE_SYSTEM, + FOLLOWUP_SYSTEMS, + getEncounterVisitType, + getFollowupSubtype, + isAnnotationFollowupEncounter, + isFollowupEncounter, + isScheduledFollowupEncounter, +} from './encounter'; + +const makeEncounter = (overrides: Partial = {}): Encounter => ({ + resourceType: 'Encounter', + status: 'in-progress', + class: { system: 'http://hl7.org/fhir/R4/v3/ActEncounterCode/vs.html', code: 'ACUTE' }, + ...overrides, +}); + +const makeFollowupType = (subtype?: string): Encounter['type'] => [ + { + coding: [ + { + system: FOLLOWUP_SYSTEMS.type.url, + code: FOLLOWUP_SYSTEMS.type.code, + display: 'Follow-up Encounter', + }, + ...(subtype + ? [ + { + system: FOLLOWUP_SUBTYPE_SYSTEM, + code: subtype, + display: subtype, + }, + ] + : []), + ], + text: 'Follow-up Encounter', + }, +]; + +describe('Encounter follow-up helpers', () => { + describe('isFollowupEncounter', () => { + it('returns false for a regular encounter', () => { + expect(isFollowupEncounter(makeEncounter())).toBe(false); + }); + + it('returns true for an annotation follow-up', () => { + expect(isFollowupEncounter(makeEncounter({ type: makeFollowupType('annotation') }))).toBe(true); + }); + + it('returns true for a scheduled follow-up', () => { + expect(isFollowupEncounter(makeEncounter({ type: makeFollowupType('scheduled') }))).toBe(true); + }); + + it('returns true for a follow-up without subtype coding', () => { + expect(isFollowupEncounter(makeEncounter({ type: makeFollowupType() }))).toBe(true); + }); + }); + + describe('getFollowupSubtype', () => { + it('returns undefined for a regular encounter', () => { + expect(getFollowupSubtype(makeEncounter())).toBeUndefined(); + }); + + it('returns "annotation" for an annotation follow-up', () => { + expect(getFollowupSubtype(makeEncounter({ type: makeFollowupType('annotation') }))).toBe('annotation'); + }); + + it('returns "scheduled" for a scheduled follow-up', () => { + expect(getFollowupSubtype(makeEncounter({ type: makeFollowupType('scheduled') }))).toBe('scheduled'); + }); + + it('defaults to "annotation" when follow-up has no subtype coding', () => { + expect(getFollowupSubtype(makeEncounter({ type: makeFollowupType() }))).toBe('annotation'); + }); + }); + + describe('isScheduledFollowupEncounter', () => { + it('returns false for a regular encounter', () => { + expect(isScheduledFollowupEncounter(makeEncounter())).toBe(false); + }); + + it('returns false for an annotation follow-up', () => { + expect(isScheduledFollowupEncounter(makeEncounter({ type: makeFollowupType('annotation') }))).toBe(false); + }); + + it('returns true for a scheduled follow-up', () => { + expect(isScheduledFollowupEncounter(makeEncounter({ type: makeFollowupType('scheduled') }))).toBe(true); + }); + + it('returns false for a follow-up without subtype coding', () => { + expect(isScheduledFollowupEncounter(makeEncounter({ type: makeFollowupType() }))).toBe(false); + }); + }); + + describe('isAnnotationFollowupEncounter', () => { + it('returns false for a regular encounter', () => { + expect(isAnnotationFollowupEncounter(makeEncounter())).toBe(false); + }); + + it('returns true for an annotation follow-up', () => { + expect(isAnnotationFollowupEncounter(makeEncounter({ type: makeFollowupType('annotation') }))).toBe(true); + }); + + it('returns false for a scheduled follow-up', () => { + expect(isAnnotationFollowupEncounter(makeEncounter({ type: makeFollowupType('scheduled') }))).toBe(false); + }); + + it('returns true for a follow-up without subtype coding (defaults to annotation)', () => { + expect(isAnnotationFollowupEncounter(makeEncounter({ type: makeFollowupType() }))).toBe(true); + }); + }); + + describe('getEncounterVisitType', () => { + it('returns "main" for a regular encounter', () => { + expect(getEncounterVisitType(makeEncounter())).toBe('main' as EncounterVisitType); + }); + + it('returns "main" for undefined encounter', () => { + expect(getEncounterVisitType(undefined)).toBe('main' as EncounterVisitType); + }); + + it('returns "follow-up" for an annotation follow-up', () => { + expect(getEncounterVisitType(makeEncounter({ type: makeFollowupType('annotation') }))).toBe( + 'follow-up' as EncounterVisitType + ); + }); + + it('returns "scheduled-follow-up" for a scheduled follow-up', () => { + expect(getEncounterVisitType(makeEncounter({ type: makeFollowupType('scheduled') }))).toBe( + 'scheduled-follow-up' as EncounterVisitType + ); + }); + + it('returns "follow-up" for a follow-up without subtype (defaults to annotation)', () => { + expect(getEncounterVisitType(makeEncounter({ type: makeFollowupType() }))).toBe( + 'follow-up' as EncounterVisitType + ); + }); + }); +}); diff --git a/packages/utils/lib/fhir/encounter.ts b/packages/utils/lib/fhir/encounter.ts index 2239f18e52..0980741b34 100644 --- a/packages/utils/lib/fhir/encounter.ts +++ b/packages/utils/lib/fhir/encounter.ts @@ -1,6 +1,6 @@ import Oystehr from '@oystehr/sdk'; import { Operation } from 'fast-json-patch'; -import { Encounter, EncounterStatusHistory, Extension, Location } from 'fhir/r4b'; +import { Appointment, Encounter, EncounterStatusHistory, Extension, Location, Resource } from 'fhir/r4b'; import { DateTime } from 'luxon'; import { CODE_SYSTEM_ACT_CODE_V3 } from '../helpers'; import { @@ -17,6 +17,9 @@ import { ENCOUNTER_PAYMENT_VARIANT_EXTENSION_URL, FHIR_BASE_URL, FHIR_EXTENSION export const FOLLOWUP_TYPES = ['Follow-up Encounter'] as const; export type FollowupType = (typeof FOLLOWUP_TYPES)[number]; +export type FollowupSubtype = 'annotation' | 'scheduled'; +export const FOLLOWUP_SUBTYPE_SYSTEM = `${FHIR_BASE_URL}/followup-subtype`; + export const FOLLOWUP_REASONS = [ 'Result - Lab', 'Result - Radiology', @@ -54,10 +57,30 @@ export const isFollowupEncounter = (encounter: Encounter): boolean => { ); }; -export type EncounterVisitType = 'main' | 'follow-up'; +export const getFollowupSubtype = (encounter: Encounter): FollowupSubtype | undefined => { + if (!isFollowupEncounter(encounter)) return undefined; + const subtypeCoding = encounter.type + ?.flatMap((t) => t.coding ?? []) + .find((c) => c.system === FOLLOWUP_SUBTYPE_SYSTEM); + if (subtypeCoding?.code === 'scheduled') return 'scheduled'; + return 'annotation'; +}; + +export const isScheduledFollowupEncounter = (encounter: Encounter): boolean => { + return getFollowupSubtype(encounter) === 'scheduled'; +}; + +export const isAnnotationFollowupEncounter = (encounter: Encounter): boolean => { + return isFollowupEncounter(encounter) && !isScheduledFollowupEncounter(encounter); +}; + +export type EncounterVisitType = 'main' | 'follow-up' | 'scheduled-follow-up'; export const getEncounterVisitType = (encounter?: Encounter): EncounterVisitType => { if (encounter && isFollowupEncounter(encounter)) { + if (isScheduledFollowupEncounter(encounter)) { + return 'scheduled-follow-up'; + } return 'follow-up'; } return 'main'; @@ -277,3 +300,80 @@ export const isEncounterSelfPay = (encounter?: Encounter): boolean => { const paymentVariant = getPaymentVariantFromEncounter(encounter); return paymentVariant === PaymentVariant.selfPay; }; + +export const buildAppointmentStartMap = (resources: Resource[]): Record => { + const map: Record = {}; + resources.forEach((r) => { + if (r.resourceType === 'Appointment' && r.id && (r as Appointment).start) { + map[r.id] = (r as Appointment).start!; + } + }); + return map; +}; + +// Resolves the best available datetime for an encounter +export const getEncounterDateTime = ( + encounter: Encounter, + appointmentStartMap: Record +): string | undefined => { + const apptId = encounter.appointment?.[0]?.reference?.replace('Appointment/', ''); + if (apptId && appointmentStartMap[apptId]) return appointmentStartMap[apptId]; + if (encounter.period?.start) return encounter.period.start; + return encounter.statusHistory?.[0]?.period?.start; +}; + +export const getEncounterDisplayName = ( + encounter: Encounter, + appointmentStartMap: Record, + formatDateTime: (iso: string) => string +): string => { + const dateTime = getEncounterDateTime(encounter, appointmentStartMap); + const dateStr = dateTime ? formatDateTime(dateTime) : ''; + if (!encounter.partOf) { + return `Main Visit${dateStr ? ` - ${dateStr}` : ''}`; + } + const typeText = encounter.type?.[0]?.text || 'Follow-up'; + return `${typeText}${dateStr ? ` - ${dateStr}` : ''}`; +}; + +export const getAnnotationFollowupStatusLabel = (encounterStatus: string | undefined): 'OPEN' | 'RESOLVED' => { + return encounterStatus === 'in-progress' ? 'OPEN' : 'RESOLVED'; +}; + +/** + * Determines which encounter should be pre-selected as the "initial visit" + * when creating a new follow-up from the current visit context. + * If the current encounter is itself a follow-up child (has partOf), the parent + * (followUpOriginEncounter) is the initial visit; otherwise the current encounter is. + */ +export const getInitialEncounterIdForFollowUp = ( + encounter: Encounter | undefined, + followUpOriginEncounter: Encounter | undefined +): string | undefined => { + return encounter?.partOf ? followUpOriginEncounter?.id : encounter?.id; +}; + +export const getFollowUpProgressNotePathSegment = ( + followupSubtype: FollowupSubtype | undefined, + encounterStatus: string | undefined +): 'review-and-sign' | 'follow-up-note' | null => { + if (followupSubtype === 'scheduled') { + if (encounterStatus === 'planned' || encounterStatus === 'arrived') return null; + return 'review-and-sign'; + } + return 'follow-up-note'; +}; + +export const getInteractionModeForEncounter = ( + encounter: Encounter, + followUpOriginEncounterId: string | undefined +): 'main' | 'follow-up' => { + if (encounter.id === followUpOriginEncounterId) return 'main'; + if (isScheduledFollowupEncounter(encounter)) return 'main'; + return 'follow-up'; +}; + +export const getEncounterLocationId = (encounter: Encounter | undefined): string | undefined => { + const locationRef = encounter?.location?.[0]?.location?.reference; + return locationRef?.split('/')[1]; +}; diff --git a/packages/utils/lib/types/api/encounter.types.ts b/packages/utils/lib/types/api/encounter.types.ts index 3fb2621277..f63bd2c6e0 100644 --- a/packages/utils/lib/types/api/encounter.types.ts +++ b/packages/utils/lib/types/api/encounter.types.ts @@ -1,5 +1,5 @@ import { Location } from 'fhir/r4b'; -import { FollowupReason, FollowupType } from '../../fhir'; +import { FollowupReason, FollowupSubtype, FollowupType } from '../../fhir'; export interface FollowupEncounterDTO { encounterId?: string; @@ -15,6 +15,7 @@ export interface PatientFollowupDetails { encounterId?: string; // will only exist when updating patientId: string | null; followupType: FollowupType; + followupSubtype?: FollowupSubtype; reason?: FollowupReason; otherReason?: string; initialEncounterID?: string; diff --git a/packages/utils/lib/types/api/patient-visit-history.types.ts b/packages/utils/lib/types/api/patient-visit-history.types.ts index de5d997ea2..a41402f9a2 100644 --- a/packages/utils/lib/types/api/patient-visit-history.types.ts +++ b/packages/utils/lib/types/api/patient-visit-history.types.ts @@ -1,4 +1,5 @@ import { Encounter, Task } from 'fhir/r4b'; +import { FollowupSubtype } from '../../fhir'; import { ServiceMode } from '../common'; import { TelemedAppointmentStatusEnum } from '../data'; import { AppointmentType, VisitStatusLabel } from './appointment.types'; @@ -48,6 +49,8 @@ export interface FollowUpVisitHistoryRow { id: string; } | undefined; + followupSubtype?: FollowupSubtype; + appointmentId?: string; } type InPersonAppointmentHistoryRow = BaseAppointmentHistoryRow & { diff --git a/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts b/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts index ba0bb33a11..ba26a9c92e 100644 --- a/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts +++ b/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts @@ -11,6 +11,7 @@ export interface CreateAppointmentInputParams { locationState?: string; unconfirmedDateOfBirth?: string | undefined; appointmentMetadata?: Appointment['meta']; + parentEncounterId?: string; } export interface CreateAppointmentResponse { diff --git a/packages/utils/lib/types/data/appointments/appointments.types.ts b/packages/utils/lib/types/data/appointments/appointments.types.ts index 5e2d12aaa0..246674bb7e 100644 --- a/packages/utils/lib/types/data/appointments/appointments.types.ts +++ b/packages/utils/lib/types/data/appointments/appointments.types.ts @@ -131,6 +131,9 @@ export interface InPersonAppointmentInformation waitingMinutes?: number; serviceCategory?: string; location?: Location; + isFollowUp?: boolean; + parentEncounterId?: string; + parentAppointmentId?: string; } export interface TelemedAppointmentInformation extends Omit { diff --git a/packages/utils/lib/utils/scheduleUtils.ts b/packages/utils/lib/utils/scheduleUtils.ts index f3c8b3d8dd..fd8207975f 100644 --- a/packages/utils/lib/utils/scheduleUtils.ts +++ b/packages/utils/lib/utils/scheduleUtils.ts @@ -18,7 +18,7 @@ import { DEFAULT_APPOINTMENT_LENGTH_MINUTES, getFullName, getPatchOperationForNewMetaTag, - isFollowupEncounter, + isAnnotationFollowupEncounter, isLocationVirtual, makeBookingOriginExtensionEntry, SCHEDULE_EXTENSION_URL, @@ -189,7 +189,7 @@ export async function getWaitingMinutesAtSchedule( console.timeEnd('get_longest_waiting_patient'); const arrivedEncounters = searchForLongestWaitingPatient.filter( - (resource) => resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter) + (resource) => resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource) ); return getWaitingMinutes(nowForTimezone, arrivedEncounters); diff --git a/packages/zambdas/src/ehr/get-appointments/index.ts b/packages/zambdas/src/ehr/get-appointments/index.ts index 0258a4ddcd..d4b2726f03 100644 --- a/packages/zambdas/src/ehr/get-appointments/index.ts +++ b/packages/zambdas/src/ehr/get-appointments/index.ts @@ -36,7 +36,7 @@ import { getVisitStatusHistory, InPersonAppointmentInformation, INSURANCE_CARD_CODE, - isFollowupEncounter, + isAnnotationFollowupEncounter, isNonPaperworkQuestionnaireResponse, isTruthy, PHOTO_ID_CARD_CODE, @@ -240,7 +240,7 @@ export const index = wrapHandler('get-appointments', async (input: ZambdaInput): if (patientId) patientIds.push(`Patient/${patientId}`); } else if (resource.resourceType === 'Patient' && resource.id) { patientIdMap[resource.id] = resource as Patient; - } else if (resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter)) { + } else if (resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource as Encounter)) { const asEnc = resource as Encounter; const apptRef = asEnc.appointment?.[0].reference; if (apptRef) { @@ -746,5 +746,12 @@ const makeAppointmentInformation = ( ?.flatMap((codeableConcept) => codeableConcept.coding ?? []) ?.find((coding) => coding.system === SERVICE_CATEGORY_SYSTEM)?.display, location: locationIdToResourceMap[encounter.location?.[0]?.location?.reference ?? ''], + isFollowUp: !!encounter.partOf, + parentEncounterId: encounter.partOf?.reference?.replace('Encounter/', ''), + parentAppointmentId: encounter.partOf + ? Object.entries(apptRefToEncounterMap) + .find(([, enc]) => `Encounter/${enc.id}` === encounter.partOf?.reference)?.[0] + ?.replace('Appointment/', '') + : undefined, }; }; diff --git a/packages/zambdas/src/ehr/patient-visit-history/get/index.ts b/packages/zambdas/src/ehr/patient-visit-history/get/index.ts index 86a0495242..e4699dc11d 100644 --- a/packages/zambdas/src/ehr/patient-visit-history/get/index.ts +++ b/packages/zambdas/src/ehr/patient-visit-history/get/index.ts @@ -8,7 +8,9 @@ import { AppointmentTypeOptions, AppointmentTypeSchema, FHIR_RESOURCE_NOT_FOUND, + FOLLOWUP_SUBTYPE_SYSTEM, FOLLOWUP_SYSTEMS, + FollowupSubtype, FollowUpVisitHistoryRow, getAttendingPractitionerId, getCoding, @@ -148,6 +150,9 @@ const performEffect = async (input: EffectInput, oystehr: Oystehr): Promise = {}; + // Track appointment IDs belonging to scheduled follow-up encounters so we can + // exclude them from top-level appointment rows + const scheduledFollowUpAppointmentIds = new Set(); allResources.forEach((res) => { switch (res.resourceType) { case 'Appointment': @@ -158,6 +163,17 @@ const performEffect = async (input: EffectInput, oystehr: Oystehr): Promise t.coding ?? []) + .find((c) => c.system === FOLLOWUP_SUBTYPE_SYSTEM); + if (subtypeCoding?.code === 'scheduled') { + const ownApptRef = res.appointment?.[0]?.reference?.replace('Appointment/', ''); + if (ownApptRef) { + scheduledFollowUpAppointmentIds.add(ownApptRef); + } + } } else { encounters.push(res as Encounter); } @@ -182,6 +198,11 @@ const performEffect = async (input: EffectInput, oystehr: Oystehr): Promise appointment.slot?.some((s) => s.reference === `Slot/${slot.id}`)); const location = locations.find( (location) => appointment?.participant?.some((p) => p.actor?.reference?.replace('Location/', '') === location.id) @@ -203,6 +224,7 @@ const performEffect = async (input: EffectInput, oystehr: Oystehr): Promise fu !== undefined) @@ -408,6 +430,7 @@ interface FollowUpContext { locations: Location[]; originalEncounter: Encounter; serviceCategory?: string; + appointments: Appointment[]; } const followUpVisitHistoryRowFromEncounter = ( @@ -418,7 +441,7 @@ const followUpVisitHistoryRowFromEncounter = ( return undefined; } const followUpType = getFollowUpTypeFromEncounter(encounter); - const { practitioners, locations, originalEncounter, serviceCategory } = context; + const { practitioners, locations, originalEncounter, serviceCategory, appointments } = context; const location = locations.find( (location) => encounter?.location?.some((loc) => loc.location?.reference?.replace('Location/', '') === location.id) @@ -426,17 +449,33 @@ const followUpVisitHistoryRowFromEncounter = ( const office = location?.address?.state && location?.name ? `${location.address.state.toUpperCase()} - ${location.name}` : '-'; const originalAppointmentId = originalEncounter.appointment?.[0]?.reference?.replace('Appointment/', ''); + + // Determine follow-up subtype from encounter type coding + const subtypeCoding = encounter.type + ?.flatMap((t) => t.coding ?? []) + .find((c) => c.system === FOLLOWUP_SUBTYPE_SYSTEM); + const followupSubtype: FollowupSubtype = subtypeCoding?.code === 'scheduled' ? 'scheduled' : 'annotation'; + + // For scheduled follow-ups, get the encounter's own appointment ID and use its data + const ownAppointmentId = + followupSubtype === 'scheduled' ? encounter.appointment?.[0]?.reference?.replace('Appointment/', '') : undefined; + + // For scheduled follow-ups, fall back to appointment data for date and reason + const ownAppointment = ownAppointmentId ? appointments.find((a) => a.id === ownAppointmentId) : undefined; + return { encounterId: encounter.id, - dateTime: encounter.period?.start, + dateTime: encounter.period?.start || ownAppointment?.start, type: followUpType, serviceCategory, - visitReason: encounter.reasonCode?.[0]?.text, + visitReason: encounter.reasonCode?.[0]?.text || ownAppointment?.description, provider: getProviderFromEncounter(encounter, practitioners), office, status: encounter.status ?? '-', originalEncounterId: originalEncounter.id, originalAppointmentId, + followupSubtype, + appointmentId: ownAppointmentId, }; }; diff --git a/packages/zambdas/src/ehr/practice-kpis-report/index.ts b/packages/zambdas/src/ehr/practice-kpis-report/index.ts index 8af447a58a..48c33939f7 100644 --- a/packages/zambdas/src/ehr/practice-kpis-report/index.ts +++ b/packages/zambdas/src/ehr/practice-kpis-report/index.ts @@ -5,7 +5,7 @@ import { appointmentTypeForAppointment, getInPersonVisitStatus, getVisitStatusHistory, - isFollowupEncounter, + isAnnotationFollowupEncounter, isInPersonAppointment, LocationKpiMetrics, OTTEHR_MODULE, @@ -337,7 +337,8 @@ export const index = wrapHandler(ZAMBDA_NAME, async (input: ZambdaInput): Promis (resource): resource is Appointment => resource.resourceType === 'Appointment' ); const encounters = allResources.filter( - (resource): resource is Encounter => resource.resourceType === 'Encounter' && !isFollowupEncounter(resource) + (resource): resource is Encounter => + resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource) ); console.log( diff --git a/packages/zambdas/src/ehr/save-followup-encounter/helpers.ts b/packages/zambdas/src/ehr/save-followup-encounter/helpers.ts index 4f5aaccfd8..49466a9644 100644 --- a/packages/zambdas/src/ehr/save-followup-encounter/helpers.ts +++ b/packages/zambdas/src/ehr/save-followup-encounter/helpers.ts @@ -2,6 +2,7 @@ import Oystehr from '@oystehr/sdk'; import { Operation } from 'fast-json-patch'; import { CodeableConcept, Coding, Encounter, EncounterParticipant, Location, Reference } from 'fhir/r4b'; import { + FOLLOWUP_SUBTYPE_SYSTEM, FOLLOWUP_SYSTEMS, FollowupReason, formatFhirEncounterToPatientFollowupDetails, @@ -28,7 +29,7 @@ export async function createEncounterResource( start: encounterDetails.start, end: encounterDetails?.end, }, - type: createEncounterType(encounterDetails.followupType), + type: createEncounterType(encounterDetails.followupType, encounterDetails.followupSubtype || 'annotation'), }; if (encounterDetails.location) { @@ -140,7 +141,7 @@ export async function updateEncounterResource( operations.push({ op: 'replace', path: '/type', - value: createEncounterType(encounterDetails.followupType), + value: createEncounterType(encounterDetails.followupType, encounterDetails.followupSubtype || 'annotation'), }); } @@ -322,7 +323,7 @@ export async function updateEncounterResource( } } -const createEncounterType = (type: string): Encounter['type'] => { +const createEncounterType = (type: string, subtype: string = 'annotation'): Encounter['type'] => { return [ { coding: [ @@ -331,6 +332,11 @@ const createEncounterType = (type: string): Encounter['type'] => { code: FOLLOWUP_SYSTEMS.type.code, display: type, }, + { + system: FOLLOWUP_SUBTYPE_SYSTEM, + code: subtype, + display: subtype, + }, ], text: type, }, diff --git a/packages/zambdas/src/ehr/visit-details/get-visit-details/index.ts b/packages/zambdas/src/ehr/visit-details/get-visit-details/index.ts index b5afd0fb41..cfffe3a2b8 100644 --- a/packages/zambdas/src/ehr/visit-details/get-visit-details/index.ts +++ b/packages/zambdas/src/ehr/visit-details/get-visit-details/index.ts @@ -26,7 +26,7 @@ import { getNameFromScheduleResource, getTimezone, INVALID_RESOURCE_ID_ERROR, - isFollowupEncounter, + isAnnotationFollowupEncounter, isValidUUID, MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS, @@ -167,7 +167,7 @@ const complexValidation = async (input: Input, oystehr: Oystehr): Promise resource.resourceType === 'Appointment') as Appointment; const patient = searchResults.find((resource) => resource.resourceType === 'Patient') as Patient; const encounter = searchResults.find( - (resource) => resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter) + (resource) => resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource as Encounter) ) as Encounter; const location = searchResults.find((resource) => resource.resourceType === 'Location') as Location | undefined; const flags = searchResults.filter((resource) => resource.resourceType === 'Flag') as Flag[]; diff --git a/packages/zambdas/src/ehr/visits-overview-report/index.ts b/packages/zambdas/src/ehr/visits-overview-report/index.ts index 6a25820149..cb6ef8bda2 100644 --- a/packages/zambdas/src/ehr/visits-overview-report/index.ts +++ b/packages/zambdas/src/ehr/visits-overview-report/index.ts @@ -9,7 +9,7 @@ import { getAttendingPractitionerId, getCoding, getInPersonVisitStatus, - isFollowupEncounter, + isAnnotationFollowupEncounter, isInPersonAppointment, isTelemedAppointment, LocationVisitCount, @@ -106,7 +106,7 @@ export const index = wrapHandler(ZAMBDA_NAME, async (input: ZambdaInput): Promis // Create encounter map for quick lookups to determine visit status const encounterMap = new Map(); encounters - .filter((encounter) => !isFollowupEncounter(encounter)) + .filter((encounter) => !isAnnotationFollowupEncounter(encounter)) .forEach((encounter) => { const appointmentRef = encounter.appointment?.[0]?.reference; if (appointmentRef && encounter.id) { diff --git a/packages/zambdas/src/patient/appointment/create-appointment/index.ts b/packages/zambdas/src/patient/appointment/create-appointment/index.ts index 7255567da8..d25a5a5707 100644 --- a/packages/zambdas/src/patient/appointment/create-appointment/index.ts +++ b/packages/zambdas/src/patient/appointment/create-appointment/index.ts @@ -31,6 +31,8 @@ import { FHIR_EXTENSION, FhirAppointmentStatus, FhirEncounterStatus, + FOLLOWUP_SUBTYPE_SYSTEM, + FOLLOWUP_SYSTEMS, formatPhoneNumber, formatPhoneNumberDisplay, getAppointmentDurationFromSlot, @@ -80,6 +82,7 @@ interface CreateAppointmentInput { locationState?: string; unconfirmedDateOfBirth?: string; appointmentMetadata?: Appointment['meta']; + parentEncounterId?: string; } // Lifting up value to outside of the handler allows it to stay in memory across warm lambda invocations @@ -115,6 +118,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput questionnaireCanonical, visitType, appointmentMetadata: maybeMetadata, + parentEncounterId, } = effectInput; console.log('effectInput', effectInput); console.timeEnd('performing-complex-validation'); @@ -148,6 +152,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput unconfirmedDateOfBirth, questionnaireCanonical, appointmentMetadata, + parentEncounterId, }, oystehr ); @@ -278,6 +283,7 @@ export async function createAppointment( createdBy, slot, appointmentMetadata, + parentEncounterId: input.parentEncounterId, }); let relatedPersonId = ''; @@ -357,6 +363,7 @@ interface TransactionInput { formUser?: string; slot?: Slot; appointmentMetadata?: Appointment['meta']; + parentEncounterId?: string; } interface TransactionOutput { appointment: Appointment; @@ -390,8 +397,20 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): serviceMode, slot, appointmentMetadata, + parentEncounterId, } = input; + // Validate parent encounter for scheduled follow-ups — no nesting allowed + if (parentEncounterId) { + const parentEncounter = await oystehr.fhir.get({ + resourceType: 'Encounter', + id: parentEncounterId, + }); + if (parentEncounter.partOf) { + throw new Error('Cannot create a follow-up of a follow-up. Please select a top-level encounter as the parent.'); + } + } + if (!patient && !createPatientRequest?.fullUrl) { throw new Error('Unexpectedly have no patient and no request to make one'); } @@ -542,6 +561,26 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): ] : [], extension: encExtensions, + ...(parentEncounterId && { + partOf: { reference: `Encounter/${parentEncounterId}` }, + type: [ + { + coding: [ + { + system: FOLLOWUP_SYSTEMS.type.url, + code: FOLLOWUP_SYSTEMS.type.code, + display: 'Follow-up Encounter', + }, + { + system: FOLLOWUP_SUBTYPE_SYSTEM, + code: 'scheduled', + display: 'scheduled', + }, + ], + text: 'Follow-up Encounter', + }, + ], + }), }; const { documents, accountInfo } = await getRelatedResources(oystehr, patient?.id); diff --git a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts index a0822d39d1..ac9a3a6483 100644 --- a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts +++ b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts @@ -45,7 +45,8 @@ export function validateCreateAppointmentParams(input: ZambdaInput, user: User): const isEHRUser = user && checkIsEHRUser(user); const bodyJSON = JSON.parse(input.body); - const { slotId, language, patient, unconfirmedDateOfBirth, locationState, appointmentMetadata } = bodyJSON; + const { slotId, language, patient, unconfirmedDateOfBirth, locationState, appointmentMetadata, parentEncounterId } = + bodyJSON; console.log('unconfirmedDateOfBirth', unconfirmedDateOfBirth); console.log('patient:', patient, 'slotId:', slotId); // Check existence of necessary fields @@ -144,6 +145,7 @@ export function validateCreateAppointmentParams(input: ZambdaInput, user: User): unconfirmedDateOfBirth, locationState, appointmentMetadata, + parentEncounterId, }; } @@ -157,6 +159,7 @@ export interface CreateAppointmentEffectInput { visitType: VisitType; locationState?: string; appointmentMetadata?: Appointment['meta']; + parentEncounterId?: string; } export const createAppointmentComplexValidation = async ( @@ -275,5 +278,6 @@ export const createAppointmentComplexValidation = async ( visitType, locationState, appointmentMetadata, + parentEncounterId: input.parentEncounterId, }; }; diff --git a/packages/zambdas/src/patient/appointment/get-past-visits/helpers.ts b/packages/zambdas/src/patient/appointment/get-past-visits/helpers.ts index 848f588285..eb959ec182 100644 --- a/packages/zambdas/src/patient/appointment/get-past-visits/helpers.ts +++ b/packages/zambdas/src/patient/appointment/get-past-visits/helpers.ts @@ -1,13 +1,13 @@ import Oystehr, { FhirSearchParams } from '@oystehr/sdk'; import { Appointment, Encounter, Location, Patient, RelatedPerson, Resource, Schedule } from 'fhir/r4b'; -import { isFollowupEncounter, OTTEHR_MODULE, removePrefix } from 'utils'; +import { isAnnotationFollowupEncounter, OTTEHR_MODULE, removePrefix } from 'utils'; export type EncounterToAppointmentIdMap = { [appointmentId: string]: Encounter }; export function mapEncountersToAppointmentIds(allResources: Resource[]): EncounterToAppointmentIdMap { const result: EncounterToAppointmentIdMap = {}; allResources.forEach((resource) => { - if (!(resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter))) return; + if (!(resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource as Encounter))) return; const encounter = resource as Encounter; const appointmentReference = encounter?.appointment?.[0].reference || ''; diff --git a/packages/zambdas/src/patient/appointment/get-visit-details/index.ts b/packages/zambdas/src/patient/appointment/get-visit-details/index.ts index 9e74c52939..98135d7562 100644 --- a/packages/zambdas/src/patient/appointment/get-visit-details/index.ts +++ b/packages/zambdas/src/patient/appointment/get-visit-details/index.ts @@ -6,6 +6,7 @@ import { FileURLInfo, getSecret, GetVisitDetailsResponse, + isAnnotationFollowupEncounter, isFollowupEncounter, SecretsKeys, } from 'utils'; @@ -55,7 +56,7 @@ export const index = wrapHandler(ZAMBDA_NAME, async (input: ZambdaInput): Promis ).unbundle(); allEncounters = encounterResults.filter((e) => e.resourceType === 'Encounter') as Encounter[]; // Find the main encounter (not follow-up) - encounter = allEncounters.find((e) => !isFollowupEncounter(e)) as Encounter; + encounter = allEncounters.find((e) => !isAnnotationFollowupEncounter(e)) as Encounter; const appointment = encounterResults.find((e) => e.resourceType === 'Appointment') as Appointment; if (!encounter || !encounter.id) { throw new Error('Error getting appointment encounter'); diff --git a/packages/zambdas/src/patient/check-in/index.ts b/packages/zambdas/src/patient/check-in/index.ts index 8e2db1915b..542b050414 100644 --- a/packages/zambdas/src/patient/check-in/index.ts +++ b/packages/zambdas/src/patient/check-in/index.ts @@ -15,7 +15,7 @@ import { getLocationInformation, getPatchBinary, getTaskResource, - isFollowupEncounter, + isAnnotationFollowupEncounter, isNonPaperworkQuestionnaireResponse, Secrets, TaskIndicator, @@ -102,7 +102,7 @@ export const index = wrapHandler('check-in', async (input: ZambdaInput): Promise if (resource.resourceType === 'Patient') { patient = resource as Patient; } - if (resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter)) { + if (resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource as Encounter)) { encounter = resource as Encounter; } if (resource.resourceType === 'Location') { diff --git a/packages/zambdas/src/patient/paperwork/update-paperwork-in-progress/index.ts b/packages/zambdas/src/patient/paperwork/update-paperwork-in-progress/index.ts index 741217d48d..8008497de5 100644 --- a/packages/zambdas/src/patient/paperwork/update-paperwork-in-progress/index.ts +++ b/packages/zambdas/src/patient/paperwork/update-paperwork-in-progress/index.ts @@ -2,7 +2,7 @@ import Oystehr, { User } from '@oystehr/sdk'; import { APIGatewayProxyResult } from 'aws-lambda'; import { Appointment, Encounter, Flag } from 'fhir/r4b'; import { DateTime } from 'luxon'; -import { isFollowupEncounter } from 'utils'; +import { isAnnotationFollowupEncounter } from 'utils'; import { createOystehrClient, getAuth0Token, getUser, wrapHandler, ZambdaInput } from '../../../shared'; import { createOrUpdateFlags } from '../sharedHelpers'; import { validateUpdatePaperworkParams } from './validateRequestParameters'; @@ -72,7 +72,7 @@ async function flagPaperworkInProgress( const appointment = resources.find((resource) => resource.resourceType === 'Appointment'); const encounter = resources.find( - (resource) => resource.resourceType === 'Encounter' && !isFollowupEncounter(resource as Encounter) + (resource) => resource.resourceType === 'Encounter' && !isAnnotationFollowupEncounter(resource as Encounter) ); const existingFlags: Flag[] = ( resources.filter(