Various improvements: study forms, participant management, PDF generator, robot integration

This commit is contained in:
2026-03-21 20:21:18 -04:00
parent bbc34921b5
commit bbbe397ba8
15 changed files with 936 additions and 675 deletions

View File

@@ -197,9 +197,9 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
...item,
url: `/studies/${selectedStudyId}${item.url}`,
}))
...item,
url: `/studies/${selectedStudyId}${item.url}`,
}))
: [];
const handleSignOut = async () => {

View File

@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
import SignatureCanvas from "react-signature-canvas";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
import { api } from "~/trpc/react";
@@ -25,211 +25,250 @@ import TableHeader from "@tiptap/extension-table-header";
import { ScrollArea } from "~/components/ui/scroll-area";
interface DigitalSignatureModalProps {
studyId: string;
participantId: string;
participantName?: string | null;
participantCode: string;
activeForm: { id: string; content: string; version: number };
onSuccess: () => void;
studyId: string;
participantId: string;
participantName?: string | null;
participantCode: string;
activeForm: { id: string; content: string; version: number };
onSuccess: () => void;
}
export function DigitalSignatureModal({
studyId,
participantId,
participantName,
participantCode,
activeForm,
onSuccess,
studyId,
participantId,
participantName,
participantCode,
activeForm,
onSuccess,
}: DigitalSignatureModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const sigCanvas = useRef<any>(null);
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const sigCanvas = useRef<any>(null);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Mutations
const getUploadUrlMutation =
api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Create a preview version of the text
let previewMd = activeForm.content;
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today);
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
// Create a preview version of the text
let previewMd = activeForm.content;
previewMd = previewMd.replace(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today);
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
const previewEditor = useEditor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: previewMd,
editable: false,
immediatelyRender: false,
});
const previewEditor = useEditor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: previewMd,
editable: false,
immediatelyRender: false,
});
const handleClear = () => {
sigCanvas.current?.clear();
};
const handleClear = () => {
sigCanvas.current?.clear();
};
const handleSubmit = async () => {
if (sigCanvas.current?.isEmpty()) {
toast.error("Signature required", { description: "Please sign the document before submitting." });
return;
}
const handleSubmit = async () => {
if (sigCanvas.current?.isEmpty()) {
toast.error("Signature required", {
description: "Please sign the document before submitting.",
});
return;
}
try {
setIsSubmitting(true);
toast.loading("Generating Signed Document...", { id: "sig-upload" });
try {
setIsSubmitting(true);
toast.loading("Generating Signed Document...", { id: "sig-upload" });
// 1. Get Signature Image Data URL
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
// 1. Get Signature Image Data URL
const signatureDataUrl = sigCanvas.current
.getTrimmedCanvas()
.toDataURL("image/png");
// 2. Prepare final Markdown and HTML
let finalMd = activeForm.content;
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
// 2. Prepare final Markdown and HTML
let finalMd = activeForm.content;
finalMd = finalMd.replace(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(
/{{SIGNATURE_IMAGE}}/g,
`<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`,
);
const headlessEditor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: finalMd,
});
const htmlContent = headlessEditor.getHTML();
headlessEditor.destroy();
const headlessEditor = new Editor({
extensions: [
StarterKit,
Table,
TableRow,
TableHeader,
TableCell,
Markdown,
],
content: finalMd,
});
const htmlContent = headlessEditor.getHTML();
headlessEditor.destroy();
// 3. Generate PDF Blob
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
const file = new File([pdfBlob], filename, { type: "application/pdf" });
// 3. Generate PDF Blob
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
const file = new File([pdfBlob], filename, { type: "application/pdf" });
// 4. Get Presigned URL
toast.loading("Uploading Document...", { id: "sig-upload" });
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 4. Get Presigned URL
toast.loading("Uploading Document...", { id: "sig-upload" });
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 5. Upload to MinIO
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 5. Upload to MinIO
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 6. Record Consent in DB
toast.loading("Finalizing Consent...", { id: "sig-upload" });
await recordConsentMutation.mutateAsync({
participantId,
consentFormId: activeForm.id,
storagePath: key,
});
// 6. Record Consent in DB
toast.loading("Finalizing Consent...", { id: "sig-upload" });
await recordConsentMutation.mutateAsync({
participantId,
consentFormId: activeForm.id,
storagePath: key,
});
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
setIsOpen(false);
onSuccess();
} catch (error) {
console.error(error);
toast.error("Failed to submit digital signature", {
id: "sig-upload",
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsSubmitting(false);
}
};
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
setIsOpen(false);
onSuccess();
} catch (error) {
console.error(error);
toast.error("Failed to submit digital signature", {
id: "sig-upload",
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
<PenBox className="mr-2 h-4 w-4" />
Sign Digitally
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
<DialogHeader>
<DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription>
Please review the document below and provide your digital signature to consent to this study.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="default"
size="sm"
className="bg-primary/90 hover:bg-primary"
>
<PenBox className="mr-2 h-4 w-4" />
Sign Digitally
</Button>
</DialogTrigger>
<DialogContent className="flex h-[90vh] max-w-4xl flex-col p-6">
<DialogHeader>
<DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription>
Please review the document below and provide your digital signature
to consent to this study.
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
{/* Document Preview (Left) */}
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Document Preview
</div>
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
<div className="prose prose-sm max-w-none text-black">
<EditorContent editor={previewEditor} />
</div>
</ScrollArea>
</div>
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-6 md:grid-cols-2">
{/* Document Preview (Left) */}
<div className="bg-muted/20 flex flex-col overflow-hidden rounded-md border">
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
Document Preview
</div>
<ScrollArea className="w-full flex-1 bg-white p-6 shadow-inner">
<div className="prose prose-sm max-w-none text-black">
<EditorContent editor={previewEditor} />
</div>
</ScrollArea>
</div>
{/* Signature Panel (Right) */}
<div className="flex flex-col space-y-4">
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Digital Signature Pad
</div>
<div className="p-4 bg-muted/10 relative">
<div className="absolute top-4 right-4">
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
<Eraser className="h-4 w-4 mr-2" />
Clear
</Button>
</div>
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
/>
</div>
<p className="text-center text-xs text-muted-foreground mt-2">
Draw your signature using your mouse or touch screen inside the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
<h4 className="flex items-center text-sm font-semibold text-primary">
<CheckCircle className="h-4 w-4 mr-2" />
Agreement
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="w-full mt-2"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
{/* Signature Panel (Right) */}
<div className="flex flex-col space-y-4">
<div className="flex flex-col overflow-hidden rounded-md border bg-white shadow-sm">
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
Digital Signature Pad
</div>
<div className="bg-muted/10 relative p-4">
<div className="absolute top-4 right-4">
<Button
variant="ghost"
size="sm"
onClick={handleClear}
disabled={isSubmitting}
>
<Eraser className="mr-2 h-4 w-4" />
Clear
</Button>
</div>
</DialogContent>
</Dialog>
);
<div
className="border-input mt-10 rounded-md border-2 border-dashed bg-white"
style={{ height: "250px" }}
>
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{
className: "w-full h-full cursor-crosshair rounded-md",
}}
/>
</div>
<p className="text-muted-foreground mt-2 text-center text-xs">
Draw your signature using your mouse or touch screen inside
the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="bg-primary/5 border-primary/20 flex flex-col space-y-3 rounded-lg border p-4">
<h4 className="text-primary flex items-center text-sm font-semibold">
<CheckCircle className="mr-2 h-4 w-4" />
Agreement
</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have
read and understood the information provided in the document
preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="mt-2 w-full"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -56,7 +56,10 @@ export function ParticipantConsentManager({
existingConsent,
participantName,
participantCode,
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
}: ParticipantConsentManagerProps & {
participantName?: string | null;
participantCode: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
@@ -99,14 +102,24 @@ export function ParticipantConsentManager({
// Substitute placeholders in markdown
let customMd = activeForm.content;
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
customMd = customMd.replace(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
customMd = customMd.replace(/{{DATE}}/g, "_________________");
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
// Use headless Tiptap to parse MD to HTML via same extensions
const editor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
extensions: [
StarterKit,
Table,
TableRow,
TableHeader,
TableCell,
Markdown,
],
content: customMd,
});
@@ -195,7 +208,11 @@ export function ParticipantConsentManager({
activeForm={activeForm}
onSuccess={handleSuccess}
/>
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
<Button
variant="outline"
size="sm"
onClick={handleDownloadUnsigned}
>
<Download className="mr-2 h-4 w-4" />
Print Empty Form
</Button>

View File

@@ -119,39 +119,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`,
},
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
{
label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`,
},
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
: [
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]),
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -291,7 +291,7 @@ export function ParticipantForm({
readOnly={true}
className={cn(
"bg-muted text-muted-foreground",
form.formState.errors.participantCode ? "border-red-500" : ""
form.formState.errors.participantCode ? "border-red-500" : "",
)}
/>
{form.formState.errors.participantCode && (
@@ -338,7 +338,11 @@ export function ParticipantForm({
<FormSection
title={contextStudyId ? "Demographics" : "Demographics & Study"}
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
description={
contextStudyId
? "Participant demographic details."
: "Study association and demographic details."
}
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{!contextStudyId && (
@@ -358,7 +362,9 @@ export function ParticipantForm({
}
>
<SelectValue
placeholder={studiesLoading ? "Loading..." : "Select study"}
placeholder={
studiesLoading ? "Loading..." : "Select study"
}
/>
</SelectTrigger>
<SelectContent>
@@ -404,11 +410,11 @@ export function ParticipantForm({
form.setValue(
"gender",
value as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
)
}
>

View File

@@ -187,7 +187,8 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
const executeSystemActionMutation = api.robots.executeSystemAction.useMutation();
const executeSystemActionMutation =
api.robots.executeSystemAction.useMutation();
const [isCompleting, setIsCompleting] = useState(false);
// Map database step types to component step types
@@ -632,17 +633,19 @@ export const WizardInterface = React.memo(function WizardInterface({
if (matchedOption) {
// Handle both string options and object options for nextStepId
const nextStepId = typeof matchedOption === "string"
? null // String options don't have nextStepId
: matchedOption.nextStepId;
const nextStepId =
typeof matchedOption === "string"
? null // String options don't have nextStepId
: matchedOption.nextStepId;
if (nextStepId) {
// Find index of the target step
const targetIndex = steps.findIndex((s) => s.id === nextStepId);
if (targetIndex !== -1) {
const label = typeof matchedOption === "string"
? matchedOption
: matchedOption.label;
const label =
typeof matchedOption === "string"
? matchedOption
: matchedOption.label;
console.log(
`[WizardInterface] Branching to step ${targetIndex} (${label})`,