mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Consolidate global routes into study-scoped architecture
Removed global participants, trials, and analytics routes. All entity management now flows through study-specific routes. Updated navigation, breadcrumbs, and forms. Added helpful redirect pages for moved routes. Eliminated duplicate table components and unified navigation patterns. Fixed dashboard route structure and layout inheritance.
This commit is contained in:
@@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Download,
|
||||
Filter,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StudyGuard } from "~/components/dashboard/study-guard";
|
||||
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,
|
||||
@@ -19,290 +12,53 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
// Mock chart component - replace with actual charting library
|
||||
function MockChart({ title, data }: { title: string; data: number[] }) {
|
||||
const maxValue = Math.max(...data);
|
||||
export default function AnalyticsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{title}</h4>
|
||||
<div className="flex h-32 items-end space-x-1">
|
||||
{data.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-primary min-h-[4px] flex-1 rounded-t"
|
||||
style={{ height: `${(value / maxValue) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsOverview() {
|
||||
const metrics = [
|
||||
{
|
||||
title: "Total Trials This Month",
|
||||
value: "142",
|
||||
change: "+12%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
title: "Avg Trial Duration",
|
||||
value: "24.5m",
|
||||
change: "-3%",
|
||||
trend: "down",
|
||||
description: "vs last month",
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
title: "Completion Rate",
|
||||
value: "94.2%",
|
||||
change: "+2.1%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: "Participant Retention",
|
||||
value: "87.3%",
|
||||
change: "+5.4%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: BarChart3,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<Card key={metric.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{metric.title}
|
||||
</CardTitle>
|
||||
<metric.icon className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metric.value}</div>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
|
||||
<span
|
||||
className={`flex items-center ${
|
||||
metric.trend === "up" ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{metric.trend === "up" ? (
|
||||
<TrendingUp className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{metric.change}
|
||||
</span>
|
||||
<span>{metric.description}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartsSection() {
|
||||
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
|
||||
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
|
||||
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trial Volume</CardTitle>
|
||||
<CardDescription>Monthly trial execution trends</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="Trials per Month" data={trialData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participant Enrollment</CardTitle>
|
||||
<CardDescription>New participants over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="New Participants" data={participantData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Rates</CardTitle>
|
||||
<CardDescription>Trial completion percentage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="Completion %" data={completionData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInsights() {
|
||||
const insights = [
|
||||
{
|
||||
title: "Peak Performance Hours",
|
||||
description:
|
||||
"Participants show 23% better performance during 10-11 AM trials",
|
||||
type: "trend",
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
title: "Attention Span Decline",
|
||||
description:
|
||||
"Average attention span has decreased by 8% over the last month",
|
||||
type: "alert",
|
||||
severity: "warning",
|
||||
},
|
||||
{
|
||||
title: "High Completion Rate",
|
||||
description: "Memory retention study achieved 98% completion rate",
|
||||
type: "success",
|
||||
severity: "success",
|
||||
},
|
||||
{
|
||||
title: "Equipment Utilization",
|
||||
description: "Robot interaction trials are at 85% capacity utilization",
|
||||
type: "info",
|
||||
severity: "info",
|
||||
},
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "success":
|
||||
return "bg-green-50 text-green-700 border-green-200";
|
||||
case "warning":
|
||||
return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
case "info":
|
||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-gray-50 text-gray-700 border-gray-200";
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study analytics
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/analytics`);
|
||||
}
|
||||
};
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Insights</CardTitle>
|
||||
<CardDescription>
|
||||
AI-generated insights from your research data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
|
||||
>
|
||||
<h4 className="mb-1 font-medium">{insight.title}</h4>
|
||||
<p className="text-sm">{insight.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsContent() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Insights and data analysis for your research
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select defaultValue="30d">
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="90d">Last 90 days</SelectItem>
|
||||
<SelectItem value="1y">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Metrics */}
|
||||
<AnalyticsOverview />
|
||||
|
||||
{/* Charts */}
|
||||
<ChartsSection />
|
||||
|
||||
{/* Insights */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<RecentInsights />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Generate custom reports</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Trial Performance Report
|
||||
<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 variant="outline" className="w-full justify-start">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Participant Engagement
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<TrendingUp className="mr-2 h-4 w-4" />
|
||||
Trend Analysis
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Custom Export
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<StudyGuard>
|
||||
<AnalyticsContent />
|
||||
</StudyGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BarChart3,
|
||||
Building,
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Dashboard Overview Cards
|
||||
function OverviewCards() {
|
||||
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Active Studies",
|
||||
value: stats?.totalStudies ?? 0,
|
||||
description: "Research studies you have access to",
|
||||
icon: Building,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
value: stats?.totalExperiments ?? 0,
|
||||
description: "Experiment protocols designed",
|
||||
icon: FlaskConical,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
},
|
||||
{
|
||||
title: "Participants",
|
||||
value: stats?.totalParticipants ?? 0,
|
||||
description: "Enrolled participants",
|
||||
icon: Users,
|
||||
color: "text-purple-600",
|
||||
bg: "bg-purple-50",
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
value: stats?.totalTrials ?? 0,
|
||||
description: "Total trials conducted",
|
||||
icon: TestTube,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
|
||||
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
|
||||
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<div className={`rounded-md p-2 ${card.bg}`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<p className="text-muted-foreground text-xs">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recent Activity Component
|
||||
function RecentActivity() {
|
||||
const { data: activities = [], isLoading } =
|
||||
api.dashboard.getRecentActivity.useQuery({
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
case "pending":
|
||||
return <Clock className="h-4 w-4 text-yellow-600" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Latest updates from your research platform
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No recent activity
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center space-x-4">
|
||||
{getStatusIcon(activity.status)}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick Actions Component
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Start New Trial",
|
||||
description: "Begin a new experimental trial",
|
||||
href: "/dashboard/trials/new",
|
||||
icon: TestTube,
|
||||
color: "bg-blue-500 hover:bg-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Add Participant",
|
||||
description: "Enroll a new participant",
|
||||
href: "/dashboard/participants/new",
|
||||
icon: Users,
|
||||
color: "bg-green-500 hover:bg-green-600",
|
||||
},
|
||||
{
|
||||
title: "Create Experiment",
|
||||
description: "Design new experiment protocol",
|
||||
href: "/dashboard/experiments/new",
|
||||
icon: FlaskConical,
|
||||
color: "bg-purple-500 hover:bg-purple-600",
|
||||
},
|
||||
{
|
||||
title: "View Analytics",
|
||||
description: "Analyze research data",
|
||||
href: "/dashboard/analytics",
|
||||
icon: BarChart3,
|
||||
color: "bg-orange-500 hover:bg-orange-600",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{actions.map((action) => (
|
||||
<Card
|
||||
key={action.title}
|
||||
className="group cursor-pointer transition-all hover:shadow-md"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<Button asChild className={`w-full ${action.color} text-white`}>
|
||||
<Link href={action.href}>
|
||||
<action.icon className="mr-2 h-4 w-4" />
|
||||
{action.title}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{action.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Study Progress Component
|
||||
function StudyProgress() {
|
||||
const { data: studies = [], isLoading } =
|
||||
api.dashboard.getStudyProgress.useQuery({
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Current status of active research studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 w-full animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : studies.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Building className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No active studies found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Create a study to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{studies.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{study.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{study.participants}/{study.totalParticipants} completed
|
||||
trials
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
study.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{study.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{study.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to your HRI Studio research platform
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date().toLocaleDateString()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<OverviewCards />
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-4 lg:grid-cols-7">
|
||||
<StudyProgress />
|
||||
<div className="col-span-4 space-y-4">
|
||||
<RecentActivity />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ParticipantForm } from "~/components/participants/ParticipantForm";
|
||||
|
||||
interface EditParticipantPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditParticipantPage({
|
||||
params,
|
||||
}: EditParticipantPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <ParticipantForm mode="edit" participantId={id} />;
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Mail,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ParticipantDetailPage({
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [participant, setParticipant] = useState<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
participantCode: string;
|
||||
study: { id: string; name: string } | null;
|
||||
demographics: unknown;
|
||||
notes: string | null;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
studyId: string;
|
||||
trials: unknown[];
|
||||
consents: unknown[];
|
||||
} | null>(null);
|
||||
const [trials, setTrials] = useState<
|
||||
{
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
experiment: { name: string } | null;
|
||||
}[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: participantData } = api.participants.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: trialsData } = api.trials.list.useQuery(
|
||||
{ participantId: resolvedParams?.id ?? "", limit: 10 },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (participantData) {
|
||||
setParticipant(participantData);
|
||||
}
|
||||
if (trialsData) {
|
||||
setTrials(trialsData);
|
||||
}
|
||||
if (participantData !== undefined) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [participantData, trialsData]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Participants", href: "/participants" },
|
||||
{
|
||||
label: participant?.name ?? participant?.participantCode ?? "Participant",
|
||||
},
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (loading || !participant) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={participant.name ?? participant.participantCode}
|
||||
subtitle={
|
||||
participant.name
|
||||
? `Code: ${participant.participantCode}`
|
||||
: "Participant"
|
||||
}
|
||||
icon="Users"
|
||||
actions={
|
||||
canEdit && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/participants/${resolvedParams?.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Participant Information */}
|
||||
<EntityViewSection title="Participant Information" icon="FileText">
|
||||
<InfoGrid
|
||||
items={[
|
||||
{
|
||||
label: "Participant Code",
|
||||
value: (
|
||||
<code className="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{participant.participantCode}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
value: participant?.name ?? "Not provided",
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: participant?.email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a
|
||||
href={`mailto:${participant.email}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{participant.email}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
"Not provided"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
value: participant?.study ? (
|
||||
<Link
|
||||
href={`/studies/${participant.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{participant.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Demographics */}
|
||||
{participant?.demographics &&
|
||||
typeof participant.demographics === "object" &&
|
||||
participant.demographics !== null &&
|
||||
Object.keys(participant.demographics as Record<string, unknown>)
|
||||
.length > 0 ? (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
|
||||
Demographics
|
||||
</h4>
|
||||
<InfoGrid
|
||||
items={(() => {
|
||||
const demo = participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const items: Array<{ label: string; value: string }> = [];
|
||||
|
||||
if (demo.age) {
|
||||
items.push({
|
||||
label: "Age",
|
||||
value:
|
||||
typeof demo.age === "number"
|
||||
? demo.age.toString()
|
||||
: typeof demo.age === "string"
|
||||
? demo.age
|
||||
: "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
if (demo.gender) {
|
||||
items.push({
|
||||
label: "Gender",
|
||||
value:
|
||||
typeof demo.gender === "string"
|
||||
? demo.gender
|
||||
: "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Notes */}
|
||||
{participant?.notes && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Notes
|
||||
</h4>
|
||||
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
|
||||
{participant.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Trial History */}
|
||||
<EntityViewSection
|
||||
title="Trial History"
|
||||
icon="Play"
|
||||
description="Experimental sessions for this participant"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button size="sm" asChild>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams?.id}`}
|
||||
>
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.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={`/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{trial.experiment?.name ?? "Trial"}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.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" />
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span>{Math.round(trial.duration / 60)} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No Trials Yet"
|
||||
description="This participant hasn't been assigned to any trials."
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams?.id}`}
|
||||
>
|
||||
Schedule First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Consent Status */}
|
||||
<EntityViewSection title="Consent Status" icon="Shield">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Informed Consent</span>
|
||||
<Badge
|
||||
variant={
|
||||
participant?.consentGiven ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{participant?.consentGiven ? (
|
||||
<>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Given
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Not Given
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{participant?.consentDate && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Consented:{" "}
|
||||
{formatDistanceToNow(new Date(participant.consentDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!participant.consentGiven && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Consent required before trials can be conducted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Registration Details */}
|
||||
<EntityViewSection title="Registration Details" icon="Calendar">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Registered",
|
||||
value: formatDistanceToNow(participant?.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
...(participant.updatedAt &&
|
||||
participant.updatedAt !== participant.createdAt
|
||||
? [
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(participant.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{canEdit && (
|
||||
<EntityViewSection title="Quick Actions" icon="Edit">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Schedule Trial",
|
||||
icon: "Play",
|
||||
href: `/trials/new?participantId=${resolvedParams?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Edit Information",
|
||||
icon: "Edit",
|
||||
href: `/participants/${resolvedParams?.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "FileText",
|
||||
href: `/participants/${resolvedParams?.id}/export`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ParticipantForm } from "~/components/participants/ParticipantForm";
|
||||
|
||||
export default function NewParticipantPage() {
|
||||
return <ParticipantForm mode="create" />;
|
||||
}
|
||||
@@ -1,10 +1,65 @@
|
||||
import { ParticipantsDataTable } from "~/components/participants/participants-data-table";
|
||||
import { StudyGuard } from "~/components/dashboard/study-guard";
|
||||
"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]);
|
||||
|
||||
export default function ParticipantsPage() {
|
||||
return (
|
||||
<StudyGuard>
|
||||
<ParticipantsDataTable />
|
||||
</StudyGuard>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,14 +149,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const trials = trialsData ?? [];
|
||||
const activities = activityData?.activities ?? [];
|
||||
|
||||
const completedTrials = trials.filter((trial: { status: string }) => trial.status === "completed").length;
|
||||
const completedTrials = trials.filter(
|
||||
(trial: { status: string }) => trial.status === "completed",
|
||||
).length;
|
||||
const totalTrials = trials.length;
|
||||
|
||||
const stats = {
|
||||
experiments: experiments.length,
|
||||
totalTrials: totalTrials,
|
||||
participants: participants.length,
|
||||
completionRate: totalTrials > 0 ? `${Math.round((completedTrials / totalTrials) * 100)}%` : "—",
|
||||
completionRate:
|
||||
totalTrials > 0
|
||||
? `${Math.round((completedTrials / totalTrials) * 100)}%`
|
||||
: "—",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -269,26 +274,27 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
experiment.status === "draft"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: experiment.status === "ready"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{experiment.status}
|
||||
</span>
|
||||
</div>
|
||||
{experiment.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{experiment.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center space-x-4 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-2 flex items-center space-x-4 text-xs">
|
||||
<span>
|
||||
Created {formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
|
||||
Created{" "}
|
||||
{formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{experiment.estimatedDuration && (
|
||||
<span>
|
||||
Est. {experiment.estimatedDuration} min
|
||||
</span>
|
||||
<span>Est. {experiment.estimatedDuration} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,9 +305,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/experiments/${experiment.id}`}>
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/experiments/${experiment.id}`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,19 +331,25 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{activity.user?.name?.charAt(0) ?? activity.user?.email?.charAt(0) ?? "?"}
|
||||
{activity.user?.name?.charAt(0) ??
|
||||
activity.user?.email?.charAt(0) ??
|
||||
"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">
|
||||
{activity.user?.name ?? activity.user?.email ?? "Unknown User"}
|
||||
{activity.user?.name ??
|
||||
activity.user?.email ??
|
||||
"Unknown User"}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(activity.createdAt, { addSuffix: true })}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(activity.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -347,7 +357,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
))}
|
||||
{activityData && activityData.pagination.total > 5 && (
|
||||
<div className="pt-2">
|
||||
<Button asChild variant="outline" size="sm" className="w-full">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<Link href={`/studies/${study.id}/activity`}>
|
||||
View All Activity ({activityData.pagination.total})
|
||||
</Link>
|
||||
@@ -434,17 +449,17 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
{
|
||||
label: "Manage Participants",
|
||||
icon: "Users",
|
||||
href: `/participants?studyId=${study.id}`,
|
||||
href: `/studies/${study.id}/participants`,
|
||||
},
|
||||
{
|
||||
label: "Schedule Trials",
|
||||
icon: "Calendar",
|
||||
href: `/trials?studyId=${study.id}`,
|
||||
href: `/studies/${study.id}/trials`,
|
||||
},
|
||||
{
|
||||
label: "View Analytics",
|
||||
icon: "BarChart3",
|
||||
href: `/analytics?studyId=${study.id}`,
|
||||
href: `/studies/${study.id}/analytics`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Camera,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Share,
|
||||
Target,
|
||||
Timer,
|
||||
TrendingUp,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface AnalysisPageProps {
|
||||
params: Promise<{
|
||||
trialId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function AnalysisPage({ params }: AnalysisPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const { trialId } = await params;
|
||||
let trial;
|
||||
try {
|
||||
trial = await api.trials.get({ id: trialId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Only allow analysis view for completed trials
|
||||
if (trial.status !== "completed") {
|
||||
redirect(`/trials/${trialId}?error=trial_not_completed`);
|
||||
}
|
||||
|
||||
// Calculate trial metrics
|
||||
const duration =
|
||||
trial.startedAt && trial.completedAt
|
||||
? Math.floor(
|
||||
(new Date(trial.completedAt).getTime() -
|
||||
new Date(trial.startedAt).getTime()) /
|
||||
1000 /
|
||||
60,
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Mock experiment steps - in real implementation, fetch from experiment API
|
||||
const experimentSteps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
}> = [];
|
||||
|
||||
// Mock analysis data - in real implementation, this would come from API
|
||||
const analysisData = {
|
||||
totalEvents: 45,
|
||||
wizardInterventions: 3,
|
||||
robotActions: 12,
|
||||
mediaCaptures: 8,
|
||||
annotations: 15,
|
||||
participantResponses: 22,
|
||||
averageResponseTime: 2.3,
|
||||
completionRate: 100,
|
||||
successRate: 95,
|
||||
errorCount: 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title="Trial Analysis"
|
||||
subtitle={`${trial.experiment.name} • Participant: ${trial.participant.participantCode}`}
|
||||
icon="BarChart3"
|
||||
status={{
|
||||
label: "Completed",
|
||||
variant: "default",
|
||||
icon: "CheckCircle",
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Data
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
Share Results
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Trial Summary Stats */}
|
||||
<EntityViewSection title="Trial Summary" icon="Target">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="bg-card rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Timer className="h-4 w-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Duration</p>
|
||||
<p className="text-lg font-semibold">{duration} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Target className="h-4 w-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Completion Rate
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{analysisData.completionRate}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Total Events</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{analysisData.totalEvents}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendingUp className="h-4 w-4 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Success Rate</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{analysisData.successRate}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Main Analysis Content */}
|
||||
<EntityViewSection title="Detailed Analysis" icon="Activity">
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="timeline">Timeline</TabsTrigger>
|
||||
<TabsTrigger value="interactions">Interactions</TabsTrigger>
|
||||
<TabsTrigger value="media">Media</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Performance Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Performance Metrics</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>Task Completion</span>
|
||||
<span>{analysisData.completionRate}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={analysisData.completionRate}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>Success Rate</span>
|
||||
<span>{analysisData.successRate}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={analysisData.successRate}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>Response Time (avg)</span>
|
||||
<span>{analysisData.averageResponseTime}s</span>
|
||||
</div>
|
||||
<Progress value={75} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{experimentSteps.length}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">
|
||||
Steps Completed
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-red-600">
|
||||
{analysisData.errorCount}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
<span>Event Breakdown</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm">Robot Actions</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{analysisData.robotActions}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm">Wizard Interventions</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{analysisData.wizardInterventions}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageSquare className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm">Participant Responses</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{analysisData.participantResponses}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Camera className="h-4 w-4 text-indigo-600" />
|
||||
<span className="text-sm">Media Captures</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{analysisData.mediaCaptures}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-sm">Annotations</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{analysisData.annotations}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Trial Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>Trial Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600">
|
||||
Started
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{trial.startedAt
|
||||
? format(trial.startedAt, "PPP 'at' p")
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600">
|
||||
Completed
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{trial.completedAt
|
||||
? format(trial.completedAt, "PPP 'at' p")
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600">
|
||||
Participant
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{trial.participant.participantCode}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600">
|
||||
Wizard
|
||||
</label>
|
||||
<p className="text-sm">N/A</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span>Event Timeline</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-12 text-center text-slate-500">
|
||||
<Clock className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<h3 className="mb-2 text-lg font-medium">
|
||||
Timeline Analysis
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Detailed timeline visualization and event analysis will be
|
||||
available here. This would show the sequence of all trial
|
||||
events with timestamps.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="interactions" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span>Interaction Analysis</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-12 text-center text-slate-500">
|
||||
<MessageSquare className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<h3 className="mb-2 text-lg font-medium">
|
||||
Interaction Patterns
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Analysis of participant-robot interactions, communication
|
||||
patterns, and behavioral observations will be displayed
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="media" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Camera className="h-5 w-5" />
|
||||
<span>Media Recordings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-12 text-center text-slate-500">
|
||||
<Camera className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<h3 className="mb-2 text-lg font-medium">Media Gallery</h3>
|
||||
<p className="text-sm">
|
||||
Video recordings, audio captures, and sensor data
|
||||
visualizations from the trial will be available for review
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Download className="h-5 w-5" />
|
||||
<span>Export Data</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Export trial data in various formats for further analysis or
|
||||
reporting.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<FileText className="mt-0.5 h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Trial Report (PDF)</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Complete analysis report with visualizations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<BarChart3 className="mt-0.5 h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Raw Data (CSV)</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Event data, timestamps, and measurements
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Camera className="mt-0.5 h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Media Archive (ZIP)</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
All video, audio, and sensor recordings
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<MessageSquare className="mt-0.5 h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Annotations (JSON)</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Researcher notes and coded observations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate metadata for the page
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: AnalysisPageProps): Promise<{ title: string; description: string }> {
|
||||
try {
|
||||
const { trialId } = await params;
|
||||
const trial = await api.trials.get({ id: trialId });
|
||||
return {
|
||||
title: `Analysis - ${trial.experiment.name} | HRIStudio`,
|
||||
description: `Analysis dashboard for trial with participant ${trial.participant.participantCode}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Trial Analysis | HRIStudio",
|
||||
description: "Analyze trial data and participant interactions",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { TrialForm } from "~/components/trials/TrialForm";
|
||||
|
||||
interface EditTrialPageProps {
|
||||
params: Promise<{
|
||||
trialId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditTrialPage({ params }: EditTrialPageProps) {
|
||||
const { trialId } = await params;
|
||||
|
||||
return <TrialForm mode="edit" trialId={trialId} />;
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Eye, Info, Play, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
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";
|
||||
|
||||
interface TrialDetailPageProps {
|
||||
params: Promise<{ trialId: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
variant: "outline" as const,
|
||||
icon: "Clock" as const,
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
variant: "secondary" as const,
|
||||
icon: "Play" as const,
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertCircle" as const,
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
variant: "outline" as const,
|
||||
icon: "AlertCircle" as const,
|
||||
},
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
id: string;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
wizardId?: string | null;
|
||||
sessionNumber?: number;
|
||||
status: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
studyId: string;
|
||||
} | null;
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type TrialEvent = {
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
actionId: string | null;
|
||||
timestamp: Date;
|
||||
data: unknown;
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
export default function TrialDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: TrialDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const startTrialMutation = api.trials.start.useMutation();
|
||||
const [trial, setTrial] = useState<Trial | null>(null);
|
||||
const [events, setEvents] = useState<TrialEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{
|
||||
trialId: string;
|
||||
} | null>(null);
|
||||
const [resolvedSearchParams, setResolvedSearchParams] = useState<{
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveSearchParams = async () => {
|
||||
const resolved = await searchParams;
|
||||
setResolvedSearchParams(resolved);
|
||||
};
|
||||
void resolveSearchParams();
|
||||
}, [searchParams]);
|
||||
|
||||
const trialQuery = api.trials.get.useQuery(
|
||||
{ id: resolvedParams?.trialId ?? "" },
|
||||
{ enabled: !!resolvedParams?.trialId },
|
||||
);
|
||||
|
||||
const eventsQuery = api.trials.getEvents.useQuery(
|
||||
{ trialId: resolvedParams?.trialId ?? "" },
|
||||
{ enabled: !!resolvedParams?.trialId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialQuery.data) {
|
||||
setTrial(trialQuery.data as Trial);
|
||||
}
|
||||
}, [trialQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventsQuery.data) {
|
||||
setEvents(eventsQuery.data as TrialEvent[]);
|
||||
}
|
||||
}, [eventsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialQuery.isLoading || eventsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [trialQuery.isLoading, eventsQuery.isLoading]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
href: trial?.experiment?.studyId
|
||||
? `/studies/${trial.experiment.studyId}`
|
||||
: "/studies",
|
||||
},
|
||||
{
|
||||
label: "Trials",
|
||||
href: trial?.experiment?.studyId
|
||||
? `/studies/${trial.experiment.studyId}/trials`
|
||||
: "/trials",
|
||||
},
|
||||
{
|
||||
label: `Trial #${resolvedParams?.trialId?.slice(-6) ?? "Unknown"}`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (trialQuery.error || !trial) return <div>Trial not found</div>;
|
||||
|
||||
const statusInfo = statusConfig[trial.status as keyof typeof statusConfig];
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canControl =
|
||||
userRoles.includes("wizard") || userRoles.includes("researcher");
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
if (!trial) return;
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
router.push(`/trials/${trial.id}/wizard`);
|
||||
};
|
||||
|
||||
const displayName = `Trial #${trial.id.slice(-6)}`;
|
||||
const experimentName = trial.experiment?.name ?? "Unknown Experiment";
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{resolvedSearchParams?.error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{resolvedSearchParams.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<EntityViewHeader
|
||||
title={displayName}
|
||||
subtitle={`${experimentName} - ${trial.participant?.participantCode ?? "Unknown Participant"}`}
|
||||
icon="Play"
|
||||
status={
|
||||
statusInfo && {
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
}
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{canControl && trial.status === "scheduled" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
disabled={startTrialMutation.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{startTrialMutation.isPending ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/trials/${trial.id}/start`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Preflight
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canControl && trial.status === "in_progress" && (
|
||||
<Button asChild variant="secondary">
|
||||
<Link href={`/trials/${trial.id}/wizard`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Monitor
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/trials/${trial.id}/analysis`}>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Trial Information */}
|
||||
<EntityViewSection title="Trial Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Experiment",
|
||||
value: trial.experiment ? (
|
||||
<Link
|
||||
href={`/experiments/${trial.experiment.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{trial.experiment.name}
|
||||
</Link>
|
||||
) : (
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Participant",
|
||||
value: trial.participant ? (
|
||||
<Link
|
||||
href={`/participants/${trial.participant.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</Link>
|
||||
) : (
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
value: trial.experiment?.studyId ? (
|
||||
<Link
|
||||
href={`/studies/${trial.experiment.studyId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Study
|
||||
</Link>
|
||||
) : (
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? trial.status,
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
value: trial.createdAt
|
||||
? formatDistanceToNow(trial.createdAt, { addSuffix: true })
|
||||
: "Not scheduled",
|
||||
},
|
||||
{
|
||||
label: "Duration",
|
||||
value: trial.duration
|
||||
? `${Math.round(trial.duration / 60)} minutes`
|
||||
: trial.status === "in_progress"
|
||||
? "Ongoing"
|
||||
: "Not available",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Trial Notes */}
|
||||
{trial.notes && (
|
||||
<EntityViewSection title="Notes" icon="FileText">
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<p className="text-muted-foreground">{trial.notes}</p>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Event Timeline */}
|
||||
<EntityViewSection
|
||||
title="Event Timeline"
|
||||
icon="Activity"
|
||||
description={`${events.length} events recorded`}
|
||||
>
|
||||
{events.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{events.slice(0, 10).map((event) => (
|
||||
<div key={event.id} className="rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{event.eventType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(event.timestamp, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{event.data ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<pre className="text-xs">
|
||||
{typeof event.data === "object" && event.data !== null
|
||||
? JSON.stringify(event.data, null, 2)
|
||||
: String(event.data as string | number | boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{events.length > 10 && (
|
||||
<div className="text-center">
|
||||
<Button variant="outline" size="sm">
|
||||
View All Events ({events.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Activity"
|
||||
title="No events recorded"
|
||||
description="Events will appear here as the trial progresses"
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Events",
|
||||
value: events.length,
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Started",
|
||||
value: trial.startedAt
|
||||
? formatDistanceToNow(trial.startedAt, { addSuffix: true })
|
||||
: "Not started",
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trial.completedAt
|
||||
? formatDistanceToNow(trial.completedAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not completed",
|
||||
},
|
||||
{
|
||||
label: "Created By",
|
||||
value: "System",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
...(canControl && trial.status === "scheduled"
|
||||
? [
|
||||
{
|
||||
label: "Start Trial",
|
||||
icon: "Play" as const,
|
||||
href: `/trials/${trial.id}/wizard`,
|
||||
variant: "default" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canControl && trial.status === "in_progress"
|
||||
? [
|
||||
{
|
||||
label: "Monitor Trial",
|
||||
icon: "Eye" as const,
|
||||
href: `/trials/${trial.id}/wizard`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(trial.status === "completed"
|
||||
? [
|
||||
{
|
||||
label: "View Analysis",
|
||||
icon: "BarChart" as const,
|
||||
href: `/trials/${trial.id}/analysis`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
href: `/trials/${trial.id}/export`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "View Events",
|
||||
icon: "Activity" as const,
|
||||
href: `/trials/${trial.id}/events`,
|
||||
},
|
||||
{
|
||||
label: "Export Report",
|
||||
icon: "FileText" as const,
|
||||
href: `/trials/${trial.id}/report`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Participant Info */}
|
||||
{trial.participant && (
|
||||
<EntityViewSection title="Participant" icon="User">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Code",
|
||||
value: trial.participant.participantCode,
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
value: trial.participant.name ?? "Not provided",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FlaskConical,
|
||||
Play,
|
||||
TestTube,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface StartPageProps {
|
||||
params: Promise<{
|
||||
trialId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function StartTrialPage({ params }: StartPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const role = session.user.roles?.[0]?.role ?? "observer";
|
||||
if (!["wizard", "researcher", "administrator"].includes(role)) {
|
||||
redirect("/trials?error=insufficient_permissions");
|
||||
}
|
||||
|
||||
const { trialId } = await params;
|
||||
|
||||
let trial: Awaited<ReturnType<typeof api.trials.get>>;
|
||||
try {
|
||||
trial = await api.trials.get({ id: trialId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Guard: Only allow start from scheduled; if in progress, go to wizard; if completed, go to analysis
|
||||
if (trial.status === "in_progress") {
|
||||
redirect(`/trials/${trialId}/wizard`);
|
||||
}
|
||||
if (trial.status === "completed") {
|
||||
redirect(`/trials/${trialId}/analysis`);
|
||||
}
|
||||
if (!["scheduled"].includes(trial.status)) {
|
||||
redirect(`/trials/${trialId}?error=trial_not_startable`);
|
||||
}
|
||||
|
||||
// Server Action: start trial and redirect to wizard
|
||||
async function startTrial() {
|
||||
"use server";
|
||||
// Confirm auth on action too
|
||||
const s = await auth();
|
||||
if (!s) redirect("/auth/signin");
|
||||
const r = s.user.roles?.[0]?.role ?? "observer";
|
||||
if (!["wizard", "researcher", "administrator"].includes(r)) {
|
||||
redirect(`/trials/${trialId}?error=insufficient_permissions`);
|
||||
}
|
||||
await api.trials.start({ id: trialId });
|
||||
redirect(`/trials/${trialId}/wizard`);
|
||||
}
|
||||
|
||||
const scheduled =
|
||||
trial.scheduledAt instanceof Date
|
||||
? trial.scheduledAt
|
||||
: trial.scheduledAt
|
||||
? new Date(trial.scheduledAt)
|
||||
: null;
|
||||
|
||||
const hasWizardAssigned = Boolean(trial.wizardId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="border-b border-slate-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial
|
||||
</Link>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Start Trial</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{trial.experiment.name} • Participant:{" "}
|
||||
{trial.participant.participantCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
Scheduled
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Experiment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
<FlaskConical className="h-4 w-4 text-slate-600" />
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{trial.experiment.name}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Participant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-slate-600" />
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{trial.participant.participantCode}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Scheduled
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-slate-600" />
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{scheduled
|
||||
? `${formatDistanceToNow(scheduled, { addSuffix: true })}`
|
||||
: "Not set"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Preflight Checks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TestTube className="h-4 w-4 text-slate-700" />
|
||||
Preflight Checklist
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">Permissions</div>
|
||||
<div className="text-slate-600">
|
||||
You have sufficient permissions to start this trial.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
|
||||
{hasWizardAssigned ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-amber-600" />
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">Wizard</div>
|
||||
<div className="text-slate-600">
|
||||
{hasWizardAssigned
|
||||
? "A wizard has been assigned to this trial."
|
||||
: "No wizard assigned. You can still start, but consider assigning a wizard for clarity."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">Status</div>
|
||||
<div className="text-slate-600">
|
||||
Trial is currently scheduled and ready to start.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button asChild variant="ghost">
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<form action={startTrial}>
|
||||
<Button type="submit" className="shadow-sm">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: StartPageProps): Promise<{ title: string; description: string }> {
|
||||
try {
|
||||
const { trialId } = await params;
|
||||
const trial = await api.trials.get({ id: trialId });
|
||||
return {
|
||||
title: `Start Trial - ${trial.experiment.name} | HRIStudio`,
|
||||
description: `Preflight and start trial for participant ${trial.participant.participantCode}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Start Trial | HRIStudio",
|
||||
description: "Preflight checklist to start an HRI trial",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { WizardInterface } from "~/components/trials/wizard/WizardInterface";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface WizardPageProps {
|
||||
params: Promise<{
|
||||
trialId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function WizardPage({ params }: WizardPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
// Check if user has wizard/researcher permissions
|
||||
const userRole = session.user.roles?.[0]?.role;
|
||||
if (
|
||||
!userRole ||
|
||||
!["wizard", "researcher", "administrator"].includes(userRole)
|
||||
) {
|
||||
redirect("/trials?error=insufficient_permissions");
|
||||
}
|
||||
|
||||
const { trialId } = await params;
|
||||
let trial;
|
||||
try {
|
||||
trial = await api.trials.get({ id: trialId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Only allow wizard interface for scheduled or in-progress trials
|
||||
if (!["scheduled", "in_progress"].includes(trial.status)) {
|
||||
redirect(`/trials/${trialId}?error=trial_not_active`);
|
||||
}
|
||||
|
||||
const normalizedTrial = {
|
||||
...trial,
|
||||
metadata:
|
||||
typeof trial.metadata === "object" && trial.metadata !== null
|
||||
? (trial.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
participant: {
|
||||
...trial.participant,
|
||||
demographics:
|
||||
typeof trial.participant.demographics === "object" &&
|
||||
trial.participant.demographics !== null
|
||||
? (trial.participant.demographics as Record<string, unknown>)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
return <WizardInterface trial={normalizedTrial} userRole={userRole} />;
|
||||
}
|
||||
|
||||
// Generate metadata for the page
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: WizardPageProps): Promise<{ title: string; description: string }> {
|
||||
try {
|
||||
const { trialId } = await params;
|
||||
const trial = await api.trials.get({ id: trialId });
|
||||
return {
|
||||
title: `Wizard Control - ${trial.experiment.name} | HRIStudio`,
|
||||
description: `Real-time wizard control interface for trial ${trial.participant.participantCode}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Wizard Control | HRIStudio",
|
||||
description: "Real-time wizard control interface for HRI trials",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TrialForm } from "~/components/trials/TrialForm";
|
||||
|
||||
export default function NewTrialPage() {
|
||||
return <TrialForm mode="create" />;
|
||||
}
|
||||
@@ -1,10 +1,65 @@
|
||||
import { TrialsDataTable } from "~/components/trials/trials-data-table";
|
||||
import { StudyGuard } from "~/components/dashboard/study-guard";
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TestTube, 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 TrialsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study trials
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/trials`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
export default function TrialsPage() {
|
||||
return (
|
||||
<StudyGuard>
|
||||
<TrialsDataTable />
|
||||
</StudyGuard>
|
||||
<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-orange-50">
|
||||
<TestTube className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Trials Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Trial 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 trials:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's trials page</li>
|
||||
<li>• Schedule and monitor trials 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ export function DashboardContent({
|
||||
...(canControl
|
||||
? [
|
||||
{
|
||||
title: "Schedule Trial",
|
||||
description: "Plan a new trial session",
|
||||
title: "Browse Studies",
|
||||
description: "View and manage studies",
|
||||
icon: Calendar,
|
||||
href: "/trials/new",
|
||||
href: "/studies",
|
||||
variant: "default" as const,
|
||||
},
|
||||
]
|
||||
@@ -84,8 +84,8 @@ export function DashboardContent({
|
||||
variant: "success" as const,
|
||||
...(canControl && {
|
||||
action: {
|
||||
label: "Control",
|
||||
href: "/trials?status=in_progress",
|
||||
label: "View",
|
||||
href: "/studies",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Users,
|
||||
UserCheck,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
@@ -72,16 +70,7 @@ const navigationItems = [
|
||||
url: "/experiments",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
title: "Participants",
|
||||
url: "/participants",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
url: "/trials",
|
||||
icon: TestTube,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "/plugins",
|
||||
|
||||
@@ -145,13 +145,6 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/trials`}>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
View Trials
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Experiment ID
|
||||
|
||||
@@ -126,19 +126,22 @@ export function ParticipantForm({
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Participants", href: "/participants" },
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
@@ -228,7 +231,7 @@ export function ParticipantForm({
|
||||
|
||||
try {
|
||||
await deleteParticipantMutation.mutateAsync({ id: participantId });
|
||||
router.push("/participants");
|
||||
router.push(`/studies/${contextStudyId}/participants`);
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to delete participant: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
@@ -483,8 +486,8 @@ export function ParticipantForm({
|
||||
mode={mode}
|
||||
entityName="Participant"
|
||||
entityNamePlural="Participants"
|
||||
backUrl="/participants"
|
||||
listUrl="/participants"
|
||||
backUrl={`/studies/${contextStudyId}/participants`}
|
||||
listUrl={`/studies/${contextStudyId}/participants`}
|
||||
title={
|
||||
mode === "create"
|
||||
? "Register New Participant"
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
createdAt: Date;
|
||||
trialCount: number;
|
||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
|
||||
function ParticipantActionsCell({ participant }: { participant: Participant }) {
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to delete participant "${participant.name ?? participant.participantCode}"?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement delete participant mutation
|
||||
toast.success("Participant deleted successfully");
|
||||
} catch {
|
||||
toast.error("Failed to delete participant");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(participant.id);
|
||||
toast.success("Participant ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
void navigator.clipboard.writeText(participant.participantCode);
|
||||
toast.success("Participant code copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{participant.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Participant ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyCode}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Participant Code
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!participant.consentGiven && (
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send Consent Form
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{participant.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Participant
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "participantCode",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Code" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/participants/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.getValue("participantCode")}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name;
|
||||
const email = row.original.email;
|
||||
return (
|
||||
<div className="max-w-[160px] space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className="truncate font-medium"
|
||||
title={name ?? "No name provided"}
|
||||
>
|
||||
{name ?? "No name provided"}
|
||||
</span>
|
||||
</div>
|
||||
{email && (
|
||||
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
|
||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate" title={email ?? ""}>
|
||||
{email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "consentGiven",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Consent" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const consentGiven = row.getValue("consentGiven");
|
||||
const consentDate = row.original.consentDate;
|
||||
|
||||
if (consentGiven) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 whitespace-nowrap text-green-800"
|
||||
title={
|
||||
consentDate
|
||||
? `Consented on ${consentDate.toLocaleDateString()}`
|
||||
: "Consented"
|
||||
}
|
||||
>
|
||||
Consented
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-100 whitespace-nowrap text-red-800"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
const consentGiven = row.getValue(id);
|
||||
if (value === "consented") return !!consentGiven;
|
||||
if (value === "pending") return !consentGiven;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trialCount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trials" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trialCount = row.original.trialCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-sm whitespace-nowrap">
|
||||
<TestTube className="text-muted-foreground h-3 w-3" />
|
||||
<span>{trialCount ?? 0}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <ParticipantActionsCell participant={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { participantsColumns, type Participant } from "./participants-columns";
|
||||
|
||||
export function ParticipantsDataTable() {
|
||||
const [consentFilter, setConsentFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: participantsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.participants.getUserParticipants.useQuery(
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh participants when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{ enabled: !!selectedStudyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Participants" },
|
||||
]
|
||||
: [{ label: "Participants" }]),
|
||||
]);
|
||||
|
||||
// Transform participants data to match the Participant type expected by columns
|
||||
const participants: Participant[] = React.useMemo(() => {
|
||||
if (!participantsData?.participants) return [];
|
||||
|
||||
return participantsData.participants.map((p) => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven:
|
||||
(p as unknown as { hasConsent?: boolean }).hasConsent ?? false,
|
||||
consentDate: (p as unknown as { latestConsent?: { signedAt: string } })
|
||||
.latestConsent?.signedAt
|
||||
? new Date(
|
||||
(
|
||||
p as unknown as { latestConsent: { signedAt: string } }
|
||||
).latestConsent.signedAt,
|
||||
)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: (p as unknown as { trialCount?: number }).trialCount ?? 0,
|
||||
userRole: undefined,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
}));
|
||||
}, [participantsData]);
|
||||
|
||||
// Consent filter options
|
||||
const consentOptions = [
|
||||
{ label: "All Participants", value: "all" },
|
||||
{ label: "Consented", value: "consented" },
|
||||
{ label: "Pending Consent", value: "pending" },
|
||||
];
|
||||
|
||||
// Filter participants based on selected filters
|
||||
const filteredParticipants = React.useMemo(() => {
|
||||
return participants.filter((participant) => {
|
||||
if (consentFilter === "all") return true;
|
||||
if (consentFilter === "consented") return participant.consentGiven;
|
||||
if (consentFilter === "pending") return !participant.consentGiven;
|
||||
return true;
|
||||
});
|
||||
}, [participants, consentFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={consentFilter} onValueChange={setConsentFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Consent Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{consentOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Participants"
|
||||
description="Manage participant registration, consent, and trial assignments"
|
||||
icon={Users}
|
||||
actions={
|
||||
<ActionButton href="/participants/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Participant
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Participants
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading participants."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Participants"
|
||||
description="Manage participant registration, consent, and trial assignments"
|
||||
icon={Users}
|
||||
actions={
|
||||
<ActionButton href="/participants/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Participant
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={participantsColumns}
|
||||
data={filteredParticipants}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search participants..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Trials", href: "/trials" },
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
@@ -426,8 +426,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
mode={mode}
|
||||
entityName="Trial"
|
||||
entityNamePlural="Trials"
|
||||
backUrl="/trials"
|
||||
listUrl="/trials"
|
||||
backUrl={`/studies/${contextStudyId}/trials`}
|
||||
listUrl={`/studies/${contextStudyId}/trials`}
|
||||
title={
|
||||
mode === "create"
|
||||
? "Schedule New Trial"
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
BarChart3,
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
StopCircle,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export type Trial = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
studyId: string;
|
||||
experimentId: string;
|
||||
participantId: string;
|
||||
wizardId: string | null;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
participantCode?: string;
|
||||
};
|
||||
wizard: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
duration?: number; // in minutes
|
||||
_count?: {
|
||||
actions: number;
|
||||
logs: number;
|
||||
};
|
||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||
canAccess?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canExecute?: boolean;
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
description: "Trial is scheduled for future execution",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
description: "Trial is currently running",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Trial has been completed successfully",
|
||||
},
|
||||
aborted: {
|
||||
label: "Aborted",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
description: "Trial was aborted before completion",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
description: "Trial failed due to an error",
|
||||
},
|
||||
};
|
||||
|
||||
function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const startTrialMutation = api.trials.start.useMutation();
|
||||
const completeTrialMutation = api.trials.complete.useMutation();
|
||||
const abortTrialMutation = api.trials.abort.useMutation();
|
||||
// const deleteTrialMutation = api.trials.delete.useMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// await deleteTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial deletion not yet implemented");
|
||||
// window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to delete trial");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(trial.id);
|
||||
toast.success("Trial ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial started successfully");
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
} catch {
|
||||
toast.error("Failed to start trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
// For now, pausing means completing the trial
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial paused/completed");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to pause trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTrial = async () => {
|
||||
if (window.confirm("Are you sure you want to stop this trial?")) {
|
||||
try {
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial stopped");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to stop trial");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = trial.status === "scheduled" && trial.canExecute;
|
||||
const canPause = trial.status === "in_progress" && trial.canExecute;
|
||||
const canStop = trial.status === "in_progress" && trial.canExecute;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{trial.canAccess ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details (Restricted)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{trial.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{canStart && (
|
||||
<DropdownMenuItem onClick={handleStartTrial}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canPause && (
|
||||
<DropdownMenuItem onClick={handlePauseTrial}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canStop && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleStopTrial}
|
||||
className="text-orange-600 focus:text-orange-600"
|
||||
>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
Stop Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/wizard`}>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/analysis`}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Trial ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
{trial.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Trial
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trial Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
return (
|
||||
<div className="max-w-[140px] min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.canAccess ? (
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
title={trial.name}
|
||||
>
|
||||
{trial.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className="text-muted-foreground block cursor-not-allowed truncate font-medium"
|
||||
title={`${trial.name} (View access restricted)`}
|
||||
>
|
||||
{trial.name}
|
||||
</div>
|
||||
)}
|
||||
{!trial.canAccess && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
|
||||
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
|
||||
>
|
||||
{trial.userRole === "observer" ? "View Only" : "Restricted"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const trial = row.original;
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${config.className} whitespace-nowrap`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{trial.userRole && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={`Your role in this study: ${trial.userRole}`}
|
||||
>
|
||||
{trial.userRole}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return value.includes(status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "participant",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Participant" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const participant = row.original.participant;
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
title={
|
||||
participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"
|
||||
}
|
||||
>
|
||||
{participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "experiment",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Experiment" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.original.experiment;
|
||||
return (
|
||||
<div className="flex max-w-[140px] items-center space-x-2">
|
||||
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/experiments/${experiment?.id ?? ""}`}
|
||||
className="truncate text-sm hover:underline"
|
||||
title={experiment?.name ?? "Unnamed Experiment"}
|
||||
>
|
||||
{experiment?.name ?? "Unnamed Experiment"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "wizard",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Wizard" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wizard = row.original.wizard;
|
||||
if (!wizard) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not assigned</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[120px] space-y-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={wizard.name ?? ""}
|
||||
>
|
||||
{wizard.name ?? ""}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={wizard.email ?? ""}
|
||||
>
|
||||
{wizard.email ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Scheduled" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
if (!date) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not scheduled</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
|
||||
if (
|
||||
trial.status === "completed" &&
|
||||
trial.startedAt &&
|
||||
trial.completedAt
|
||||
) {
|
||||
const duration = Math.round(
|
||||
(trial.completedAt.getTime() - trial.startedAt.getTime()) /
|
||||
(1000 * 60),
|
||||
);
|
||||
return <div className="text-sm whitespace-nowrap">{duration}m</div>;
|
||||
}
|
||||
|
||||
if (trial.status === "in_progress" && trial.startedAt) {
|
||||
const duration = Math.round(
|
||||
(Date.now() - trial.startedAt.getTime()) / (1000 * 60),
|
||||
);
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap text-blue-600">
|
||||
{duration}m
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trial.duration) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
~{trial.duration}m
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
header: "Data",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
const counts = trial._count;
|
||||
|
||||
return (
|
||||
<div className="flex space-x-3 text-sm">
|
||||
<div className="flex items-center space-x-1" title="Actions recorded">
|
||||
<TestTube className="text-muted-foreground h-3 w-3" />
|
||||
<span>{counts?.actions ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1" title="Log entries">
|
||||
<BarChart3 className="text-muted-foreground h-3 w-3" />
|
||||
<span>{counts?.logs ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <TrialActionsCell trial={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Plus, TestTube, Eye } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
import { trialsColumns, type Trial } from "./trials-columns";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function TrialsDataTable() {
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: trialsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.trials.getUserTrials.useQuery(
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
studyId: selectedStudyId ?? undefined,
|
||||
status:
|
||||
statusFilter === "all"
|
||||
? undefined
|
||||
: (statusFilter as
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed"),
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds for real-time updates
|
||||
enabled: !!selectedStudyId, // Only fetch when a study is selected
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh trials when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{ enabled: !!selectedStudyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Trials" },
|
||||
]
|
||||
: [{ label: "Trials" }]),
|
||||
]);
|
||||
|
||||
// Transform trials data to match the Trial type expected by columns
|
||||
const trials: Trial[] = React.useMemo(() => {
|
||||
if (!trialsData?.trials) return [];
|
||||
|
||||
return trialsData.trials.map((trial) => ({
|
||||
id: trial.id,
|
||||
name: trial.notes
|
||||
? `Trial: ${trial.notes}`
|
||||
: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
description: trial.notes,
|
||||
status: trial.status,
|
||||
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : null,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
createdAt: trial.createdAt,
|
||||
updatedAt: trial.updatedAt,
|
||||
studyId: trial.experiment?.studyId ?? "",
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId ?? "",
|
||||
wizardId: trial.wizardId,
|
||||
study: {
|
||||
id: trial.experiment?.studyId ?? "",
|
||||
name: trial.experiment?.study?.name ?? "",
|
||||
},
|
||||
experiment: {
|
||||
id: trial.experimentId,
|
||||
name: trial.experiment?.name ?? "",
|
||||
},
|
||||
participant: {
|
||||
id: trial.participantId ?? "",
|
||||
name:
|
||||
trial.participant?.name ?? trial.participant?.participantCode ?? "",
|
||||
email: trial.participant?.email ?? "",
|
||||
},
|
||||
wizard: trial.wizard
|
||||
? {
|
||||
id: trial.wizard.id,
|
||||
name: trial.wizard.name,
|
||||
email: trial.wizard.email,
|
||||
}
|
||||
: null,
|
||||
duration: trial.duration ? Math.round(trial.duration / 60) : undefined,
|
||||
_count: {
|
||||
actions: trial._count?.events ?? 0,
|
||||
logs: trial._count?.mediaCaptures ?? 0,
|
||||
},
|
||||
userRole: trial.userRole,
|
||||
canAccess: trial.canAccess ?? false,
|
||||
canEdit:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "aborted"),
|
||||
canDelete:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed"),
|
||||
canExecute:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "in_progress"),
|
||||
}));
|
||||
}, [trialsData]);
|
||||
|
||||
// Status filter options
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "all" },
|
||||
{ label: "Scheduled", value: "scheduled" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Aborted", value: "aborted" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
];
|
||||
|
||||
// Filter trials based on selected filters
|
||||
const filteredTrials = React.useMemo(() => {
|
||||
return trials.filter((trial) => {
|
||||
const statusMatch =
|
||||
statusFilter === "all" || trial.status === statusFilter;
|
||||
return statusMatch;
|
||||
});
|
||||
}, [trials, statusFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Trials
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading your trials."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filteredTrials.some((trial) => !trial.canAccess) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="rounded-full bg-amber-100 p-1">
|
||||
<Eye className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
Limited Trial Access
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
Some trials are marked as “View Only” or
|
||||
“Restricted” because you have observer-level
|
||||
access to their studies. Only researchers, wizards, and study
|
||||
owners can view detailed trial information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={trialsColumns}
|
||||
data={filteredTrials}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search trials..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -113,7 +112,7 @@ export function WizardInterface({
|
||||
{ label: studyData.name, href: `/studies/${studyData.id}` },
|
||||
{ label: "Trials", href: `/studies/${studyData.id}/trials` },
|
||||
]
|
||||
: [{ label: "Trials", href: "/trials" }]),
|
||||
: []),
|
||||
{
|
||||
label: `Trial ${trial.participant.participantCode}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import {
|
||||
Activity, BarChart3, Calendar, FlaskConical, Home, Play, Target, UserCog, Users
|
||||
Activity,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
FlaskConical,
|
||||
Home,
|
||||
Target,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavigationItem {
|
||||
@@ -41,22 +47,6 @@ export const researchWorkflowItems: NavigationItem[] = [
|
||||
requiresStudy: true,
|
||||
description: "Design experimental protocols",
|
||||
},
|
||||
{
|
||||
label: "Participants",
|
||||
href: "/participants",
|
||||
icon: Users,
|
||||
roles: ["administrator", "researcher"],
|
||||
requiresStudy: true,
|
||||
description: "Manage study participants",
|
||||
},
|
||||
{
|
||||
label: "Trials",
|
||||
href: "/trials",
|
||||
icon: Play,
|
||||
roles: ["administrator", "researcher", "wizard"],
|
||||
requiresStudy: true,
|
||||
description: "Execute and monitor trials",
|
||||
},
|
||||
];
|
||||
|
||||
// Trial Execution - Active wizard controls
|
||||
|
||||
Reference in New Issue
Block a user