feat: complete forms system overhaul

- Add new forms table with type (consent/survey/questionnaire)
- Add formResponses table for submissions
- Add forms API router with full CRUD:
  - list, get, create, update, delete
  - setActive, createVersion
  - getResponses, submitResponse
- Add forms list page with card-based UI
- Add form builder with field types (text, textarea, multiple_choice, checkbox, rating, yes_no, date, signature)
- Add form viewer with edit mode and preview
- Add responses viewing with participant info
This commit is contained in:
2026-03-22 17:43:12 -04:00
parent 8529d0ef89
commit 49e0df016a
6 changed files with 1756 additions and 290 deletions

View File

@@ -0,0 +1,506 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Eye,
Edit2,
Users,
CheckCircle,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
const statusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
interface FormViewPageProps {
params: Promise<{
id: string;
formId: string;
}>;
}
export default function FormViewPage({ params }: FormViewPageProps) {
const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string; formId: string } | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: form, isLoading } = api.forms.get.useQuery(
{ id: resolvedParams?.formId ?? "" },
{ enabled: !!resolvedParams?.formId },
);
const { data: responsesData } = api.forms.getResponses.useQuery(
{ formId: resolvedParams?.formId ?? "", limit: 50 },
{ enabled: !!resolvedParams?.formId },
);
const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
const updateForm = api.forms.update.useMutation({
onSuccess: () => {
toast.success("Form updated successfully!");
setIsEditing(false);
void utils.forms.get.invalidate({ id: resolvedParams?.formId });
},
onError: (error) => {
toast.error("Failed to update form", { description: error.message });
},
});
useEffect(() => {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
setFields((form.fields as Field[]) || []);
}
}, [form]);
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms", href: `/studies/${resolvedParams?.id}/forms` },
{ label: form?.title ?? "Form" },
]);
if (!session?.user) {
return notFound();
}
if (isLoading || !form) return <div>Loading...</div>;
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const responses = responsesData?.responses ?? [];
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSave = () => {
updateForm.mutate({
id: form.id,
title,
description,
fields,
settings: form.settings as Record<string, any>,
});
};
return (
<div className="container mx-auto max-w-4xl space-y-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${resolvedParams?.id}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-2xl font-bold">{form.title}</h1>
{form.active && (
<Badge variant="default" className="text-xs">Active</Badge>
)}
</div>
<p className="text-muted-foreground text-sm capitalize">
{form.type} Version {form.version}
</p>
</div>
</div>
{canManage && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={updateForm.isPending}>
<Save className="mr-2 h-4 w-4" />
Save Changes
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Form
</Button>
)}
</div>
)}
</div>
<Tabs defaultValue="fields" className="space-y-4">
<TabsList>
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="responses">
Responses ({responses.length})
</TabsTrigger>
</TabsList>
<TabsContent value="fields">
{isEditing ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields defined</p>
) : (
<div className="space-y-3">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center gap-3 rounded-lg border p-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs">
{index + 1}
</span>
<div className="flex-1">
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{fieldTypes.find(f => f.value === field.type)?.label}
{field.required && " • Required"}
{field.type === "multiple_choice" && `${field.options?.length} options`}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="preview">
<Card>
<CardHeader>
<CardTitle>Form Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<h2 className="text-xl font-semibold">{title}</h2>
{description && <p className="text-muted-foreground">{description}</p>}
</div>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields to preview</p>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<Label>
{index + 1}. {field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
{field.type === "text" && (
<Input placeholder="Enter your response..." disabled />
)}
{field.type === "textarea" && (
<Textarea placeholder="Enter your response..." disabled />
)}
{field.type === "multiple_choice" && (
<div className="space-y-2">
{field.options?.map((opt, i) => (
<label key={i} className="flex items-center gap-2">
<input type="radio" disabled /> {opt}
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<label className="flex items-center gap-2">
<input type="checkbox" disabled /> Yes
</label>
)}
{field.type === "yes_no" && (
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input type="radio" disabled /> Yes
</label>
<label className="flex items-center gap-2">
<input type="radio" disabled /> No
</label>
</div>
)}
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from({ length: field.settings?.scale || 5 }, (_, i) => (
<button key={i} type="button" className="h-8 w-8 rounded border disabled" disabled>
{i + 1}
</button>
))}
</div>
)}
{field.type === "date" && (
<Input type="date" disabled />
)}
{field.type === "signature" && (
<div className="h-24 rounded border bg-muted/50 flex items-center justify-center text-muted-foreground">
Signature pad (disabled in preview)
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="responses">
<Card>
<CardHeader>
<CardTitle>Form Responses</CardTitle>
</CardHeader>
<CardContent>
{responses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Users className="mb-2 h-8 w-8" />
<p>No responses yet</p>
</div>
) : (
<div className="space-y-4">
{responses.map((response) => (
<div key={response.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{response.participant?.name || response.participant?.participantCode || "Unknown"}
</span>
</div>
<Badge className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}>
{response.status}
</Badge>
</div>
<div className="space-y-2 text-sm">
{Object.entries(response.responses as Record<string, any>).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="text-muted-foreground">{key}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
{response.signedAt && (
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground">
Signed: {new Date(response.signedAt).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypes = [
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
{ value: "survey", label: "Survey", icon: ClipboardList, description: "Multi-question questionnaires" },
{ value: "questionnaire", label: "Questionnaire", icon: FileQuestion, description: "Custom data collection forms" },
];
export default function NewFormPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const utils = api.useUtils();
const studyId = typeof params.id === "string" ? params.id : "";
const [formType, setFormType] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: study } = api.studies.get.useQuery(
{ id: studyId },
{ enabled: !!studyId },
);
const createForm = api.forms.create.useMutation({
onSuccess: (data) => {
toast.success("Form created successfully!");
router.push(`/studies/${studyId}/forms/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create form", { description: error.message });
setIsSubmitting(false);
},
});
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Forms", href: `/studies/${studyId}/forms` },
{ label: "Create Form" },
]);
if (!session?.user) {
return notFound();
}
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formType || !title) {
toast.error("Please select a form type and enter a title");
return;
}
setIsSubmitting(true);
createForm.mutate({
studyId,
type: formType as "consent" | "survey" | "questionnaire",
title,
description,
fields,
settings: {},
});
};
return (
<div className="container mx-auto max-w-4xl space-y-6 py-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${studyId}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-bold">Create New Form</h1>
<p className="text-muted-foreground">Design a consent form, survey, or questionnaire</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{formTypes.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormType(type.value)}
className={`flex flex-col items-start rounded-lg border p-4 text-left transition-all hover:bg-muted/50 ${
formType === type.value
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border"
}`}
>
<type.icon className={`mb-2 h-5 w-5 ${formType === type.value ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium">{type.label}</span>
<span className="text-muted-foreground text-xs">{type.description}</span>
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter form title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown above to add fields</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={field.settings?.scale?.toString() || "5"}
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href={`/studies/${studyId}/forms`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isSubmitting || !formType || !title}>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "Creating..." : "Create Form"}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,131 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
Loader2,
Plus,
Download,
Edit2,
Search,
ClipboardList,
FileQuestion,
FileSignature,
MoreHorizontal,
Pencil,
Trash2,
Eye,
Save,
Copy,
CheckCircle,
XCircle,
Clock,
} from "lucide-react";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import {
Bold,
Italic,
List,
ListOrdered,
Heading1,
Heading2,
Quote,
Table as TableIcon,
} from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => {
if (!editor) {
return null;
}
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
return (
<div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1">
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "bg-muted" : ""}
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "bg-muted" : ""}
>
<Italic className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "bg-muted" : ""}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "bg-muted" : ""}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "bg-muted" : ""}
>
<Quote className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
const formTypeColors = {
consent: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
questionnaire: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
};
const statusConfig = {
active: {
label: "Active",
variant: "default" as const,
icon: CheckCircle,
},
draft: {
label: "Draft",
variant: "secondary" as const,
icon: Clock,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: XCircle,
},
};
interface StudyFormsPageProps {
@@ -136,11 +79,10 @@ interface StudyFormsPageProps {
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
const [editorTarget, setEditorTarget] = useState<string>("");
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
const resolveParams = async () => {
@@ -155,91 +97,33 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
{ enabled: !!resolvedParams?.id },
);
const { data: activeConsentForm, refetch: refetchConsentForm } =
api.studies.getActiveConsentForm.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: formsData, isLoading } = api.forms.list.useQuery(
{ studyId: resolvedParams?.id ?? "", search: search || undefined },
{ enabled: !!resolvedParams?.id },
);
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
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({
const deleteMutation = api.forms.delete.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
toast.success("Form deleted successfully");
void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
toast.error("Failed to delete 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);
}
};
const setActiveMutation = api.forms.setActive.useMutation({
onSuccess: () => {
toast.success("Form set as active");
void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
},
onError: (error) => {
toast.error("Failed to set active", { description: error.message });
},
});
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
@@ -254,121 +138,145 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
if (!study) return <div>Loading...</div>;
const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
const forms = formsData?.forms ?? [];
return (
<EntityView>
<PageHeader
title="Study Forms"
description="Manage consent forms and future questionnaires for this study"
title="Forms"
description="Manage consent forms, surveys, and questionnaires for this study"
icon={FileText}
actions={
canManage && (
<Button asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Link>
</Button>
)
}
/>
<div className="grid grid-cols-1 gap-8">
<EntityViewSection
title="Consent Document"
{forms.length === 0 && !isLoading ? (
<EmptyState
icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials."
actions={
title="No Forms Yet"
description="Create consent forms, surveys, or questionnaires to collect data from participants"
action={
canManage ? (
<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>
<Button asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Your First Form
</Link>
</Button>
) : null
}
>
{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 className="space-y-6">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search forms..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)}
</EntityViewSection>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => {
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const typeColor = formTypeColors[form.type as keyof typeof formTypeColors] || "bg-gray-100";
const isActive = form.active;
return (
<Card key={form.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`rounded-md p-2 ${typeColor}`}>
<TypeIcon className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base">{form.title}</CardTitle>
<p className="text-muted-foreground text-xs capitalize">
{form.type}
</p>
</div>
</div>
{isActive && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pb-3">
{form.description && (
<p className="text-muted-foreground text-sm line-clamp-2 mb-3">
{form.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>v{form.version}</span>
<span>{(form as any)._count?.responses ?? 0} responses</span>
</div>
</CardContent>
<div className="flex items-center justify-between border-t bg-muted/30 px-4 py-2">
<Button asChild variant="ghost" size="sm">
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}`}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
{canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!isActive && (
<DropdownMenuItem
onClick={() => setActiveMutation.mutate({ id: form.id })}
>
<CheckCircle className="mr-2 h-4 w-4" />
Set Active
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
if (confirm("Are you sure you want to delete this form?")) {
deleteMutation.mutate({ id: form.id });
}
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
);
})}
</div>
</div>
)}
</EntityView>
);
}
}