feat: Implement digital signatures for participant consent and introduce study forms management.

This commit is contained in:
2026-03-02 10:51:20 -05:00
parent 61af467cc8
commit 0051946bde
172 changed files with 12612 additions and 9461 deletions

View File

@@ -20,7 +20,7 @@ export default function StudyAnalyticsPage() {
// Fetch list of trials
const { data: trialsList, isLoading } = api.trials.list.useQuery(
{ studyId, limit: 100 },
{ enabled: !!studyId }
{ enabled: !!studyId },
);
// Set breadcrumbs
@@ -49,19 +49,23 @@ export default function StudyAnalyticsPage() {
<div className="bg-transparent">
<Suspense fallback={<div>Loading analytics...</div>}>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading session data...</span>
<div className="flex h-64 items-center justify-center">
<div className="flex animate-pulse flex-col items-center gap-2">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-muted-foreground text-sm">
Loading session data...
</span>
</div>
</div>
) : (
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
...t,
startedAt: t.startedAt ? new Date(t.startedAt) : null,
completedAt: t.completedAt ? new Date(t.completedAt) : null,
createdAt: new Date(t.createdAt),
}))} />
<StudyAnalyticsDataTable
data={(trialsList ?? []).map((t) => ({
...t,
startedAt: t.startedAt ? new Date(t.startedAt) : null,
completedAt: t.completedAt ? new Date(t.completedAt) : null,
createdAt: new Date(t.createdAt),
}))}
/>
)}
</Suspense>
</div>

View File

