mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
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:
506
src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx
Normal file
506
src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
src/app/(dashboard)/studies/[id]/forms/new/page.tsx
Normal file
354
src/app/(dashboard)/studies/[id]/forms/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,131 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSession } from "~/lib/auth-client";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
|
||||||
Plus,
|
Plus,
|
||||||
Download,
|
Search,
|
||||||
Edit2,
|
ClipboardList,
|
||||||
|
FileQuestion,
|
||||||
|
FileSignature,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
Eye,
|
Eye,
|
||||||
Save,
|
Copy,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
EntityView,
|
EntityView,
|
||||||
EntityViewHeader,
|
|
||||||
EntityViewSection,
|
EntityViewSection,
|
||||||
EmptyState,
|
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";
|
||||||
import { Badge } from "~/components/ui/badge";
|
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 { 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 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 }) => {
|
const formTypeIcons = {
|
||||||
if (!editor) {
|
consent: FileSignature,
|
||||||
return null;
|
survey: ClipboardList,
|
||||||
}
|
questionnaire: FileQuestion,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const formTypeColors = {
|
||||||
<div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1">
|
consent: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
||||||
<Button
|
survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
variant="ghost"
|
questionnaire: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||||
size="sm"
|
};
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
const statusConfig = {
|
||||||
className={editor.isActive("bold") ? "bg-muted" : ""}
|
active: {
|
||||||
>
|
label: "Active",
|
||||||
<Bold className="h-4 w-4" />
|
variant: "default" as const,
|
||||||
</Button>
|
icon: CheckCircle,
|
||||||
<Button
|
},
|
||||||
variant="ghost"
|
draft: {
|
||||||
size="sm"
|
label: "Draft",
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
variant: "secondary" as const,
|
||||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
icon: Clock,
|
||||||
className={editor.isActive("italic") ? "bg-muted" : ""}
|
},
|
||||||
>
|
deprecated: {
|
||||||
<Italic className="h-4 w-4" />
|
label: "Deprecated",
|
||||||
</Button>
|
variant: "destructive" as const,
|
||||||
<div className="bg-border mx-1 h-6 w-[1px]" />
|
icon: XCircle,
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StudyFormsPageProps {
|
interface StudyFormsPageProps {
|
||||||
@@ -136,11 +79,10 @@ interface StudyFormsPageProps {
|
|||||||
|
|
||||||
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||||
null,
|
const [search, setSearch] = useState("");
|
||||||
);
|
|
||||||
const [editorTarget, setEditorTarget] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resolveParams = async () => {
|
const resolveParams = async () => {
|
||||||
@@ -155,91 +97,33 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
{ enabled: !!resolvedParams?.id },
|
{ enabled: !!resolvedParams?.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
const { data: formsData, isLoading } = api.forms.list.useQuery(
|
||||||
api.studies.getActiveConsentForm.useQuery(
|
{ studyId: resolvedParams?.id ?? "", search: search || undefined },
|
||||||
{ studyId: resolvedParams?.id ?? "" },
|
|
||||||
{ enabled: !!resolvedParams?.id },
|
{ enabled: !!resolvedParams?.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only sync once when form loads to avoid resetting user edits
|
const userRole = (study as any)?.userRole;
|
||||||
useEffect(() => {
|
const canManage = userRole === "owner" || userRole === "researcher";
|
||||||
if (activeConsentForm && !editorTarget) {
|
|
||||||
setEditorTarget(activeConsentForm.content);
|
|
||||||
}
|
|
||||||
}, [activeConsentForm, editorTarget]);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
const deleteMutation = api.forms.delete.useMutation({
|
||||||
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: () => {
|
onSuccess: () => {
|
||||||
toast.success("Consent Form Saved Successfully!");
|
toast.success("Form deleted successfully");
|
||||||
void refetchConsentForm();
|
void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
|
||||||
void utils.studies.getActivity.invalidate({
|
|
||||||
studyId: resolvedParams?.id ?? "",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Error saving consent form", { description: error.message });
|
toast.error("Failed to delete form", { description: error.message });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDownloadConsent = async () => {
|
const setActiveMutation = api.forms.setActive.useMutation({
|
||||||
if (!activeConsentForm || !study || !editor) return;
|
onSuccess: () => {
|
||||||
|
toast.success("Form set as active");
|
||||||
try {
|
void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
|
||||||
toast.loading("Generating Document...", { id: "pdf-gen" });
|
},
|
||||||
await downloadPdfFromHtml(editor.getHTML(), {
|
onError: (error) => {
|
||||||
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`,
|
toast.error("Failed to set active", { description: error.message });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error generating PDF", { id: "pdf-gen" });
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useBreadcrumbsEffect([
|
useBreadcrumbsEffect([
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
@@ -254,121 +138,145 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
|
|
||||||
if (!study) return <div>Loading...</div>;
|
if (!study) return <div>Loading...</div>;
|
||||||
|
|
||||||
const userRole = (study as any)?.userRole;
|
const forms = formsData?.forms ?? [];
|
||||||
const canManage = userRole === "owner" || userRole === "researcher";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityView>
|
<EntityView>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Study Forms"
|
title="Forms"
|
||||||
description="Manage consent forms and future questionnaires for this study"
|
description="Manage consent forms, surveys, and questionnaires for this study"
|
||||||
icon={FileText}
|
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={
|
actions={
|
||||||
canManage ? (
|
canManage && (
|
||||||
<div className="flex gap-2">
|
<Button asChild>
|
||||||
<Button
|
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
|
||||||
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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
)}
|
Create Form
|
||||||
Generate Default Template
|
</Link>
|
||||||
</Button>
|
</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>
|
|
||||||
) : 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>
|
{forms.length === 0 && !isLoading ? (
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="FileText"
|
icon="FileText"
|
||||||
title="No Consent Form"
|
title="No Forms Yet"
|
||||||
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
description="Create consent forms, surveys, or questionnaires to collect data from participants"
|
||||||
|
action={
|
||||||
|
canManage ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Your First Form
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</EntityViewSection>
|
|
||||||
</div>
|
</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>
|
</EntityView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { collaborationRouter } from "~/server/api/routers/collaboration";
|
|||||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||||
import { experimentsRouter } from "~/server/api/routers/experiments";
|
import { experimentsRouter } from "~/server/api/routers/experiments";
|
||||||
import { filesRouter } from "~/server/api/routers/files";
|
import { filesRouter } from "~/server/api/routers/files";
|
||||||
|
import { formsRouter } from "~/server/api/routers/forms";
|
||||||
import { mediaRouter } from "~/server/api/routers/media";
|
import { mediaRouter } from "~/server/api/routers/media";
|
||||||
import { participantsRouter } from "~/server/api/routers/participants";
|
import { participantsRouter } from "~/server/api/routers/participants";
|
||||||
import { pluginsRouter } from "~/server/api/routers/plugins";
|
import { pluginsRouter } from "~/server/api/routers/plugins";
|
||||||
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
storage: storageRouter,
|
storage: storageRouter,
|
||||||
|
forms: formsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
592
src/server/api/routers/forms.ts
Normal file
592
src/server/api/routers/forms.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
import {
|
||||||
|
activityLogs,
|
||||||
|
formResponses,
|
||||||
|
formTypeEnum,
|
||||||
|
forms,
|
||||||
|
formFieldTypeEnum,
|
||||||
|
participants,
|
||||||
|
studyMembers,
|
||||||
|
userSystemRoles,
|
||||||
|
} from "~/server/db/schema";
|
||||||
|
|
||||||
|
const formTypes = formTypeEnum.enumValues;
|
||||||
|
const fieldTypes = formFieldTypeEnum.enumValues;
|
||||||
|
|
||||||
|
async function checkStudyAccess(
|
||||||
|
db: typeof import("~/server/db").db,
|
||||||
|
userId: string,
|
||||||
|
studyId: string,
|
||||||
|
requiredRole?: string[],
|
||||||
|
) {
|
||||||
|
const adminRole = await db.query.userSystemRoles.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(userSystemRoles.userId, userId),
|
||||||
|
eq(userSystemRoles.role, "administrator"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adminRole) {
|
||||||
|
return { role: "administrator", studyId, userId, joinedAt: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, userId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have access to this study",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRole && !requiredRole.includes(membership.role)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to perform this action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formsRouter = createTRPCRouter({
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
studyId: z.string().uuid(),
|
||||||
|
type: z.enum(formTypes).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.number().min(1).default(1),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { studyId, type, search, page, limit } = input;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, studyId);
|
||||||
|
|
||||||
|
const conditions = [eq(forms.studyId, studyId)];
|
||||||
|
if (type) {
|
||||||
|
conditions.push(eq(forms.type, type));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(forms.title, `%${search}%`),
|
||||||
|
ilike(forms.description, `%${search}%`),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [formsList, totalCount] = await Promise.all([
|
||||||
|
ctx.db.query.forms.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
with: {
|
||||||
|
createdBy: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(forms.updatedAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(forms)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formsWithCounts = await Promise.all(
|
||||||
|
formsList.map(async (form) => {
|
||||||
|
const responseCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(formResponses)
|
||||||
|
.where(eq(formResponses.formId, form.id));
|
||||||
|
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
forms: formsWithCounts,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalCount[0]?.count ?? 0,
|
||||||
|
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, input.id),
|
||||||
|
with: {
|
||||||
|
createdBy: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
participantCode: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(formResponses.submittedAt)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
|
||||||
|
|
||||||
|
return form;
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
studyId: z.string().uuid(),
|
||||||
|
type: z.enum(formTypes),
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
).default([]),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [newForm] = await ctx.db
|
||||||
|
.insert(forms)
|
||||||
|
.values({
|
||||||
|
studyId: input.studyId,
|
||||||
|
type: input.type,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
fields: input.fields,
|
||||||
|
settings: input.settings ?? {},
|
||||||
|
createdBy: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!newForm) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create form",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
studyId: input.studyId,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "form_created",
|
||||||
|
description: `Created form "${newForm.title}"`,
|
||||||
|
resourceType: "form",
|
||||||
|
resourceId: newForm.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newForm;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
).optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...updateData } = input;
|
||||||
|
|
||||||
|
const existingForm = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingForm) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [updatedForm] = await ctx.db
|
||||||
|
.update(forms)
|
||||||
|
.set({
|
||||||
|
...updateData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(forms.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedForm) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to update form",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
studyId: existingForm.studyId,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "form_updated",
|
||||||
|
description: `Updated form "${updatedForm.title}"`,
|
||||||
|
resourceType: "form",
|
||||||
|
resourceId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedForm;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ctx.db.delete(forms).where(eq(forms.id, input.id));
|
||||||
|
|
||||||
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
studyId: form.studyId,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "form_deleted",
|
||||||
|
description: `Deleted form "${form.title}"`,
|
||||||
|
resourceType: "form",
|
||||||
|
resourceId: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setActive: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(forms)
|
||||||
|
.set({ active: false })
|
||||||
|
.where(eq(forms.studyId, form.studyId));
|
||||||
|
|
||||||
|
const [updatedForm] = await ctx.db
|
||||||
|
.update(forms)
|
||||||
|
.set({ active: true })
|
||||||
|
.where(eq(forms.id, input.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedForm;
|
||||||
|
}),
|
||||||
|
|
||||||
|
createVersion: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...updateData } = input;
|
||||||
|
|
||||||
|
const existingForm = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingForm) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const latestForm = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.studyId, existingForm.studyId),
|
||||||
|
orderBy: [desc(forms.version)],
|
||||||
|
});
|
||||||
|
const newVersion = (latestForm?.version ?? 0) + 1;
|
||||||
|
|
||||||
|
const [newForm] = await ctx.db
|
||||||
|
.insert(forms)
|
||||||
|
.values({
|
||||||
|
studyId: existingForm.studyId,
|
||||||
|
type: existingForm.type,
|
||||||
|
title: updateData.title ?? existingForm.title,
|
||||||
|
description: updateData.description ?? existingForm.description,
|
||||||
|
fields: updateData.fields ?? existingForm.fields,
|
||||||
|
settings: updateData.settings ?? existingForm.settings,
|
||||||
|
version: newVersion,
|
||||||
|
active: false,
|
||||||
|
createdBy: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!newForm) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create form version",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
studyId: existingForm.studyId,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "form_version_created",
|
||||||
|
description: `Created version ${newVersion} of form "${newForm.title}"`,
|
||||||
|
resourceType: "form",
|
||||||
|
resourceId: newForm.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newForm;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getResponses: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().uuid(),
|
||||||
|
page: z.number().min(1).default(1),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
status: z.enum(["pending", "completed", "rejected"]).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { formId, page, limit, status } = input;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, formId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
|
||||||
|
|
||||||
|
const conditions = [eq(formResponses.formId, formId)];
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq(formResponses.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [responses, totalCount] = await Promise.all([
|
||||||
|
ctx.db.query.formResponses.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
participantCode: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(formResponses.submittedAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(formResponses)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
responses,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalCount[0]?.count ?? 0,
|
||||||
|
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
submitResponse: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().uuid(),
|
||||||
|
participantId: z.string().uuid(),
|
||||||
|
responses: z.record(z.string(), z.any()),
|
||||||
|
signatureData: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { formId, participantId, responses, signatureData } = input;
|
||||||
|
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, formId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
|
||||||
|
|
||||||
|
const existingResponse = await ctx.db.query.formResponses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(formResponses.formId, formId),
|
||||||
|
eq(formResponses.participantId, participantId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingResponse) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Participant has already submitted this form",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newResponse] = await ctx.db
|
||||||
|
.insert(formResponses)
|
||||||
|
.values({
|
||||||
|
formId,
|
||||||
|
participantId,
|
||||||
|
responses,
|
||||||
|
signatureData,
|
||||||
|
status: signatureData ? "completed" : "pending",
|
||||||
|
signedAt: signatureData ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newResponse;
|
||||||
|
}),
|
||||||
|
|
||||||
|
listVersions: protectedProcedure
|
||||||
|
.input(z.object({ studyId: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId);
|
||||||
|
|
||||||
|
const formsList = await ctx.db.query.forms.findMany({
|
||||||
|
where: eq(forms.studyId, input.studyId),
|
||||||
|
with: {
|
||||||
|
createdBy: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(forms.version)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const formsWithCounts = await Promise.all(
|
||||||
|
formsList.map(async (form) => {
|
||||||
|
const responseCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(formResponses)
|
||||||
|
.where(eq(formResponses.formId, form.id));
|
||||||
|
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return formsWithCounts;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -68,6 +68,29 @@ export const stepTypeEnum = pgEnum("step_type", [
|
|||||||
"conditional",
|
"conditional",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const formTypeEnum = pgEnum("form_type", [
|
||||||
|
"consent",
|
||||||
|
"survey",
|
||||||
|
"questionnaire",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const formFieldTypeEnum = pgEnum("form_field_type", [
|
||||||
|
"text",
|
||||||
|
"textarea",
|
||||||
|
"multiple_choice",
|
||||||
|
"checkbox",
|
||||||
|
"rating",
|
||||||
|
"yes_no",
|
||||||
|
"date",
|
||||||
|
"signature",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const formResponseStatusEnum = pgEnum("form_response_status", [
|
||||||
|
"pending",
|
||||||
|
"completed",
|
||||||
|
"rejected",
|
||||||
|
]);
|
||||||
|
|
||||||
export const communicationProtocolEnum = pgEnum("communication_protocol", [
|
export const communicationProtocolEnum = pgEnum("communication_protocol", [
|
||||||
"rest",
|
"rest",
|
||||||
"ros2",
|
"ros2",
|
||||||
@@ -594,6 +617,64 @@ export const consentForms = createTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// New unified forms table
|
||||||
|
export const forms = createTable(
|
||||||
|
"form",
|
||||||
|
{
|
||||||
|
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||||
|
studyId: uuid("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
type: formTypeEnum("type").notNull(),
|
||||||
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
version: integer("version").default(1).notNull(),
|
||||||
|
active: boolean("active").default(true).notNull(),
|
||||||
|
fields: jsonb("fields").notNull().default([]),
|
||||||
|
settings: jsonb("settings").default({}),
|
||||||
|
createdBy: text("created_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
studyVersionUnique: unique().on(table.studyId, table.version),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Form responses/submissions
|
||||||
|
export const formResponses = createTable(
|
||||||
|
"form_response",
|
||||||
|
{
|
||||||
|
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||||
|
formId: uuid("form_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => forms.id, { onDelete: "cascade" }),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
responses: jsonb("responses").notNull().default({}),
|
||||||
|
status: formResponseStatusEnum("status").default("pending"),
|
||||||
|
signatureData: text("signature_data"),
|
||||||
|
signedAt: timestamp("signed_at", { withTimezone: true }),
|
||||||
|
ipAddress: inet("ip_address"),
|
||||||
|
submittedAt: timestamp("submitted_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
formParticipantUnique: unique().on(table.formId, table.participantId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const participantConsents = createTable(
|
export const participantConsents = createTable(
|
||||||
"participant_consent",
|
"participant_consent",
|
||||||
{
|
{
|
||||||
@@ -1118,6 +1199,29 @@ export const participantConsentsRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const formsRelations = relations(forms, ({ one, many }) => ({
|
||||||
|
study: one(studies, {
|
||||||
|
fields: [forms.studyId],
|
||||||
|
references: [studies.id],
|
||||||
|
}),
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [forms.createdBy],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
responses: many(formResponses),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const formResponsesRelations = relations(formResponses, ({ one }) => ({
|
||||||
|
form: one(forms, {
|
||||||
|
fields: [formResponses.formId],
|
||||||
|
references: [forms.id],
|
||||||
|
}),
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [formResponses.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const robotsRelations = relations(robots, ({ many }) => ({
|
export const robotsRelations = relations(robots, ({ many }) => ({
|
||||||
experiments: many(experiments),
|
experiments: many(experiments),
|
||||||
plugins: many(plugins),
|
plugins: many(plugins),
|
||||||
|
|||||||
Reference in New Issue
Block a user