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

@@ -35,7 +35,8 @@ async function main() {
.values({ .values({
studyId: study.id, studyId: study.id,
name: "Story: Red Rock", name: "Story: Red Rock",
description: "A story about a red rock on Mars with comprehension check and branching.", description:
"A story about a red rock on Mars with comprehension check and branching.",
version: 1, version: 1,
status: "draft", status: "draft",
robotId: robot.id, robotId: robot.id,
@@ -53,102 +54,212 @@ async function main() {
const checkId = uuidv4(); const checkId = uuidv4();
// Step 1: The Hook // Step 1: The Hook
const [step1] = await db.insert(schema.steps).values({ const [step1] = await db
experimentId: experiment.id, .insert(schema.steps)
name: "The Hook", .values({
type: "wizard", experimentId: experiment.id,
orderIndex: 0, name: "The Hook",
}).returning(); type: "wizard",
orderIndex: 0,
})
.returning();
// Step 2: The Narrative // Step 2: The Narrative
const [step2] = await db.insert(schema.steps).values({ const [step2] = await db
experimentId: experiment.id, .insert(schema.steps)
name: "The Narrative", .values({
type: "wizard", experimentId: experiment.id,
orderIndex: 1, name: "The Narrative",
}).returning(); type: "wizard",
orderIndex: 1,
})
.returning();
// Step 3: Comprehension Check (Conditional) // Step 3: Comprehension Check (Conditional)
const [step3] = await db.insert(schema.steps).values({ const [step3] = await db
id: checkId, .insert(schema.steps)
experimentId: experiment.id, .values({
name: "Comprehension Check", id: checkId,
type: "conditional", experimentId: experiment.id,
orderIndex: 2, name: "Comprehension Check",
conditions: { type: "conditional",
variable: "last_wizard_response", orderIndex: 2,
options: [ conditions: {
{ label: "Answer: Red (Correct)", value: "Red", variant: "default", nextStepId: branchAId }, variable: "last_wizard_response",
{ label: "Answer: Other (Incorrect)", value: "Incorrect", variant: "destructive", nextStepId: branchBId } options: [
] {
} label: "Answer: Red (Correct)",
}).returning(); value: "Red",
variant: "default",
nextStepId: branchAId,
},
{
label: "Answer: Other (Incorrect)",
value: "Incorrect",
variant: "destructive",
nextStepId: branchBId,
},
],
},
})
.returning();
// Step 4: Branch A (Correct) // Step 4: Branch A (Correct)
const [step4] = await db.insert(schema.steps).values({ const [step4] = await db
id: branchAId, .insert(schema.steps)
experimentId: experiment.id, .values({
name: "Branch A: Correct Response", id: branchAId,
type: "wizard", experimentId: experiment.id,
orderIndex: 3, name: "Branch A: Correct Response",
conditions: { nextStepId: conclusionId } // SKIP BRANCH B type: "wizard",
}).returning(); orderIndex: 3,
conditions: { nextStepId: conclusionId }, // SKIP BRANCH B
})
.returning();
// Step 5: Branch B (Incorrect) // Step 5: Branch B (Incorrect)
const [step5] = await db.insert(schema.steps).values({ const [step5] = await db
id: branchBId, .insert(schema.steps)
experimentId: experiment.id, .values({
name: "Branch B: Incorrect Response", id: branchBId,
type: "wizard", experimentId: experiment.id,
orderIndex: 4, name: "Branch B: Incorrect Response",
conditions: { nextStepId: conclusionId } type: "wizard",
}).returning(); orderIndex: 4,
conditions: { nextStepId: conclusionId },
})
.returning();
// Step 6: Conclusion // Step 6: Conclusion
const [step6] = await db.insert(schema.steps).values({ const [step6] = await db
id: conclusionId, .insert(schema.steps)
experimentId: experiment.id, .values({
name: "Conclusion", id: conclusionId,
type: "wizard", experimentId: experiment.id,
orderIndex: 5, name: "Conclusion",
}).returning(); type: "wizard",
orderIndex: 5,
})
.returning();
// 4. Create Actions // 4. Create Actions
// The Hook // The Hook
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ stepId: step1!.id, name: "Say Hello", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Hello! Are you ready for a story?" } }, {
{ stepId: step1!.id, name: "Wave", type: "nao6-ros2.move_arm", orderIndex: 1, parameters: { arm: "right", shoulder_pitch: 0.5 } } stepId: step1!.id,
name: "Say Hello",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Hello! Are you ready for a story?" },
},
{
stepId: step1!.id,
name: "Wave",
type: "nao6-ros2.move_arm",
orderIndex: 1,
parameters: { arm: "right", shoulder_pitch: 0.5 },
},
]); ]);
// The Narrative // The Narrative
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ stepId: step2!.id, name: "The Story", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Once, a traveler went to Mars. He found a bright red rock that glowed." } }, {
{ stepId: step2!.id, name: "Look Left", type: "nao6-ros2.turn_head", orderIndex: 1, parameters: { yaw: 0.5, speed: 0.3 } }, stepId: step2!.id,
{ stepId: step2!.id, name: "Look Right", type: "nao6-ros2.turn_head", orderIndex: 2, parameters: { yaw: -0.5, speed: 0.3 } } name: "The Story",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: {
text: "Once, a traveler went to Mars. He found a bright red rock that glowed.",
},
},
{
stepId: step2!.id,
name: "Look Left",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 0.5, speed: 0.3 },
},
{
stepId: step2!.id,
name: "Look Right",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: -0.5, speed: 0.3 },
},
]); ]);
// Comprehension Check // Comprehension Check
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ stepId: step3!.id, name: "Ask Color", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "What color was the rock I found on Mars?" } }, {
{ stepId: step3!.id, name: "Wait for Color", type: "wizard_wait_for_response", orderIndex: 1, parameters: { options: ["Red", "Blue", "Green", "Incorrect"], prompt_text: "What color did the participant say?" } } stepId: step3!.id,
name: "Ask Color",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "What color was the rock I found on Mars?" },
},
{
stepId: step3!.id,
name: "Wait for Color",
type: "wizard_wait_for_response",
orderIndex: 1,
parameters: {
options: ["Red", "Blue", "Green", "Incorrect"],
prompt_text: "What color did the participant say?",
},
},
]); ]);
// Branch A (Using say_with_emotion) // Branch A (Using say_with_emotion)
await db.insert(schema.actions).values([ await db
{ stepId: step4!.id, name: "Happy Response", type: "nao6-ros2.say_with_emotion", orderIndex: 0, parameters: { text: "Exacty! It was a glowing red rock.", emotion: "happy" } } .insert(schema.actions)
]); .values([
{
stepId: step4!.id,
name: "Happy Response",
type: "nao6-ros2.say_with_emotion",
orderIndex: 0,
parameters: {
text: "Exacty! It was a glowing red rock.",
emotion: "happy",
},
},
]);
// Branch B // Branch B
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ stepId: step5!.id, name: "Correct them", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Actually, it was red." } }, {
{ stepId: step5!.id, name: "Shake Head", type: "nao6-ros2.turn_head", orderIndex: 1, parameters: { yaw: 0.3, speed: 0.5 } } stepId: step5!.id,
name: "Correct them",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Actually, it was red." },
},
{
stepId: step5!.id,
name: "Shake Head",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 0.3, speed: 0.5 },
},
]); ]);
// Conclusion // Conclusion
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ stepId: step6!.id, name: "Final Goodbye", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "That is all for today. Goodbye!" } }, {
{ stepId: step6!.id, name: "Rest", type: "nao6-ros2.move_arm", orderIndex: 1, parameters: { shoulder_pitch: 1.5 } } stepId: step6!.id,
name: "Final Goodbye",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "That is all for today. Goodbye!" },
},
{
stepId: step6!.id,
name: "Rest",
type: "nao6-ros2.move_arm",
orderIndex: 1,
parameters: { shoulder_pitch: 1.5 },
},
]); ]);
console.log("✅ Seed completed successfully!"); console.log("✅ Seed completed successfully!");

