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: 3 additions & 0 deletions apps/ehr/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import InHouseMedicationQuickPickDetailPage from './features/visits/telemed/comp
import AdminAddLabSet from './features/visits/telemed/components/admin/lab-sets/AdminAddLabSet';
import AdminLabSetDetails from './features/visits/telemed/components/admin/lab-sets/AdminLabSetDetails';
import ProcedureQuickPickDetailPage from './features/visits/telemed/components/admin/ProcedureQuickPickDetailPage';
import { QuestionnaireBuilderPage } from './features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage';
import RadiologyQuickPickDetailPage from './features/visits/telemed/components/admin/RadiologyQuickPickDetailPage';
import { useApiClients } from './hooks/useAppClients';
import useEvolveUser from './hooks/useEvolveUser';
Expand Down Expand Up @@ -278,6 +279,8 @@ function App(): ReactElement {
<Route path="/admin/in-house-labs/:activityDefinitionId" element={<AdminInHouseLabDetails />} />
<Route path="/admin/lab-sets/add" element={<AdminAddLabSet />} />
<Route path="/admin/lab-sets/:listId" element={<AdminLabSetDetails />} />
<Route path="/admin/questionnaires/new" element={<QuestionnaireBuilderPage />} />
<Route path="/admin/questionnaires/:questionnaireId" element={<QuestionnaireBuilderPage />} />
{FEATURE_FLAGS.LEGACY_DATA_ENABLED && <Route path="/legacy-data" element={<LegacyDataPage />} />}
<Route path="/tasks" element={<Tasks />} />
<Route path="*" element={<Navigate to={'/'} />} />
Expand Down
53 changes: 53 additions & 0 deletions apps/ehr/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ const ADMIN_CREATE_TEMPLATE_ZAMBDA_ID = 'admin-create-template';
const ADMIN_RENAME_TEMPLATE_ZAMBDA_ID = 'admin-rename-template';
const ADMIN_DELETE_TEMPLATE_ZAMBDA_ID = 'admin-delete-template';
const ADMIN_GET_TEMPLATE_DETAIL_ZAMBDA_ID = 'admin-get-template-detail';
const ADMIN_LIST_QUESTIONNAIRES_ZAMBDA_ID = 'admin-list-questionnaires';
const ADMIN_GET_QUESTIONNAIRE_ZAMBDA_ID = 'admin-get-questionnaire';
const ADMIN_CREATE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-create-questionnaire';
const ADMIN_UPDATE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-update-questionnaire';
const ADMIN_DELETE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-delete-questionnaire';
const ADMIN_LIST_IN_HOUSE_LABS_ZAMBDA_ID = 'admin-list-in-house-labs';
const ADMIN_ADD_IN_HOUSE_LAB_ZAMBDA_ID = 'admin-add-in-house-lab';
const ADMIN_GET_IN_HOUSE_LAB_CONFIG_ZAMBDA_ID = 'admin-get-in-house-lab-config';
Expand Down Expand Up @@ -2844,6 +2849,28 @@ export const migrateExamData = async (
}
};

// ── Practice-Managed Questionnaires ──

export const listPracticeManagedQuestionnaires = async (
oystehr: Oystehr
): Promise<{
questionnaires: any[];
systemQuestionnaires: { id: string; url: string; title: string }[];
}> => {
const response = await oystehr.zambda.execute({ id: ADMIN_LIST_QUESTIONNAIRES_ZAMBDA_ID });
return chooseJson(response);
};

export const getPracticeManagedQuestionnaire = async (
oystehr: Oystehr,
questionnaireId: string
): Promise<{ questionnaire: any }> => {
const response = await oystehr.zambda.execute({
id: ADMIN_GET_QUESTIONNAIRE_ZAMBDA_ID,
questionnaireId,
});
return chooseJson(response);
};
// ── Service Categories (FHIR-backed bookable appointment categories) ──

