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,23 +54,31 @@ 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
.insert(schema.steps)
.values({
experimentId: experiment.id, experimentId: experiment.id,
name: "The Hook", name: "The Hook",
type: "wizard", type: "wizard",
orderIndex: 0, orderIndex: 0,
}).returning(); })
.returning();
// Step 2: The Narrative // Step 2: The Narrative
const [step2] = await db.insert(schema.steps).values({ const [step2] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id, experimentId: experiment.id,
name: "The Narrative", name: "The Narrative",
type: "wizard", type: "wizard",
orderIndex: 1, orderIndex: 1,
}).returning(); })
.returning();
// Step 3: Comprehension Check (Conditional) // Step 3: Comprehension Check (Conditional)
const [step3] = await db.insert(schema.steps).values({ const [step3] = await db
.insert(schema.steps)
.values({
id: checkId, id: checkId,
experimentId: experiment.id, experimentId: experiment.id,
name: "Comprehension Check", name: "Comprehension Check",
@@ -78,77 +87,179 @@ async function main() {
conditions: { conditions: {
variable: "last_wizard_response", variable: "last_wizard_response",
options: [ options: [
{ label: "Answer: Red (Correct)", value: "Red", variant: "default", nextStepId: branchAId }, {
{ label: "Answer: Other (Incorrect)", value: "Incorrect", variant: "destructive", nextStepId: branchBId } label: "Answer: Red (Correct)",
] value: "Red",
} variant: "default",
}).returning(); 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
.insert(schema.steps)
.values({
id: branchAId, id: branchAId,
experimentId: experiment.id, experimentId: experiment.id,
name: "Branch A: Correct Response", name: "Branch A: Correct Response",
type: "wizard", type: "wizard",
orderIndex: 3, orderIndex: 3,
conditions: { nextStepId: conclusionId } // SKIP BRANCH B conditions: { nextStepId: conclusionId }, // SKIP BRANCH B
}).returning(); })
.returning();
// Step 5: Branch B (Incorrect) // Step 5: Branch B (Incorrect)
const [step5] = await db.insert(schema.steps).values({ const [step5] = await db
.insert(schema.steps)
.values({
id: branchBId, id: branchBId,
experimentId: experiment.id, experimentId: experiment.id,
name: "Branch B: Incorrect Response", name: "Branch B: Incorrect Response",
type: "wizard", type: "wizard",
orderIndex: 4, orderIndex: 4,
conditions: { nextStepId: conclusionId } conditions: { nextStepId: conclusionId },
}).returning(); })
.returning();
// Step 6: Conclusion // Step 6: Conclusion
const [step6] = await db.insert(schema.steps).values({ const [step6] = await db
.insert(schema.steps)
.values({
id: conclusionId, id: conclusionId,
experimentId: experiment.id, experimentId: experiment.id,
name: "Conclusion", name: "Conclusion",
type: "wizard", type: "wizard",
orderIndex: 5, orderIndex: 5,
}).returning(); })
.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,7 +3,15 @@
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 {
FileText,
Loader2,
Plus,
Download,
Edit2,
Eye,
Save,
} from "lucide-react";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
@@ -16,14 +24,23 @@ 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 }) => {
@@ -32,13 +49,13 @@ const Toolbar = ({ editor }: { editor: any }) => {
} }
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>
@@ -47,16 +64,16 @@ const Toolbar = ({ editor }: { editor: any }) => {
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>
@@ -64,16 +81,16 @@ const Toolbar = ({ editor }: { editor: any }) => {
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>
@@ -81,7 +98,7 @@ const Toolbar = ({ editor }: { editor: any }) => {
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>
@@ -89,15 +106,21 @@ const Toolbar = ({ editor }: { editor: any }) => {
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
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
> >
<TableIcon className="h-4 w-4" /> <TableIcon className="h-4 w-4" />
</Button> </Button>
@@ -114,7 +137,9 @@ interface StudyFormsPageProps {
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>(
null,
);
const [editorTarget, setEditorTarget] = useState<string>(""); const [editorTarget, setEditorTarget] = useState<string>("");
useEffect(() => { useEffect(() => {
@@ -156,7 +181,7 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
transformPastedText: true, transformPastedText: true,
}), }),
], ],
content: editorTarget || '', content: editorTarget || "",
immediatelyRender: false, immediatelyRender: false,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
// @ts-ignore // @ts-ignore
@@ -177,10 +202,14 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
setEditorTarget(data.content); setEditorTarget(data.content);
editor?.commands.setContent(data.content); editor?.commands.setContent(data.content);
void refetchConsentForm(); void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" }); void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
}, },
onError: (error) => { onError: (error) => {
toast.error("Error generating consent form", { description: error.message }); toast.error("Error generating consent form", {
description: error.message,
});
}, },
}); });
@@ -188,7 +217,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
onSuccess: () => { onSuccess: () => {
toast.success("Consent Form Saved Successfully!"); toast.success("Consent Form Saved Successfully!");
void refetchConsentForm(); void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" }); void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
}, },
onError: (error) => { onError: (error) => {
toast.error("Error saving consent form", { description: error.message }); toast.error("Error saving consent form", { description: error.message });
@@ -201,7 +232,7 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
try { try {
toast.loading("Generating Document...", { id: "pdf-gen" }); toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), { await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf` filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`,
}); });
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" }); toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) { } catch (error) {
@@ -241,8 +272,13 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => generateConsentMutation.mutate({ studyId: study.id })} onClick={() =>
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending} generateConsentMutation.mutate({ studyId: study.id })
}
disabled={
generateConsentMutation.isPending ||
updateConsentMutation.isPending
}
> >
{generateConsentMutation.isPending ? ( {generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -254,8 +290,16 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
{activeConsentForm && ( {activeConsentForm && (
<Button <Button
size="sm" size="sm"
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })} onClick={() =>
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content} updateConsentMutation.mutate({
studyId: study.id,
content: editorTarget,
})
}
disabled={
updateConsentMutation.isPending ||
editorTarget === activeConsentForm.content
}
> >
{updateConsentMutation.isPending ? ( {updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -272,10 +316,10 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium leading-none"> <p className="text-sm leading-none font-medium">
{activeConsentForm.title} {activeConsentForm.title}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
v{activeConsentForm.version} Status: Active v{activeConsentForm.version} Status: Active
</p> </p>
</div> </div>
@@ -288,17 +332,25 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Download PDF Download PDF
</Button> </Button>
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge> <Badge
variant="outline"
className="bg-green-50 text-green-700 hover:bg-green-50"
>
Active
</Badge>
</div> </div>
</div> </div>
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden"> <div className="bg-muted/30 border-border flex w-full justify-center overflow-hidden rounded-md border p-8">
<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="dark:bg-card ring-border flex w-full max-w-4xl flex-col rounded-sm bg-white shadow-xl ring-1">
<div className="border-b border-border bg-muted/50 dark:bg-muted/10"> <div className="border-border bg-muted/50 dark:bg-muted/10 border-b">
<Toolbar editor={editor} /> <Toolbar editor={editor} />
</div> </div>
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card"> <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 max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" /> <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>
</div> </div>

View File

@@ -273,7 +273,8 @@ 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 ${
experiment.status === "draft"
? "bg-gray-100 text-gray-800" ? "bg-gray-100 text-gray-800"
: experiment.status === "ready" : experiment.status === "ready"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"

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

@@ -46,12 +46,16 @@ export function DigitalSignatureModal({
const sigCanvas = useRef<any>(null); const sigCanvas = useRef<any>(null);
// Mutations // Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation(); const getUploadUrlMutation =
api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.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(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode); previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString(); const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today); previewMd = previewMd.replace(/{{DATE}}/g, today);
@@ -70,7 +74,9 @@ export function DigitalSignatureModal({
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", {
description: "Please sign the document before submitting.",
});
return; return;
} }
@@ -79,17 +85,32 @@ export function DigitalSignatureModal({
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(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode); finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today); finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`); 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: [
StarterKit,
Table,
TableRow,
TableHeader,
TableCell,
Markdown,
],
content: finalMd, content: finalMd,
}); });
const htmlContent = headlessEditor.getHTML(); const htmlContent = headlessEditor.getHTML();
@@ -148,26 +169,31 @@ export function DigitalSignatureModal({
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
variant="default"
size="sm"
className="bg-primary/90 hover:bg-primary"
>
<PenBox className="mr-2 h-4 w-4" /> <PenBox className="mr-2 h-4 w-4" />
Sign Digitally Sign Digitally
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6"> <DialogContent className="flex h-[90vh] max-w-4xl flex-col p-6">
<DialogHeader> <DialogHeader>
<DialogTitle>Digital Consent Signature</DialogTitle> <DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription> <DialogDescription>
Please review the document below and provide your digital signature to consent to this study. Please review the document below and provide your digital signature
to consent to this study.
</DialogDescription> </DialogDescription>
</DialogHeader> </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>
@@ -176,26 +202,37 @@ export function DigitalSignatureModal({
{/* 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"
size="sm"
onClick={handleClear}
disabled={isSubmitting}
>
<Eraser className="mr-2 h-4 w-4" />
Clear Clear
</Button> </Button>
</div> </div>
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}> <div
className="border-input mt-10 rounded-md border-2 border-dashed bg-white"
style={{ height: "250px" }}
>
<SignatureCanvas <SignatureCanvas
ref={sigCanvas} ref={sigCanvas}
penColor="black" penColor="black"
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }} canvasProps={{
className: "w-full h-full cursor-crosshair rounded-md",
}}
/> />
</div> </div>
<p className="text-center text-xs text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2 text-center text-xs">
Draw your signature using your mouse or touch screen inside the box above. Draw your signature using your mouse or touch screen inside
the box above.
</p> </p>
</div> </div>
</div> </div>
@@ -203,16 +240,18 @@ export function DigitalSignatureModal({
<div className="flex-1" /> <div className="flex-1" />
{/* Submission Actions */} {/* Submission Actions */}
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20"> <div className="bg-primary/5 border-primary/20 flex flex-col space-y-3 rounded-lg border p-4">
<h4 className="flex items-center text-sm font-semibold text-primary"> <h4 className="text-primary flex items-center text-sm font-semibold">
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="mr-2 h-4 w-4" />
Agreement Agreement
</h4> </h4>
<p className="text-xs text-muted-foreground leading-relaxed"> <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. 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> </p>
<Button <Button
className="w-full mt-2" className="mt-2 w-full"
size="lg" size="lg"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}

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

@@ -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>

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,7 +633,8 @@ 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 =
typeof matchedOption === "string"
? null // String options don't have nextStepId ? null // String options don't have nextStepId
: matchedOption.nextStepId; : matchedOption.nextStepId;
@@ -640,7 +642,8 @@ export const WizardInterface = React.memo(function WizardInterface({
// 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 =
typeof matchedOption === "string"
? matchedOption ? matchedOption
: matchedOption.label; : matchedOption.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

@@ -4,10 +4,19 @@ export interface PdfOptions {
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) => {
@@ -31,9 +40,12 @@ const createPrintWrapper = (htmlContent: string) => {
return { printWrapper, element }; return { printWrapper, element };
}; };
export async function downloadPdfFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<void> { export async function downloadPdfFromHtml(
htmlContent: string,
options: PdfOptions = {},
): Promise<void> {
// @ts-ignore - Dynamic import to prevent SSR issues with window/document // @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import('html2pdf.js')).default; const html2pdf = (await import("html2pdf.js")).default;
const { printWrapper, element } = createPrintWrapper(htmlContent); const { printWrapper, element } = createPrintWrapper(htmlContent);
@@ -45,15 +57,18 @@ export async function downloadPdfFromHtml(htmlContent: string, options: PdfOptio
} }
} }
export async function generatePdfBlobFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<Blob> { export async function generatePdfBlobFromHtml(
htmlContent: string,
options: PdfOptions = {},
): Promise<Blob> {
// @ts-ignore - Dynamic import to prevent SSR issues with window/document // @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import('html2pdf.js')).default; 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}`);