View File

@@ -5,21 +5,27 @@ async function migrate() {
console.log("Adding identifier column to hs_plugin..."); console.log("Adding identifier column to hs_plugin...");
try { try {
await db.execute(sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`); await db.execute(
sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`,
);
console.log("✓ Added identifier column"); console.log("✓ Added identifier column");
} catch (e: any) { } catch (e: any) {
console.log("Column may already exist:", e.message); console.log("Column may already exist:", e.message);
} }
try { try {
await db.execute(sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`); await db.execute(
sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`,
);
console.log("✓ Copied name to identifier"); console.log("✓ Copied name to identifier");
} catch (e: any) { } catch (e: any) {
console.log("Error copying:", e.message); console.log("Error copying:", e.message);
} }
try { try {
await db.execute(sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`); await db.execute(
sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`,
);
console.log("✓ Added unique constraint"); console.log("✓ Added unique constraint");
} catch (e: any) { } catch (e: any) {
console.log("Constraint may already exist:", e.message); console.log("Constraint may already exist:", e.message);

View File

@@ -3,12 +3,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
import { import {
EntityView, FileText,
EntityViewHeader, Loader2,
EntityViewSection, Plus,
EmptyState, Download,
Edit2,
Eye,
Save,
} from "lucide-react";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -16,302 +24,346 @@ import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from '@tiptap/starter-kit'; import StarterKit from "@tiptap/starter-kit";
import { Markdown } from 'tiptap-markdown'; import { Markdown } from "tiptap-markdown";
import { Table } from '@tiptap/extension-table'; import { Table } from "@tiptap/extension-table";
import { TableRow } from '@tiptap/extension-table-row'; import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from '@tiptap/extension-table-cell'; import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from '@tiptap/extension-table-header'; import { TableHeader } from "@tiptap/extension-table-header";
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react"; import {
Bold,
Italic,
List,
ListOrdered,
Heading1,
Heading2,
Quote,
Table as TableIcon,
} from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator"; import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => { const Toolbar = ({ editor }: { editor: any }) => {
if (!editor) { if (!editor) {
return null; return null;
} }
return ( return (
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap"> <div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()} disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'bg-muted' : ''} className={editor.isActive("bold") ? "bg-muted" : ""}
> >
<Bold className="h-4 w-4" /> <Bold className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()} disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'bg-muted' : ''} className={editor.isActive("italic") ? "bg-muted" : ""}
> >
<Italic className="h-4 w-4" /> <Italic className="h-4 w-4" />
</Button> </Button>
<div className="w-[1px] h-6 bg-border mx-1" /> <div className="bg-border mx-1 h-6 w-[1px]" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''} className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
> >
<Heading1 className="h-4 w-4" /> <Heading1 className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''} className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
> >
<Heading2 className="h-4 w-4" /> <Heading2 className="h-4 w-4" />
</Button> </Button>
<div className="w-[1px] h-6 bg-border mx-1" /> <div className="bg-border mx-1 h-6 w-[1px]" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-muted' : ''} className={editor.isActive("bulletList") ? "bg-muted" : ""}
> >
<List className="h-4 w-4" /> <List className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'bg-muted' : ''} className={editor.isActive("orderedList") ? "bg-muted" : ""}
> >
<ListOrdered className="h-4 w-4" /> <ListOrdered className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'bg-muted' : ''} className={editor.isActive("blockquote") ? "bg-muted" : ""}
> >
<Quote className="h-4 w-4" /> <Quote className="h-4 w-4" />
</Button> </Button>
<div className="w-[1px] h-6 bg-border mx-1" /> <div className="bg-border mx-1 h-6 w-[1px]" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} onClick={() =>
> editor
<TableIcon className="h-4 w-4" /> .chain()
</Button> .focus()
</div> .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
); .run()
}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
}; };
interface StudyFormsPageProps { interface StudyFormsPageProps {
params: Promise<{ params: Promise<{
id: string; id: string;
}>; }>;
} }
export default function StudyFormsPage({ params }: StudyFormsPageProps) { export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const utils = api.useUtils(); const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null); const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
const [editorTarget, setEditorTarget] = useState<string>(""); null,
);
const [editorTarget, setEditorTarget] = useState<string>("");
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
const resolved = await params; const resolved = await params;
setResolvedParams(resolved); setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: activeConsentForm, refetch: refetchConsentForm } =
api.studies.getActiveConsentForm.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || '',
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error generating consent form", { description: error.message });
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
},
});
const handleDownloadConsent = async () => {
if (!activeConsentForm || !study || !editor) return;
try {
toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
});
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
}; };
void resolveParams();
}, [params]);
useBreadcrumbsEffect([ const { data: study } = api.studies.get.useQuery(
{ label: "Dashboard", href: "/dashboard" }, { id: resolvedParams?.id ?? "" },
{ label: "Studies", href: "/studies" }, { enabled: !!resolvedParams?.id },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` }, );
{ label: "Forms" },
]);
if (!session?.user) { const { data: activeConsentForm, refetch: refetchConsentForm } =
return notFound(); api.studies.getActiveConsentForm.useQuery(
} { studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
if (!study) return <div>Loading...</div>;
return (
<EntityView>
<PageHeader
title="Study Forms"
description="Manage consent forms and future questionnaires for this study"
icon={FileText}
/>
<div className="grid grid-cols-1 gap-8">
<EntityViewSection
title="Consent Document"
icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials."
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{activeConsentForm.title}
</p>
<p className="text-sm text-muted-foreground">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
</div>
</div>
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
<Toolbar editor={editor} />
</div>
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
</div>
</div>
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)}
</EntityViewSection>
</div>
</EntityView>
); );
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || "",
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
},
onError: (error) => {
toast.error("Error generating consent form", {
description: error.message,
});
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
},
});
const handleDownloadConsent = async () => {
if (!activeConsentForm || !study || !editor) return;
try {
toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`,
});
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
};
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms" },
]);
if (!session?.user) {
return notFound();
}
if (!study) return <div>Loading...</div>;
return (
<EntityView>
<PageHeader
title="Study Forms"
description="Manage consent forms and future questionnaires for this study"
icon={FileText}
/>
<div className="grid grid-cols-1 gap-8">
<EntityViewSection
title="Consent Document"
icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials."
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
generateConsentMutation.mutate({ studyId: study.id })
}
disabled={
generateConsentMutation.isPending ||
updateConsentMutation.isPending
}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() =>
updateConsentMutation.mutate({
studyId: study.id,
content: editorTarget,
})
}
disabled={
updateConsentMutation.isPending ||
editorTarget === activeConsentForm.content
}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{activeConsentForm.title}
</p>
<p className="text-muted-foreground text-sm">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge
variant="outline"
className="bg-green-50 text-green-700 hover:bg-green-50"
>
Active
</Badge>
</div>
</div>
<div className="bg-muted/30 border-border flex w-full justify-center overflow-hidden rounded-md border p-8">
<div className="dark:bg-card ring-border flex w-full max-w-4xl flex-col rounded-sm bg-white shadow-xl ring-1">
<div className="border-border bg-muted/50 dark:bg-muted/10 border-b">
<Toolbar editor={editor} />
</div>
<div className="editor-container dark:bg-card min-h-[850px] bg-white px-16 py-20 text-sm">
<EditorContent
editor={editor}
className="prose prose-sm dark:prose-invert h-full max-w-none outline-none focus:outline-none focus-visible:outline-none"
/>
</div>
</div>
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)}
</EntityViewSection>
</div>
</EntityView>
);
} }

