diff --git a/aidbox-forms-smart-launch-2/src/app/(authorized)/questionnaire-responses/page.tsx b/aidbox-forms-smart-launch-2/src/app/(authorized)/questionnaire-responses/page.tsx new file mode 100644 index 0000000..2c66488 --- /dev/null +++ b/aidbox-forms-smart-launch-2/src/app/(authorized)/questionnaire-responses/page.tsx @@ -0,0 +1,208 @@ +import { getCurrentAidbox } from "@/lib/server/smart"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PageHeader } from "@/components/page-header"; +import { PageSizeSelect } from "@/components/page-size-select"; +import { Pager } from "@/components/pager"; +import { + Bundle, + Patient, + Practitioner, + Questionnaire, + QuestionnaireResponse, +} from "fhir/r4"; +import { + constructName, + getFirst, + isDefined, + typeSafeObjectFromEntries, +} from "@/lib/utils"; +import { decidePageSize } from "@/lib/server/utils"; +import { revalidatePath } from "next/cache"; +import { QuestionnaireResponsesActions } from "@/components/questionnaire-responses-actions"; + +interface PageProps { + searchParams: Promise<{ + page?: string; + pageSize?: string; + }>; +} + +export default async function QuestionnaireResponsesPage({ + searchParams, +}: PageProps) { + const aidbox = await getCurrentAidbox(); + const params = await searchParams; + + const pageSize = await decidePageSize(params.pageSize); + const page = Number(params.page) || 1; + + const response = await aidbox + .get( + "fhir/QuestionnaireResponse?_include=QuestionnaireResponse.questionnaire", + { + searchParams: { + _count: pageSize, + _page: page, + }, + }, + ) + .json>(); + + const resources = + response.entry?.map((entry) => entry.resource)?.filter(isDefined) || []; + + const questionnaires = typeSafeObjectFromEntries( + await Promise.all( + resources + .map((resource) => resource.questionnaire) + .filter(isDefined) + .map(async (canonical) => { + const [url, version] = canonical.split("|"); + + return [ + canonical, + await aidbox + .get( + `fhir/Questionnaire?url=${url}${version ? `&version=${version}` : ""}`, + ) + .json>() + .then(getFirst) + .catch(() => undefined), + ]; + }), + ), + ); + + const patients = typeSafeObjectFromEntries( + await Promise.all( + resources + .map((resource) => resource.subject?.reference) + .filter(isDefined) + .map(async (reference) => { + return [ + reference, + await aidbox + .get(`fhir/${reference}`) + .json() + .catch(() => undefined), + ]; + }), + ), + ); + + const authors = typeSafeObjectFromEntries( + await Promise.all( + resources + .map((resource) => resource.author?.reference) + .filter(isDefined) + .map(async (reference) => [ + reference, + await aidbox + .get(`fhir/${reference}`) + .json() + .catch(() => null), + ]), + ), + ); + + const total = response.total || 0; + const totalPages = Math.ceil(total / pageSize); + + async function deleteQuestionnaireResponse({ id }: QuestionnaireResponse) { + "use server"; + + const aidbox = await getCurrentAidbox(); + await aidbox.delete(`fhir/QuestionnaireResponse/${id}`).json(); + revalidatePath("/questionnaire-responses"); + } + + return ( + <> + +
+
+ + + + Patient + Questionnaire + Author + Status + Last modified + Actions + + + + {resources.map((resource) => { + const subject = resource.subject?.reference + ? patients[resource.subject.reference] + : undefined; + + const questionnaire = resource.questionnaire + ? questionnaires[resource.questionnaire] + : undefined; + + const author = resource.author?.reference + ? authors[resource.author.reference] + : undefined; + + return ( + + + {constructName(subject?.name)} + + {questionnaire?.title} + {constructName(author?.name)} + {resource.status} + + {resource.meta?.lastUpdated && + new Date(resource.meta.lastUpdated).toLocaleString()} + + + + + + ); + })} + {!resources.length && ( + + + No questionnaire responses found + + + )} + +
+
+ +
+
+ {total ? ( +
{`Showing ${ + (page - 1) * pageSize + 1 + }-${Math.min( + page * pageSize, + total, + )} of ${total} practitioners`}
+ ) : null} + +
+ +
+
+ + ); +} diff --git a/aidbox-forms-smart-launch-2/src/components/forms-renderer.tsx b/aidbox-forms-smart-launch-2/src/components/forms-renderer.tsx index 3df0e9c..c299f82 100644 --- a/aidbox-forms-smart-launch-2/src/components/forms-renderer.tsx +++ b/aidbox-forms-smart-launch-2/src/components/forms-renderer.tsx @@ -7,6 +7,7 @@ export function FormsRenderer({ onChange, }: { questionnaire: Questionnaire; + questionnaireResponse?: QuestionnaireResponse; onChange?: (questionnaireResponse: QuestionnaireResponse) => void; }) { const ref = useRef(null); @@ -33,6 +34,7 @@ export function FormsRenderer({ Promise; +}) { + const [viewing, setViewing] = useState(false); + const { toast } = useToast(); + + return ( + <> + + + + + + Actions + + navigator && + navigator.clipboard.writeText(questionnaireResponse.id as string) + } + > + + Copy ID + + + + {questionnaire && ( + setViewing(true)}> + + Preview + + )} + + + + + Edit + + + + { + if (onDeleteAction) { + await onDeleteAction(questionnaireResponse); + + toast({ + title: "Questionnaire deleted", + description: `Questionnaire deleted successfully`, + }); + } + }} + > + + Delete + + + + + + + + Preview + + {viewing && questionnaire && ( + }> + { + toast({ + title: "Not saved", + description: "This is a preview, changes will not be saved", + }); + }} + /> + + )} + + + + ); +} diff --git a/aidbox-forms-smart-launch-2/src/lib/server/sync.ts b/aidbox-forms-smart-launch-2/src/lib/server/sync.ts index 3d22f76..7f80475 100644 --- a/aidbox-forms-smart-launch-2/src/lib/server/sync.ts +++ b/aidbox-forms-smart-launch-2/src/lib/server/sync.ts @@ -13,11 +13,7 @@ import { upsertOrganization, } from "@/lib/server/aidbox"; import { getCapabilityStatement } from "@/lib/server/smart"; -import { readableStreamToObject } from "@/lib/utils"; - -function isBundle(resource: Resource): resource is Bundle { - return "resourceType" in resource && resource.resourceType === "Bundle"; -} +import { isBundle, readableStreamToObject } from "@/lib/utils"; async function extractPatient(client: Client) { const patient = await client.patient.read().catch((e) => { diff --git a/aidbox-forms-smart-launch-2/src/lib/utils.ts b/aidbox-forms-smart-launch-2/src/lib/utils.ts index 6dca49b..a00476c 100644 --- a/aidbox-forms-smart-launch-2/src/lib/utils.ts +++ b/aidbox-forms-smart-launch-2/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import { Address, HumanName } from "fhir/r4"; +import { Address, Bundle, HumanName, Resource } from "fhir/r4"; import crypto from "crypto"; import { SMART_LAUNCH_TYPES } from "@/lib/constants"; @@ -86,3 +86,29 @@ export function createSmartAppLauncherUrl({ export function isDefined(value: T): value is NonNullable { return value !== undefined && value !== null; } + +export function isBundle( + resource: any, +): resource is Bundle { + return "resourceType" in resource && resource.resourceType === "Bundle"; +} + +export function getFirst( + resource: T | Bundle, +): T { + if (isBundle(resource)) { + const first = resource.entry?.[0].resource; + if (!first) { + throw new Error("Resource not found"); + } + return first; + } else { + return resource; + } +} + +export function typeSafeObjectFromEntries< + const T extends ReadonlyArray, +>(entries: T): { [K in T[number] as K[0]]: K[1] } { + return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] }; +} diff --git a/aidbox-forms-smart-launch-2/types.d.ts b/aidbox-forms-smart-launch-2/types.d.ts index c6596dd..8598b36 100644 --- a/aidbox-forms-smart-launch-2/types.d.ts +++ b/aidbox-forms-smart-launch-2/types.d.ts @@ -15,6 +15,7 @@ declare global { HTMLIFrameElement > & { questionnaire?: string; + "questionnaire-response"?: string; }; "aidbox-form-builder": React.DetailedHTMLProps< React.IframeHTMLAttributes,