Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ColorSchemeScript, MantineProvider } from '@mantine/core';
import { MantineProvider } from '@mantine/core';
import type { Metadata } from 'next';
import { ReactNode } from 'react';
import Root from './root';
Expand All @@ -18,7 +18,6 @@ export default function RootLayout(props: { children: ReactNode }): JSX.Element
return (
<html lang="en">
<head>
<ColorSchemeScript />
<link rel="shortcut icon" href="/favicon.svg" />
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no" />
</head>
Expand Down
7 changes: 6 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Button, Title } from '@mantine/core';
import { Container, ResourceTable, SignInForm, useMedplum, useMedplumProfile } from '@medplum/react';
import { Suspense } from 'react';
import { useRouter } from 'next/navigation';

// Medplum can autodetect Google Client ID from origin, but only if using window.location.host.
// Because window.location.host is not available on the server, we must use a constant value.
Expand All @@ -13,10 +14,11 @@ const googleClientId = '921088377005-3j1sa10vr6hj86jgmdfh2l53v3mp7lfi.apps.googl
export default function HomePage(): JSX.Element {
const medplum = useMedplum();
const profile = useMedplumProfile();
const router = useRouter();
return (
<Container mt="xl">
<Title order={1} my="xl">
Welcome to Medplum &amp; Next.js!
Welcome to Medplum &amp; Next.js &amp; GutAlly!
</Title>
{!profile && <SignInForm googleClientId={googleClientId}>Sign in</SignInForm>}
{profile && (
Expand All @@ -26,6 +28,9 @@ export default function HomePage(): JSX.Element {
</Title>
<ResourceTable value={profile} ignoreMissingValues />
<Button onClick={() => medplum.signOut()}>Sign out</Button>
<Button onClick={() => router.push('/patientintake')} style={{ marginLeft: '10px' }}>
Patient Intake Form
</Button>
</Suspense>
)}
</Container>
Expand Down
75 changes: 75 additions & 0 deletions app/patientintake/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { createReference, normalizeErrorString } from '@medplum/core';
import { Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes';
import { Document, QuestionnaireForm, useMedplum, useMedplumProfile } from '@medplum/react';
import { useCallback, useEffect, useState } from 'react';
import { showNotification } from '@mantine/notifications';
import { Loading } from '../../components/Loading';
import { useRouter } from 'next/navigation';

export default function Page(): JSX.Element {
const router = useRouter();
const medplum = useMedplum();
const profile = useMedplumProfile();
const [notFound, setNotFound] = useState(false);

const [questionnaire, setQuestionnaire] = useState<Questionnaire | undefined>(undefined);

useEffect(() => {
if (medplum.isLoading() || !profile) {
return;
}
medplum
.searchOne('Questionnaire', { name: 'patient-intake' })
.then((intakeQuestionnaire) => {
setQuestionnaire(intakeQuestionnaire);
})
.catch((err) => {
setNotFound(true);
console.log(err);
});
}, [medplum, profile]);

const handleOnSubmit = useCallback(
(response: QuestionnaireResponse) => {
if (!questionnaire || !profile) {
return;
}

medplum
.createResource<QuestionnaireResponse>({
...response,
author: createReference(profile),
})
.then(() => {
showNotification({
title: 'Success',
message: 'Answers recorded',
});
})
.catch((err) => {
showNotification({
color: 'red',
title: 'Error',
message: normalizeErrorString(err),
});
});
},
[medplum, router, questionnaire, profile]
);

if (notFound) {
return <div>Patient Intake Questionnaire Not found</div>;
}

if (!questionnaire) {
return <Loading />;
}

return (
<Document width={800}>
<QuestionnaireForm questionnaire={questionnaire} onSubmit={handleOnSubmit} />
</Document>
);
}
9 changes: 9 additions & 0 deletions components/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Center, Loader } from '@mantine/core';

export function Loading(): JSX.Element {
return (
<Center style={{ width: '100%', height: '300px' }}>
<Loader />
</Center>
);
}
42 changes: 42 additions & 0 deletions components/PatientActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Button, Stack, Title } from '@mantine/core';
import { getReferenceString } from '@medplum/core';
import { Patient } from '@medplum/fhirtypes';
import { useMedplum } from '@medplum/react';
import { IconEye } from '@tabler/icons-react';
import { useContext } from 'react';
import { useNavigate } from 'react-router';
import { IntakeQuestionnaireContext } from '../Questionnaire.context';

interface PatientActionsProps {
patient: Patient;
onChange: (patient: Patient) => void;
}

export function PatientActions(props: PatientActionsProps): JSX.Element {
const medplum = useMedplum();
const navigate = useNavigate();

const { questionnaire } = useContext(IntakeQuestionnaireContext);
const questionnaireResponse = questionnaire
? medplum
.searchOne('QuestionnaireResponse', {
subject: getReferenceString(props.patient),
questionnaire: questionnaire.url,
})
.read()
: null;

function handleViewIntakeForm(): void {
navigate(`/Patient/${props.patient.id}/intake/${questionnaireResponse?.id}`)?.catch(console.error);
}

return (
<Stack p="xs" m="xs">
<Title>Patient Actions</Title>

<Button leftSection={<IconEye size={16} />} onClick={handleViewIntakeForm} disabled={!questionnaireResponse}>
View Intake Form
</Button>
</Stack>
);
}
30 changes: 30 additions & 0 deletions components/PatientConsents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { formatSearchQuery, Operator, SearchRequest } from '@medplum/core';
import { Patient } from '@medplum/fhirtypes';
import { SearchControl } from '@medplum/react';
import { useNavigate } from 'react-router';

interface PatientConsentsProps {
patient: Patient;
}

export function PatientConsents(props: PatientConsentsProps): JSX.Element {
const navigate = useNavigate();

const search: SearchRequest = {
resourceType: 'Consent',
filters: [{ code: 'patient', operator: Operator.EQUALS, value: `Patient/${props.patient.id}` }],
fields: ['status', 'scope', 'category'],
};

return (
<SearchControl
search={search}
hideFilters={true}
hideToolbar={true}
onClick={(e) => navigate(`/${e.resource.resourceType}/${e.resource.id}`)?.catch(console.error)}
onChange={(e) => {
navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`)?.catch(console.error);
}}
/>
);
}
93 changes: 93 additions & 0 deletions components/PatientDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Tabs } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { normalizeErrorString } from '@medplum/core';
import { Patient, Resource } from '@medplum/fhirtypes';
import { Document, ResourceForm, ResourceHistoryTable, ResourceTable, useMedplum } from '@medplum/react';
import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react';
import { useNavigate } from 'react-router';
import { PatientConsents } from './PatientConsents';
import { PatientObservations } from './PatientObservations';
import { PatientImmunizations } from './PatientImmunizations';

interface PatientDetailsProps {
patient: Patient;
onChange: (patient: Patient) => void;
}

export function PatientDetails(props: PatientDetailsProps): JSX.Element {
const medplum = useMedplum();
const navigate = useNavigate();
const id = props.patient.id;

const tabs = [
['details', 'Details'],
['edit', 'Edit'],
['history', 'History'],
['observations', 'SDOH'],
['consents', 'Consents'],
['immunizations', 'Immunizations'],
];
// Get the current tab
const tab = window.location.pathname.split('/').pop();
const currentTab = tab && tabs.map((t) => t[0]).includes(tab) ? tab : tabs[0][0];

function handleTabChange(newTab: string | null): void {
navigate(`/Patient/${id}/${newTab ?? ''}`)?.catch(console.error);
}

function handlePatientEdit(resource: Resource): void {
medplum
// Update the resource then re-render the page and go to the details tab
.updateResource(resource)
.then((patient) => {
props.onChange(patient as Patient);
showNotification({
icon: <IconCircleCheck />,
title: 'Success',
message: 'Patient edited',
});
navigate(`/Patient/${id}/details`)?.catch(console.error);
window.scrollTo(0, 0);
})
.catch((err) => {
showNotification({
color: 'red',
icon: <IconCircleOff />,
title: 'Error',
message: normalizeErrorString(err),
});
});
}

return (
<Document>
<Tabs value={currentTab.toLowerCase()} onChange={handleTabChange}>
<Tabs.List mb="xs">
{tabs.map((tab) => (
<Tabs.Tab value={tab[0]} key={tab[0]}>
{tab[1]}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="details">
<ResourceTable value={props.patient} />
</Tabs.Panel>
<Tabs.Panel value="edit">
<ResourceForm defaultValue={props.patient} onSubmit={handlePatientEdit} />
</Tabs.Panel>
<Tabs.Panel value="history">
<ResourceHistoryTable resourceType="Patient" id={id} />
</Tabs.Panel>
<Tabs.Panel value="observations">
<PatientObservations patient={props.patient} />
</Tabs.Panel>
<Tabs.Panel value="consents">
<PatientConsents patient={props.patient} />
</Tabs.Panel>
<Tabs.Panel value="immunizations">
<PatientImmunizations patient={props.patient} />
</Tabs.Panel>
</Tabs>
</Document>
);
}
30 changes: 30 additions & 0 deletions components/PatientImmunizations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { formatSearchQuery, Operator, SearchRequest } from '@medplum/core';
import { Patient } from '@medplum/fhirtypes';
import { SearchControl } from '@medplum/react';
import { useNavigate } from 'react-router';

interface PatientImmunizationsProps {
patient: Patient;
}

export function PatientImmunizations(props: PatientImmunizationsProps): JSX.Element {
const navigate = useNavigate();

const search: SearchRequest = {
resourceType: 'Immunization',
filters: [{ code: 'patient', operator: Operator.EQUALS, value: `Patient/${props.patient.id}` }],
fields: ['status', 'vaccineCode', 'occurrenceDateTime'],
};

return (
<SearchControl
search={search}
hideFilters={true}
hideToolbar={true}
onClick={(e) => navigate(`/${e.resource.resourceType}/${e.resource.id}`)?.catch(console.error)}
onChange={(e) => {
navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`)?.catch(console.error);
}}
/>
);
}
33 changes: 33 additions & 0 deletions components/PatientObservations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { formatSearchQuery, Operator, SearchRequest } from '@medplum/core';
import { Patient } from '@medplum/fhirtypes';
import { SearchControl } from '@medplum/react';
import { useNavigate } from 'react-router';

interface PatientObservationsProps {
patient: Patient;
}

export function PatientObservations(props: PatientObservationsProps): JSX.Element {
const navigate = useNavigate();

const search: SearchRequest = {
resourceType: 'Observation',
filters: [
{ code: 'patient', operator: Operator.EQUALS, value: `Patient/${props.patient.id}` },
{ code: 'category', operator: Operator.EQUALS, value: 'sdoh' },
],
fields: ['code', 'value[x]'],
};

return (
<SearchControl
search={search}
hideFilters={true}
hideToolbar={true}
onClick={(e) => navigate(`/${e.resource.resourceType}/${e.resource.id}`)?.catch(console.error)}
onChange={(e) => {
navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`)?.catch(console.error);
}}
/>
);
}