View File

@@ -273,12 +273,13 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link> </Link>
</h4> </h4>
<span <span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft" className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
? "bg-gray-100 text-gray-800" experiment.status === "draft"
: experiment.status === "ready" ? "bg-gray-100 text-gray-800"
? "bg-green-100 text-green-800" : experiment.status === "ready"
: "bg-blue-100 text-blue-800" ? "bg-green-100 text-green-800"
}`} : "bg-blue-100 text-blue-800"
}`}
> >
{experiment.status} {experiment.status}
</span> </span>

View File

@@ -9,7 +9,12 @@ import {
} from "~/lib/storage/minio"; } from "~/lib/storage/minio";
import { auth } from "~/server/auth"; import { auth } from "~/server/auth";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema"; import {
experiments,
mediaCaptures,
studyMembers,
trials,
} from "~/server/db/schema";
const uploadSchema = z.object({ const uploadSchema = z.object({
trialId: z.string().optional(), trialId: z.string().optional(),
@@ -91,15 +96,15 @@ export async function POST(request: NextRequest) {
.where( .where(
and( and(
eq(studyMembers.studyId, trial[0].studyId), eq(studyMembers.studyId, trial[0].studyId),
eq(studyMembers.userId, session.user.id) eq(studyMembers.userId, session.user.id),
) ),
) )
.limit(1); .limit(1);
if (!membership.length) { if (!membership.length) {
return NextResponse.json( return NextResponse.json(
{ error: "Insufficient permissions to upload to this trial" }, { error: "Insufficient permissions to upload to this trial" },
{ status: 403 } { status: 403 },
); );
} }
} }

View File

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

View File

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

View File

@@ -119,39 +119,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(contextStudyId ...(contextStudyId
? [ ? [
{ {
label: participant?.study?.name ?? "Study", label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`, href: `/studies/${contextStudyId}`,
}, },
{ {
label: "Participants", label: "Participants",
href: `/studies/${contextStudyId}/participants`, href: `/studies/${contextStudyId}/participants`,
}, },
...(mode === "edit" && participant ...(mode === "edit" && participant
? [ ? [
{ {
label: participant.name ?? participant.participantCode, label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`, href: `/studies/${contextStudyId}/participants/${participant.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Participant" }]), : [{ label: "New Participant" }]),
] ]
: [ : [
{ {
label: "Participants", label: "Participants",
href: `/studies/${contextStudyId}/participants`, href: `/studies/${contextStudyId}/participants`,
}, },
...(mode === "edit" && participant ...(mode === "edit" && participant
? [ ? [
{ {
label: participant.name ?? participant.participantCode, label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`, href: `/studies/${contextStudyId}/participants/${participant.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Participant" }]), : [{ label: "New Participant" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -291,7 +291,7 @@ export function ParticipantForm({
readOnly={true} readOnly={true}
className={cn( className={cn(
"bg-muted text-muted-foreground", "bg-muted text-muted-foreground",
form.formState.errors.participantCode ? "border-red-500" : "" form.formState.errors.participantCode ? "border-red-500" : "",
)} )}
/> />
{form.formState.errors.participantCode && ( {form.formState.errors.participantCode && (
@@ -338,7 +338,11 @@ export function ParticipantForm({
<FormSection <FormSection
title={contextStudyId ? "Demographics" : "Demographics & Study"} 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"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{!contextStudyId && ( {!contextStudyId && (
@@ -358,7 +362,9 @@ export function ParticipantForm({
} }
> >
<SelectValue <SelectValue
placeholder={studiesLoading ? "Loading..." : "Select study"} placeholder={
studiesLoading ? "Loading..." : "Select study"
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -404,11 +410,11 @@ export function ParticipantForm({
form.setValue( form.setValue(
"gender", "gender",
value as value as
| "male" | "male"
| "female" | "female"
| "non_binary" | "non_binary"
| "prefer_not_to_say" | "prefer_not_to_say"
| "other", | "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); const [isCompleting, setIsCompleting] = useState(false);
// Map database step types to component step types // Map database step types to component step types
@@ -632,17 +633,19 @@ export const WizardInterface = React.memo(function WizardInterface({
if (matchedOption) { if (matchedOption) {
// Handle both string options and object options for nextStepId // Handle both string options and object options for nextStepId
const nextStepId = typeof matchedOption === "string" const nextStepId =
? null // String options don't have nextStepId typeof matchedOption === "string"
: matchedOption.nextStepId; ? null // String options don't have nextStepId
: matchedOption.nextStepId;
if (nextStepId) { if (nextStepId) {
// Find index of the target step // Find index of the target step
const targetIndex = steps.findIndex((s) => s.id === nextStepId); const targetIndex = steps.findIndex((s) => s.id === nextStepId);
if (targetIndex !== -1) { if (targetIndex !== -1) {
const label = typeof matchedOption === "string" const label =
? matchedOption typeof matchedOption === "string"
: matchedOption.label; ? matchedOption
: matchedOption.label;
console.log( console.log(
`[WizardInterface] Branching to step ${targetIndex} (${label})`, `[WizardInterface] Branching to step ${targetIndex} (${label})`,

View File

@@ -345,7 +345,8 @@ export function useWizardRos(
...execution, ...execution,
status: "failed", status: "failed",
endTime: new Date(), endTime: new Date(),
error: error instanceof Error ? error.message : "System action failed", error:
error instanceof Error ? error.message : "System action failed",
}; };
service.emit("action_failed", failedExecution); service.emit("action_failed", failedExecution);
throw error; throw error;

View File

@@ -1,61 +1,76 @@
export interface PdfOptions { export interface PdfOptions {
filename?: string; filename?: string;
} }
const getHtml2PdfOptions = (filename?: string) => ({ const getHtml2PdfOptions = (filename?: string) => ({
margin: 0.5, margin: 0.5,
filename: filename ?? 'document.pdf', filename: filename ?? "document.pdf",
image: { type: 'jpeg' as const, quality: 0.98 }, image: { type: "jpeg" as const, quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: "#ffffff", windowWidth: 800 }, html2canvas: {
jsPDF: { unit: 'in', format: 'letter' as const, orientation: 'portrait' as const } scale: 2,
useCORS: true,
backgroundColor: "#ffffff",
windowWidth: 800,
},
jsPDF: {
unit: "in",
format: "letter" as const,
orientation: "portrait" as const,
},
}); });
const createPrintWrapper = (htmlContent: string) => { const createPrintWrapper = (htmlContent: string) => {
const printWrapper = document.createElement("div"); const printWrapper = document.createElement("div");
printWrapper.style.position = "absolute"; printWrapper.style.position = "absolute";
printWrapper.style.left = "-9999px"; printWrapper.style.left = "-9999px";
printWrapper.style.top = "0px"; printWrapper.style.top = "0px";
printWrapper.className = "light"; // Prevent dark mode variables from bleeding into the physical PDF printWrapper.className = "light"; // Prevent dark mode variables from bleeding into the physical PDF
const element = document.createElement("div"); const element = document.createElement("div");
element.innerHTML = htmlContent; element.innerHTML = htmlContent;
// Assign standard prose layout and explicitly white/black print colors // Assign standard prose layout and explicitly white/black print colors
element.className = "prose prose-sm max-w-none p-12 bg-white text-black"; element.className = "prose prose-sm max-w-none p-12 bg-white text-black";
element.style.width = "800px"; element.style.width = "800px";
element.style.backgroundColor = "white"; element.style.backgroundColor = "white";
element.style.color = "black"; element.style.color = "black";
printWrapper.appendChild(element); printWrapper.appendChild(element);
document.body.appendChild(printWrapper); document.body.appendChild(printWrapper);
return { printWrapper, element }; return { printWrapper, element };
}; };
export async function downloadPdfFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<void> { export async function downloadPdfFromHtml(
// @ts-ignore - Dynamic import to prevent SSR issues with window/document htmlContent: string,
const html2pdf = (await import('html2pdf.js')).default; options: PdfOptions = {},
): Promise<void> {
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import("html2pdf.js")).default;
const { printWrapper, element } = createPrintWrapper(htmlContent); const { printWrapper, element } = createPrintWrapper(htmlContent);
try { try {
const opt = getHtml2PdfOptions(options.filename); const opt = getHtml2PdfOptions(options.filename);
await html2pdf().set(opt).from(element).save(); await html2pdf().set(opt).from(element).save();
} finally { } finally {
document.body.removeChild(printWrapper); document.body.removeChild(printWrapper);
} }
} }
export async function generatePdfBlobFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<Blob> { export async function generatePdfBlobFromHtml(
// @ts-ignore - Dynamic import to prevent SSR issues with window/document htmlContent: string,
const html2pdf = (await import('html2pdf.js')).default; options: PdfOptions = {},
): Promise<Blob> {
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import("html2pdf.js")).default;
const { printWrapper, element } = createPrintWrapper(htmlContent); const { printWrapper, element } = createPrintWrapper(htmlContent);
try { try {
const opt = getHtml2PdfOptions(options.filename); const opt = getHtml2PdfOptions(options.filename);
const pdfBlob = await html2pdf().set(opt).from(element).output('blob'); const pdfBlob = await html2pdf().set(opt).from(element).output("blob");
return pdfBlob; return pdfBlob;
} finally { } finally {
document.body.removeChild(printWrapper); document.body.removeChild(printWrapper);
} }
} }

View File

@@ -499,7 +499,8 @@ export const robotsRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const robotIp = process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168"; const robotIp =
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
const password = process.env.NAO_PASSWORD || "robolab"; const password = process.env.NAO_PASSWORD || "robolab";
console.log(`[Robots] Initializing robot ${input.id} at ${robotIp}`); console.log(`[Robots] Initializing robot ${input.id} at ${robotIp}`);
@@ -514,7 +515,10 @@ export const robotsRouter = createTRPCRouter({
// Execute commands sequentially // Execute commands sequentially
console.log("[Robots] Executing AL disable..."); console.log("[Robots] Executing AL disable...");
await execAsync(disableAlCmd).catch((e) => await execAsync(disableAlCmd).catch((e) =>
console.warn("AL disable failed (non-critical/already disabled):", e), console.warn(
"AL disable failed (non-critical/already disabled):",
e,
),
); );
console.log("[Robots] Executing Wake Up..."); console.log("[Robots] Executing Wake Up...");
@@ -538,7 +542,8 @@ export const robotsRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const robotIp = process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168"; const robotIp =
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
const password = process.env.NAO_PASSWORD || "robolab"; const password = process.env.NAO_PASSWORD || "robolab";
console.log(`[Robots] Executing system action ${input.id}`); console.log(`[Robots] Executing system action ${input.id}`);