@@ -44,7 +44,7 @@ export function DesignerPageClient({
const stepCount = initialDesign.steps.length;
const actionCount = initialDesign.steps.reduce(
(sum, step) => sum + step.actions.length,
0
0,
);
return { stepCount, actionCount };

View File

@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
}: ExperimentDesignerPageProps) {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
const experiment = await api.experiments.get({
id: resolvedParams.experimentId,
});
if (!experiment) {
notFound();
@@ -36,13 +38,13 @@ export default async function ExperimentDesignerPage({
// Only pass initialDesign if there's existing visual design data
let initialDesign:
| {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
| undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) {
@@ -220,7 +222,9 @@ export default async function ExperimentDesignerPage({
};
};
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
const actions: ExperimentAction[] = s.actions.map((a) =>
hydrateAction(a),
);
return {
id: s.id,
name: s.name,
@@ -278,7 +282,9 @@ export async function generateMetadata({
}> {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
const experiment = await api.experiments.get({
id: resolvedParams.experimentId,
});
return {
title: `${experiment?.name} - Designer | HRIStudio`,

View File

@@ -1,7 +1,15 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
import {
Calendar,
Clock,
Edit,
Play,
Settings,
Users,
TestTube,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
@@ -9,13 +17,13 @@ import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
@@ -23,436 +31,443 @@ import { useSession } from "next-auth/react";
import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps {
params: Promise<{ id: string; experimentId: string }>;
params: Promise<{ id: string; experimentId: string }>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
null,
);
const { selectStudy } = useStudyManagement();
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{
id: string;
experimentId: string;
} | null>(null);
const { selectStudy } = useStudyManagement();
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
// Ensure study context is synced
if (resolved.id) {
void selectStudy(resolved.id);
}
};
void resolveParams();
}, [params, selectStudy]);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
// Ensure study context is synced
if (resolved.id) {
void selectStudy(resolved.id);
}
};
void resolveParams();
}, [params, selectStudy]);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
const studyId = experiment.study.id;
const experimentId = experiment.id;
const studyId = experiment.study.id;
const experimentId = experiment.id;
return (
<EntityView>
<PageHeader
title={displayName}
description={description ?? undefined}
icon={TestTube}
badges={[
{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
}
]}
actions={
canEdit ? (
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</div>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
},
...(canEdit
? [
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
return (
<EntityView>
<PageHeader
title={displayName}
description={description ?? undefined}
icon={TestTube}
badges={[
{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
},
]}
actions={
canEdit ? (
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</div>
</EntityView>
);
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
},
...(canEdit
? [
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}

View File

@@ -0,0 +1,317 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { notFound } from "next/navigation";
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } 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 { 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;
}
return (
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
<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="w-[1px] h-6 bg-border mx-1" />
<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="w-[1px] h-6 bg-border mx-1" />
<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="w-[1px] h-6 bg-border mx-1" />
<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 {
params: Promise<{
id: string;
}>;
}
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
const [editorTarget, setEditorTarget] = useState<string>("");
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: activeConsentForm, refetch: refetchConsentForm } =
api.studies.getActiveConsentForm.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || '',
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error generating consent form", { description: error.message });
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
},
});
const handleDownloadConsent = async () => {
if (!activeConsentForm || !study || !editor) return;
try {
toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
});
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
};
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms" },
]);
if (!session?.user) {
return notFound();
}
if (!study) return <div>Loading...</div>;
return (
<EntityView>
<PageHeader
title="Study Forms"
description="Manage consent forms and future questionnaires for this study"
icon={FileText}
/>
<div className="grid grid-cols-1 gap-8">
<EntityViewSection
title="Consent Document"
icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials."
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{activeConsentForm.title}
</p>
<p className="text-sm text-muted-foreground">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
</div>
</div>
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
<Toolbar editor={editor} />
</div>
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
</div>
</div>
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)}
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -71,6 +71,7 @@ type Member = {
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const { data: session } = useSession();
const utils = api.useUtils();
const [study, setStudy] = useState<Study | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
@@ -176,7 +177,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
}
},
]}
actions={
<div className="flex items-center gap-2">
@@ -301,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
<Link
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
<Link
href={`/studies/${study.id}/experiments/${experiment.id}`}
>
View
</Link>
</Button>
</div>
</div>

View File

@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
import { notFound } from "next/navigation";
interface EditParticipantPageProps {
params: Promise<{
id: string;
participantId: string;
}>;
params: Promise<{
id: string;
participantId: string;
}>;
}
export default async function EditParticipantPage({
params,
params,
}: EditParticipantPageProps) {
const { id: studyId, participantId } = await params;
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
const participant = await api.participants.get({ id: participantId });
if (!participant || participant.studyId !== studyId) {
notFound();
}
if (!participant || participant.studyId !== studyId) {
notFound();
}
// Transform data to match form expectations if needed, or pass directly
return (
<ParticipantForm
mode="edit"
studyId={studyId}
participantId={participantId}
/>
);
// Transform data to match form expectations if needed, or pass directly
return (
<ParticipantForm
mode="edit"
studyId={studyId}
participantId={participantId}
/>
);
}

View File

@@ -1,12 +1,18 @@
import { notFound } from "next/navigation";
import { api } from "~/trpc/server";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { ParticipantDocuments } from "./participant-documents";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
@@ -17,104 +23,129 @@ import { PageHeader } from "~/components/ui/page-header";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>;
params: Promise<{ id: string; participantId: string }>;
}
export default async function ParticipantDetailPage({
params,
params,
}: ParticipantDetailPageProps) {
const { id: studyId, participantId } = await params;
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
const participant = await api.participants.get({ id: participantId });
if (!participant) {
notFound();
}
if (!participant) {
notFound();
}
// Ensure participant belongs to study
if (participant.studyId !== studyId) {
notFound();
}
// Ensure participant belongs to study
if (participant.studyId !== studyId) {
notFound();
}
return (
<EntityView>
<PageHeader
title={participant.participantCode}
description={participant.name ?? "Unnamed Participant"}
icon={Users}
badges={[
{
label: participant.consentGiven ? "Consent Given" : "No Consent",
variant: participant.consentGiven ? "default" : "secondary"
}
]}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</Button>
}
return (
<EntityView>
<PageHeader
title={participant.participantCode}
description={participant.name ?? "Unnamed Participant"}
icon={Users}
badges={[
{
label: participant.consentGiven ? "Consent Given" : "No Consent",
variant: participant.consentGiven ? "default" : "secondary",
},
]}
actions={
<Button asChild variant="outline" size="sm">
<Link
href={`/studies/${studyId}/participants/${participantId}/edit`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</Button>
}
/>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid grid-cols-1 gap-6">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
participantName={participant.name}
participantCode={participant.participantCode}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/>
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground mb-1 block">Code</span>
<span className="text-base font-medium">
{participant.participantCode}
</span>
</div>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<div>
<span className="text-muted-foreground mb-1 block">Name</span>
<span className="text-base font-medium">
{participant.name || "-"}
</span>
</div>
<TabsContent value="overview">
<div className="grid gap-6 grid-cols-1">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/>
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground block mb-1">Code</span>
<span className="font-medium text-base">{participant.participantCode}</span>
</div>
<div>
<span className="text-muted-foreground mb-1 block">
Email
</span>
<span className="text-base font-medium">
{participant.email || "-"}
</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Name</span>
<span className="font-medium text-base">{participant.name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground mb-1 block">
Added
</span>
<span className="text-base font-medium">
{new Date(participant.createdAt).toLocaleDateString()}
</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Email</span>
<span className="font-medium text-base">{participant.email || "-"}</span>
</div>
<div>
<span className="text-muted-foreground mb-1 block">Age</span>
<span className="text-base font-medium">
{(participant.demographics as any)?.age || "-"}
</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Added</span>
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
</div>
<div>
<span className="text-muted-foreground mb-1 block">
Gender
</span>
<span className="text-base font-medium capitalize">
{(participant.demographics as any)?.gender?.replace(
"_",
" ",
) || "-"}
</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<div>
<span className="text-muted-foreground block mb-1">Age</span>
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Gender</span>
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
}

View File

@@ -4,184 +4,192 @@ import { useState } from "react";
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils";
import { toast } from "sonner";
interface ParticipantDocumentsProps {
participantId: string;
participantId: string;
}
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
export function ParticipantDocuments({
participantId,
}: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
const { data: documents, isLoading } =
api.files.listParticipantDocuments.useQuery({
participantId,
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId,
});
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
// 2. Upload to MinIO/S3
const uploadRes = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!uploadRes.ok) {
throw new Error("Upload to storage failed");
}
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId,
});
// 3. Register in DB
await registerUpload.mutateAsync({
participantId,
name: file.name,
type: file.type,
storagePath,
fileSize: file.size,
});
// 2. Upload to MinIO/S3
const uploadRes = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
});
toast.success("File uploaded successfully");
utils.files.listParticipantDocuments.invalidate({ participantId });
} catch (error) {
console.error(error);
toast.error("Failed to upload file");
} finally {
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
if (!uploadRes.ok) {
throw new Error("Upload to storage failed");
}
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({
storagePath,
});
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
// 3. Register in DB
await registerUpload.mutateAsync({
participantId,
name: file.name,
type: file.type,
storagePath,
fileSize: file.size,
});
toast.success("File uploaded successfully");
utils.files.listParticipantDocuments.invalidate({ participantId });
} catch (error) {
console.error(error);
toast.error("Failed to upload file");
} finally {
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : documents?.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 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(doc.fileSize ?? 0)} {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Are you sure you want to delete this file?")) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<Upload className="mr-2 h-4 w-4" />
)}
</CardContent>
</Card>
);
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : documents?.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-muted-foreground text-xs">
{formatBytes(doc.fileSize ?? 0)} {" "}
{new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm("Are you sure you want to delete this file?")
) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -13,111 +13,112 @@ import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import { api } from "~/trpc/react";
function AnalysisPageContent() {
const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string =
typeof params.trialId === "string" ? params.trialId : "";
const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string =
typeof params.trialId === "string" ? params.trialId : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Get trial data
const {
data: trial,
isLoading,
error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Get trial data
const {
data: trial,
isLoading,
error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` },
{
label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`,
},
{ label: "Analysis" },
]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` },
{
label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`,
},
{ label: "Analysis" },
]);
// Sync selected study (unified study-context)
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
// Sync selected study (unified study-context)
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
media: trial.media?.map(m => ({
...m,
mediaType: m.mediaType ?? "video",
format: m.format ?? undefined,
contentType: m.contentType ?? undefined
})) ?? [],
};
if (isLoading) {
return (
<TrialAnalysisView
trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
}
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
media:
trial.media?.map((m) => ({
...m,
mediaType: m.mediaType ?? "video",
format: m.format ?? undefined,
contentType: m.contentType ?? undefined,
})) ?? [],
};
return (
<TrialAnalysisView
trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
);
}
export default function TrialAnalysisPage() {
return (
<Suspense
fallback={
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
}
>
<AnalysisPageContent />
</Suspense>
);
return (
<Suspense
fallback={
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
}
>
<AnalysisPageContent />
</Suspense>
);
}

View File

@@ -3,7 +3,14 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
import {
Play,
Zap,
ArrowLeft,
User,
FlaskConical,
LineChart,
} from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -144,7 +151,7 @@ function TrialDetailContent() {
{
label: trial.status.replace("_", " ").toUpperCase(),
variant: getStatusBadgeVariant(trial.status),
}
},
]}
actions={
<div className="flex gap-2">
@@ -156,13 +163,13 @@ function TrialDetailContent() {
)}
{(trial.status === "in_progress" ||
trial.status === "scheduled") && (
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</Button>
)}
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>

View File

@@ -211,11 +211,7 @@ function WizardPageContent() {
}
};
return (
<div>
{renderView()}
</div>
);
return <div>{renderView()}</div>;
}
export default function TrialWizardPage() {