mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37: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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user