mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
Various improvements: study forms, participant management, PDF generator, robot integration
This commit is contained in:
@@ -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!");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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})`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user