export interface ServiceCategoryRuntimeConfig {
Expand Down Expand Up @@ -2885,6 +2912,32 @@ export const createServiceCategory = async (
return chooseJson(response);
};

export const createPracticeManagedQuestionnaire = async (
oystehr: Oystehr,
questionnaire: Record<string, unknown>
): Promise<{ questionnaire: any }> => {
const response = await oystehr.zambda.execute({ id: ADMIN_CREATE_QUESTIONNAIRE_ZAMBDA_ID, questionnaire });
return chooseJson(response);
};

export const updatePracticeManagedQuestionnaire = async (
oystehr: Oystehr,
questionnaire: Record<string, unknown>
): Promise<{ questionnaire: any }> => {
const response = await oystehr.zambda.execute({ id: ADMIN_UPDATE_QUESTIONNAIRE_ZAMBDA_ID, questionnaire });
return chooseJson(response);
};

export const deletePracticeManagedQuestionnaire = async (
oystehr: Oystehr,
questionnaireId: string
): Promise<{ message: string }> => {
const response = await oystehr.zambda.execute({
id: ADMIN_DELETE_QUESTIONNAIRE_ZAMBDA_ID,
questionnaireId,
});
return chooseJson(response);
};
export const updateServiceCategory = async (
oystehr: Oystehr,
serviceCategory: ServiceCategory
Expand Down
15 changes: 15 additions & 0 deletions apps/ehr/src/components/PatientEncountersGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AddIcon from '@mui/icons-material/Add';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import SendIcon from '@mui/icons-material/Send';
import SubdirectoryArrowRightIcon from '@mui/icons-material/SubdirectoryArrowRight';
import {
Box,
Expand Down Expand Up @@ -51,6 +52,7 @@ import {
import { FEATURE_FLAGS } from '../constants/feature-flags';
import { formatISOStringToDateAndTime } from '../helpers/formatDateTime';
import { useApiClients } from '../hooks/useAppClients';
import { SendFormDialog } from './dialogs/SendFormDialog';
import { RoundedButton } from './RoundedButton';

type PatientEncountersGridProps = {
Expand Down Expand Up @@ -146,6 +148,7 @@ export const PatientEncountersGrid: FC<PatientEncountersGridProps> = (props) =>
const [serviceCategory, setServiceCategory] = useState('all');
const [hideCancelled, setHideCancelled] = useState(false);
const [hideNoShow, setHideNoShow] = useState(false);
const [sendFormOpen, setSendFormOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>('dateTime');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [page, setPage] = useState(0);
Expand Down Expand Up @@ -354,8 +357,20 @@ export const PatientEncountersGrid: FC<PatientEncountersGridProps> = (props) =>
>
Follow-up
</RoundedButton>
<RoundedButton
variant="contained"
startIcon={<SendIcon fontSize="small" />}
onClick={() => setSendFormOpen(true)}
disabled={!patient?.id}
>
Send Form
</RoundedButton>
</Box>

{patient?.id && (
<SendFormDialog open={sendFormOpen} onClose={() => setSendFormOpen(false)} patientId={patient.id} />
)}

<Box sx={{ display: 'flex', gap: 2 }}>
<TextField size="small" fullWidth label="Type" select value={type} onChange={(e) => setType(e.target.value)}>
<MenuItem value="all">All</MenuItem>
Expand Down
173 changes: 173 additions & 0 deletions apps/ehr/src/components/dialogs/SendFormDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SendIcon from '@mui/icons-material/Send';
import {
Autocomplete,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { enqueueSnackbar } from 'notistack';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { listPracticeManagedQuestionnaires } from 'src/api/api';
import { useApiClients } from 'src/hooks/useAppClients';

interface SendFormDialogProps {
open: boolean;
onClose: () => void;
/** Appointment-scoped send: link is tied to an encounter. */
appointmentId?: string;
/** Patient-scoped send (no encounter): link is tied to the patient record only. */
patientId?: string;
}

const SEND_FORM_ZAMBDA = 'send-patient-form';
const PATIENT_APP_URL = import.meta.env.VITE_APP_PATIENT_APP_URL || '';

export const SendFormDialog: FC<SendFormDialogProps> = ({ open, onClose, appointmentId, patientId }) => {
const { oystehrZambda } = useApiClients();
const [selectedId, setSelectedId] = useState('');
const [sending, setSending] = useState(false);
const [questionnaires, setQuestionnaires] = useState<{ id: string; title: string }[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);

useEffect(() => {
if (!open || !oystehrZambda) return;
// Guard against out-of-order results from rapid close/reopen.
let cancelled = false;
setLoading(true);
setLoadError(false);
listPracticeManagedQuestionnaires(oystehrZambda)
.then((result) => {
if (cancelled) return;
setQuestionnaires(
(result.questionnaires || [])
.filter((q: any) => q.status === 'active')
.map((q: any) => ({ id: q.id, title: q.title || q.name || 'Untitled' }))
.sort((a: { title: string }, b: { title: string }) => a.title.localeCompare(b.title))
);
})
.catch((err) => {
if (cancelled) return;
// A failed fetch must not masquerade as "no questionnaires available".
console.error('Failed to load questionnaires:', err);
setLoadError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, oystehrZambda]);

const formUrl = useMemo(() => {
if (!selectedId || !PATIENT_APP_URL) return '';
if (appointmentId) return `${PATIENT_APP_URL}/forms/${appointmentId}/${selectedId}`;
if (patientId) return `${PATIENT_APP_URL}/forms/patient/${patientId}/${selectedId}`;
return '';
}, [selectedId, appointmentId, patientId]);

const handleCopyUrl = useCallback(() => {
if (!formUrl) return;
void navigator.clipboard.writeText(formUrl).then(() => {
enqueueSnackbar('Form URL copied to clipboard', { variant: 'success' });
});
}, [formUrl]);

const handleSend = useCallback(async () => {
if (!oystehrZambda || !selectedId) return;

const selected = questionnaires.find((q) => q.id === selectedId);
if (!selected) return;

setSending(true);
try {
await oystehrZambda.zambda.execute({
id: SEND_FORM_ZAMBDA,
...(appointmentId ? { appointmentId } : { patientId }),
questionnaireId: selectedId,
questionnaireName: selected.title,
});
enqueueSnackbar('Form link sent to patient', { variant: 'success' });
onClose();
setSelectedId('');
} catch (err) {
console.error('Failed to send form:', err);
enqueueSnackbar('Failed to send form link', { variant: 'error' });
} finally {
setSending(false);
}
}, [oystehrZambda, selectedId, appointmentId, patientId, questionnaires, onClose]);

return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Send Form to Patient</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Select a form to send to the patient via SMS, or copy the link to share directly.
</Typography>
{loading ? (
<CircularProgress size={24} />
) : loadError ? (
<Typography color="error">Could not load forms. Close and reopen to retry.</Typography>
) : questionnaires.length === 0 ? (
<Typography color="text.secondary">No practice-managed questionnaires available.</Typography>
) : (
<>
<Autocomplete
size="small"
options={questionnaires}
getOptionLabel={(opt) => opt.title}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
value={questionnaires.find((q) => q.id === selectedId) || null}
onChange={(_, value) => setSelectedId(value?.id || '')}
disabled={sending}
autoHighlight
renderInput={(params) => <TextField {...params} label="Form" placeholder="Type to filter…" />}
/>
{formUrl && (
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
fullWidth
value={formUrl}
InputProps={{
readOnly: true,
sx: { fontSize: 12, fontFamily: 'monospace' },
}}
/>
<Tooltip title="Copy URL">
<IconButton size="small" onClick={handleCopyUrl}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={sending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSend}
disabled={!selectedId || sending || loading}
startIcon={sending ? <CircularProgress size={16} /> : <SendIcon />}
>
Send via SMS
</Button>
</DialogActions>
</Dialog>
);
};
36 changes: 36 additions & 0 deletions apps/ehr/src/features/visits/in-person/hooks/useTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,42 @@ function fhirTaskToTask(task: FhirTask, encountersMap?: Map<string, Encounter>):
const patientReference = getInputReference(MANUAL_TASK.input.patient, task);
const appointmentId = getInputString(MANUAL_TASK.input.appointmentId, task);
const orderId = getInputString(MANUAL_TASK.input.orderId, task);
const documentReferenceId = getInputString(MANUAL_TASK.input.documentReferenceId, task);
// Follow-up tasks emitted by completed practice-managed forms carry a
// document-reference-id; use it as a signal to route to the patient docs
// page with the Paperwork folder preselected, rather than the default
// manual-task behaviour.
const patientIdFromRef = patientReference?.reference?.split('/')?.[1];
if (category === MANUAL_TASK.category.patientFollowUp && documentReferenceId && patientIdFromRef) {
title =
getInputString(MANUAL_TASK.input.title, task) ||
`Patient follow-up for ${patientReference?.display?.replaceAll(',', '') ?? ''}`;
subtitle = `Form completed / ${task.location?.display ?? ''}`;
details = '';
completable = true;
action = {
name: GO_TO_TASK,
link: `/patient/${patientIdFromRef}/docs?folder=Paperwork`,
};
return {
id: task.id ?? '',
category,
createdDate: task.authoredOn ?? '',
title,
subtitle,
details,
status: task.status,
action,
assignee: task.owner
? {
id: task.owner?.reference?.split('/')?.[1] ?? '',
name: task.owner?.display ?? '',
date: getExtension(task.owner, TASK_ASSIGNED_DATE_TIME_EXTENSION_URL)?.valueDateTime ?? '',
}
: undefined,
completable,
};
}
title =
getInputString(MANUAL_TASK.input.title, task) +
(patientReference ? ' for ' + patientReference.display?.replaceAll(',', '') : '');
Expand Down
Loading
Loading