mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.
This commit is contained in:
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function AnalyticsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study analytics
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/analytics`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
||||
<AlertCircle className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Analytics Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Analytics are now organized by study for better data insights.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To view analytics, please:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's analytics page</li>
|
||||
<li>• Get study-specific insights and data</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ExperimentForm } from "~/components/experiments/ExperimentForm";
|
||||
|
||||
interface EditExperimentPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditExperimentPage({
|
||||
params,
|
||||
}: EditExperimentPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <ExperimentForm mode="edit" experimentId={id} />;
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{ id: 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,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
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;
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
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 } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.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]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Unknown 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();
|
||||
|
||||
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");
|
||||
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title={displayName}
|
||||
subtitle={description ?? undefined}
|
||||
icon="TestTube"
|
||||
status={{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "TestTube",
|
||||
}}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : 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={`/experiments/${experiment.id}/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={`/experiments/${experiment.id}/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/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
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,
|
||||
href: `/experiments/${experiment.id}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit" as const,
|
||||
href: `/experiments/${experiment.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/experiments/${experiment.id}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { FlaskConical, ArrowRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function ExperimentsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study experiments
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/experiments`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
||||
<FlaskConical className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Experiments Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Experiment management is now organized by study for better
|
||||
workflow organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To manage experiments:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's experiments page</li>
|
||||
<li>• Create and manage experiment protocols for that specific study</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Users, ArrowRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function ParticipantsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study participants
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/participants`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||
<Users className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Participants Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Participant management is now organized by study for better
|
||||
organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To manage participants:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's participants page</li>
|
||||
<li>• Add and manage participants for that specific study</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Store } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function PluginBrowseRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study plugin browse
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/plugins/browse`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
|
||||
<Store className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Plugin Store Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Plugin browsing is now organized by study for better robot
|
||||
capability management.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To browse and install plugins:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's plugin store</li>
|
||||
<li>
|
||||
• Browse and install robot capabilities for that specific study
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Puzzle, ArrowRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function PluginsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study plugins
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/plugins`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
|
||||
<Puzzle className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Plugins Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Plugin management is now organized by study for better robot
|
||||
capability management.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To manage plugins:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's plugins page</li>
|
||||
<li>
|
||||
• Install and configure robot capabilities for that specific
|
||||
study
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type Experiment } from "~/lib/experiments/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2, {
|
||||
message: "Name must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
status: z.enum([
|
||||
"draft",
|
||||
"ready",
|
||||
"data_collection",
|
||||
"analysis",
|
||||
"completed",
|
||||
"archived",
|
||||
]),
|
||||
});
|
||||
|
||||
interface ExperimentFormProps {
|
||||
experiment: Experiment;
|
||||
}
|
||||
|
||||
export function ExperimentForm({ experiment }: ExperimentFormProps) {
|
||||
const router = useRouter();
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment updated successfully");
|
||||
router.refresh();
|
||||
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error updating experiment: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
status: experiment.status,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateExperiment.mutate({
|
||||
id: experiment.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
status: values.status,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Experiment name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of your experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your experiment..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A short description of the experiment goals.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="ready">Ready</SelectItem>
|
||||
<SelectItem value="data_collection">Data Collection</SelectItem>
|
||||
<SelectItem value="analysis">Analysis</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The current status of the experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" disabled={updateExperiment.isPending}>
|
||||
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { type Experiment } from "~/lib/experiments/types";
|
||||
import { api } from "~/trpc/server";
|
||||
import { ExperimentForm } from "./experiment-form";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
interface ExperimentEditPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
}
|
||||
|
||||
export default async function ExperimentEditPage({
|
||||
params,
|
||||
}: ExperimentEditPageProps) {
|
||||
const { id: studyId, experimentId } = await params;
|
||||
|
||||
const experiment = await api.experiments.get({ id: experimentId });
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure experiment belongs to study
|
||||
if (experiment.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Convert to type expected by form
|
||||
const experimentData: Experiment = {
|
||||
...experiment,
|
||||
status: experiment.status as Experiment["status"],
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title="Edit Experiment"
|
||||
subtitle={`Update settings for ${experiment.name}`}
|
||||
icon="Edit"
|
||||
backButton={
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-2">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<EntityViewSection title="Experiment Details" icon="Settings">
|
||||
<ExperimentForm experiment={experimentData} />
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
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;
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
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();
|
||||
|
||||
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 trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.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]);
|
||||
|
||||
// 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();
|
||||
|
||||
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");
|
||||
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title={displayName}
|
||||
subtitle={description ?? undefined}
|
||||
icon="TestTube"
|
||||
status={{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "TestTube",
|
||||
}}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
</>
|
||||
) : 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,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ParticipantForm } from "~/components/participants/ParticipantForm";
|
||||
import { api } from "~/trpc/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface EditParticipantPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditParticipantPage({
|
||||
params,
|
||||
}: EditParticipantPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { ParticipantDocuments } from "./participant-documents";
|
||||
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";
|
||||
import { Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
|
||||
export default async function ParticipantDetailPage({
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title={participant.participantCode}
|
||||
subtitle={participant.name ?? "Unnamed Participant"}
|
||||
icon="Users"
|
||||
badge={
|
||||
<Badge variant={participant.consentGiven ? "default" : "secondary"}>
|
||||
{participant.consentGiven ? "Consent Given" : "No Consent"}
|
||||
</Badge>
|
||||
}
|
||||
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 gap-6 grid-cols-1">
|
||||
<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 block mb-1">Name</span>
|
||||
<span className="font-medium text-base">{participant.name || "-"}</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 block mb-1">Added</span>
|
||||
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
} from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatBytes } from "~/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ParticipantDocumentsProps {
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
|
||||
// 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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { LineChart, ArrowLeft } from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
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 { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// 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" },
|
||||
]);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 trialData = {
|
||||
...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,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial Details
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user