mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Begin plugins system
This commit is contained in:
@@ -1,235 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminUserTable } from "~/components/admin/admin-user-table";
|
||||
import { RoleManagement } from "~/components/admin/role-management";
|
||||
import { SystemStats } from "~/components/admin/system-stats";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Shield,
|
||||
Users,
|
||||
Database,
|
||||
Settings,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
BarChart3,
|
||||
FileText,
|
||||
UserCheck,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { requireAdmin } from "~/server/auth/utils";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await requireAdmin();
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
|
||||
// System Overview Cards
|
||||
function SystemOverview() {
|
||||
// Mock data - replace with actual API calls when available
|
||||
const stats = {
|
||||
totalUsers: 0,
|
||||
activeStudies: 0,
|
||||
systemHealth: 100,
|
||||
pluginRepositories: 0,
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats.totalUsers,
|
||||
description: "Registered platform users",
|
||||
icon: Users,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Active Studies",
|
||||
value: stats.activeStudies,
|
||||
description: "Currently running studies",
|
||||
icon: Activity,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
},
|
||||
{
|
||||
title: "System Health",
|
||||
value: `${stats.systemHealth}%`,
|
||||
description: "Overall system status",
|
||||
icon: CheckCircle2,
|
||||
color: "text-emerald-600",
|
||||
bg: "bg-emerald-50",
|
||||
},
|
||||
{
|
||||
title: "Plugin Repos",
|
||||
value: stats.pluginRepositories,
|
||||
description: "Configured repositories",
|
||||
icon: Database,
|
||||
color: "text-purple-600",
|
||||
bg: "bg-purple-50",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">
|
||||
System Administration
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
Manage users, roles, and system settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="destructive">Administrator</Badge>
|
||||
<span className="text-sm text-slate-600">
|
||||
{session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/profile">Profile</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">← Back to Home</Link>
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Dashboard Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{/* System Overview */}
|
||||
<div className="lg:col-span-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Current system status and statistics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SystemStats />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common admin tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
Create User
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
System Health
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Export Data
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Audit Logs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Management */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Role Management</CardTitle>
|
||||
<CardDescription>System role definitions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RoleManagement />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* User Management */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage user accounts and role assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdminUserTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Warning */}
|
||||
<div className="mt-8">
|
||||
<Card className="border-yellow-200 bg-yellow-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-yellow-900">
|
||||
Administrator Access
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-yellow-800">
|
||||
You have full administrative access to this system. Please use these
|
||||
privileges responsibly. All administrative actions are logged for
|
||||
security purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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 Admin Activity
|
||||
function RecentActivity() {
|
||||
// Mock data - replace with actual audit log API
|
||||
const activities = [
|
||||
{
|
||||
id: "1",
|
||||
type: "user_created",
|
||||
title: "New user registered",
|
||||
description: "researcher@university.edu joined the platform",
|
||||
time: "2 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "repository_added",
|
||||
title: "Plugin repository added",
|
||||
description: "Official TurtleBot3 repository configured",
|
||||
time: "4 hours ago",
|
||||
status: "info",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "role_updated",
|
||||
title: "User role modified",
|
||||
description: "john.doe@lab.edu promoted to researcher",
|
||||
time: "6 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "system_update",
|
||||
title: "System maintenance",
|
||||
description: "Database optimization completed",
|
||||
time: "1 day ago",
|
||||
status: "success",
|
||||
},
|
||||
];
|
||||
|
||||
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 <AlertTriangle className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Latest administrative actions and system events
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
{activity.time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// System Status
|
||||
function SystemStatus() {
|
||||
// Mock data - replace with actual system health checks
|
||||
const services = [
|
||||
{
|
||||
name: "Database",
|
||||
status: "healthy",
|
||||
uptime: "99.9%",
|
||||
responseTime: "12ms",
|
||||
},
|
||||
{
|
||||
name: "Authentication",
|
||||
status: "healthy",
|
||||
uptime: "100%",
|
||||
responseTime: "8ms",
|
||||
},
|
||||
{
|
||||
name: "File Storage",
|
||||
status: "healthy",
|
||||
uptime: "99.8%",
|
||||
responseTime: "45ms",
|
||||
},
|
||||
{
|
||||
name: "Plugin System",
|
||||
status: "healthy",
|
||||
uptime: "99.5%",
|
||||
responseTime: "23ms",
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return <Badge className="bg-green-100 text-green-800">Healthy</Badge>;
|
||||
case "warning":
|
||||
return <Badge className="bg-yellow-100 text-yellow-800">Warning</Badge>;
|
||||
case "error":
|
||||
return <Badge className="bg-red-100 text-red-800">Error</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Status</CardTitle>
|
||||
<CardDescription>
|
||||
Current status of core system services
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.name}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{service.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Uptime: {service.uptime} • Response: {service.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(service.status)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick Admin Actions
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Manage Users",
|
||||
description: "View and modify user accounts",
|
||||
href: "/admin/users",
|
||||
icon: Users,
|
||||
disabled: true, // Enable when route exists
|
||||
},
|
||||
{
|
||||
title: "Plugin Repositories",
|
||||
description: "Configure plugin sources",
|
||||
href: "/admin/repositories",
|
||||
icon: Database,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: "System Settings",
|
||||
description: "Configure platform settings",
|
||||
href: "/admin/settings",
|
||||
icon: Settings,
|
||||
disabled: true, // Enable when route exists
|
||||
},
|
||||
{
|
||||
title: "View Audit Logs",
|
||||
description: "Review system activity",
|
||||
href: "/admin/audit",
|
||||
icon: FileText,
|
||||
disabled: true, // Enable when route exists
|
||||
},
|
||||
{
|
||||
title: "Role Management",
|
||||
description: "Manage user permissions",
|
||||
href: "/admin/roles",
|
||||
icon: UserCheck,
|
||||
disabled: true, // Enable when route exists
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
description: "Platform usage statistics",
|
||||
href: "/admin/analytics",
|
||||
icon: BarChart3,
|
||||
disabled: true, // Enable when route exists
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{actions.map((action) => (
|
||||
<Card
|
||||
key={action.title}
|
||||
className={`group transition-all ${
|
||||
action.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-3 flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-slate-100 p-2">
|
||||
<action.icon className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{action.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{action.description}
|
||||
</p>
|
||||
{action.disabled ? (
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Coming Soon
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild className="w-full" variant="outline">
|
||||
<Link href={action.href}>Access</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Administration" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="System administration and platform management"
|
||||
icon={Shield}
|
||||
actions={
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-800">
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
Administrator
|
||||
</Badge>
|
||||
<ActionButton href="/admin/repositories">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System Overview */}
|
||||
<SystemOverview />
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<RecentActivity />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SystemStatus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Administrative Tools</h2>
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="rounded-lg bg-amber-100 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-1 font-semibold text-amber-900">
|
||||
Administrator Access
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800">
|
||||
You have full administrative access to this system. All actions
|
||||
are logged for security purposes. Please use these privileges
|
||||
responsibly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/app/(dashboard)/admin/repositories/page.tsx
Normal file
5
src/app/(dashboard)/admin/repositories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RepositoriesDataTable } from "~/components/admin/repositories-data-table";
|
||||
|
||||
export default function AdminRepositoriesPage() {
|
||||
return <RepositoriesDataTable />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface ExperimentDesignerPageProps {
|
||||
@@ -19,17 +20,33 @@ export default async function ExperimentDesignerPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Parse existing visual design if available
|
||||
const existingDesign = experiment.visualDesign as {
|
||||
blocks?: unknown[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
} | null;
|
||||
|
||||
// Only pass initialDesign if there's existing visual design data
|
||||
const initialDesign =
|
||||
existingDesign?.blocks && existingDesign.blocks.length > 0
|
||||
? {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
blocks: existingDesign.blocks as ExperimentBlock[],
|
||||
version: existingDesign.version ?? 1,
|
||||
lastSaved:
|
||||
typeof existingDesign.lastSaved === "string"
|
||||
? new Date(existingDesign.lastSaved)
|
||||
: new Date(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<EnhancedBlockDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={{
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
blocks: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
initialDesign={initialDesign}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -40,7 +57,10 @@ export default async function ExperimentDesignerPage({
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ExperimentDesignerPageProps) {
|
||||
}: ExperimentDesignerPageProps): Promise<{
|
||||
title: string;
|
||||
description: string;
|
||||
}> {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.id });
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Play,
|
||||
Settings,
|
||||
Share,
|
||||
Target,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -27,20 +11,17 @@ import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
@@ -52,7 +33,7 @@ const statusConfig = {
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "FlaskConical" as const,
|
||||
icon: "TestTube" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
@@ -66,89 +47,141 @@ const statusConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: { id: string; name: string };
|
||||
robot: { id: string; name: string; description: string | null } | null;
|
||||
protocol?: { blocks: unknown[] } | null;
|
||||
visualDesign?: unknown;
|
||||
studyId: string;
|
||||
createdBy: string;
|
||||
robotId: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
experiment: { name: string } | null;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string | null;
|
||||
updatedAt: Date;
|
||||
canAccess: boolean;
|
||||
userRole: string;
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
params,
|
||||
}: ExperimentDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<any>(null);
|
||||
const [trials, setTrials] = useState<any[]>([]);
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: experimentData } = api.experiments.get.useQuery(
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: trialsData } = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.id ?? "", limit: 10 },
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentData) {
|
||||
setExperiment(experimentData);
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
if (trialsData) {
|
||||
setTrials(trialsData);
|
||||
}, [experimentQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
if (experimentData !== undefined) {
|
||||
}, [trialsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.isLoading || trialsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentData, trialsData]);
|
||||
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
{ label: experiment?.name || "Experiment" },
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Unknown Study",
|
||||
href: `/studies/${experiment?.study?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment?.study?.id}/experiments`,
|
||||
},
|
||||
{
|
||||
label: experiment?.name ?? "Experiment",
|
||||
},
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (experimentQuery.error) return notFound();
|
||||
if (!experiment) return notFound();
|
||||
|
||||
if (loading || !experiment) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const displayName = experiment.name ?? "Untitled Experiment";
|
||||
const description = experiment.description;
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
|
||||
const statusInfo = statusConfig[experiment.status];
|
||||
|
||||
// TODO: Get actual stats from API
|
||||
const mockStats = {
|
||||
totalTrials: trials.length,
|
||||
completedTrials: trials.filter((t) => t.status === "completed").length,
|
||||
averageDuration: "—",
|
||||
successRate:
|
||||
trials.length > 0
|
||||
? `${Math.round((trials.filter((t) => t.status === "completed").length / trials.length) * 100)}%`
|
||||
: "—",
|
||||
};
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={experiment.name}
|
||||
subtitle={experiment.description}
|
||||
icon="FlaskConical"
|
||||
title={displayName}
|
||||
subtitle={description ?? undefined}
|
||||
icon="TestTube"
|
||||
status={{
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "TestTube",
|
||||
}}
|
||||
actions={
|
||||
canEdit ? (
|
||||
@@ -172,23 +205,16 @@ export default function ExperimentDetailPage({
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
{/* Experiment Information */}
|
||||
<EntityViewSection title="Experiment Information" icon="FlaskConical">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<EntityViewSection title="Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
@@ -204,8 +230,8 @@ export default function ExperimentDetailPage({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Robot Platform",
|
||||
value: experiment.robot?.name || "Not specified",
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? "Unknown",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
@@ -223,10 +249,10 @@ export default function ExperimentDetailPage({
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Overview */}
|
||||
{/* Protocol Section */}
|
||||
<EntityViewSection
|
||||
title="Protocol Overview"
|
||||
icon="Target"
|
||||
title="Experiment Protocol"
|
||||
icon="FileText"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -242,22 +268,22 @@ export default function ExperimentDetailPage({
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<h4 className="mb-2 font-medium">Protocol Structure</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Visual protocol designed with{" "}
|
||||
{Array.isArray((experiment.protocol as any).blocks)
|
||||
? (experiment.protocol as any).blocks.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</p>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Protocol contains{" "}
|
||||
{Array.isArray(
|
||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||
)
|
||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||
.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Target"
|
||||
title="No Protocol Defined"
|
||||
description="Use the experiment designer to create your protocol"
|
||||
icon="FileText"
|
||||
title="No protocol defined"
|
||||
description="Create an experiment protocol using the visual designer"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
@@ -275,12 +301,10 @@ export default function ExperimentDetailPage({
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
description="Latest experimental sessions"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
@@ -310,78 +334,70 @@ export default function ExperimentDetailPage({
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.replace("_", " ")}
|
||||
{trial.status.charAt(0).toUpperCase() +
|
||||
trial.status.slice(1).replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
{formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{Math.round(trial.duration / 60)} min
|
||||
</span>
|
||||
)}
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ||
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{trials.length > 5 && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/trials?experimentId=${experiment.id}`}>
|
||||
View All Trials ({trials.length})
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No Trials Yet"
|
||||
description="Start your first trial to begin collecting data"
|
||||
title="No trials yet"
|
||||
description="Start your first trial to collect data"
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
Start First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Quick Stats */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart3">
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: mockStats.totalTrials,
|
||||
value: trials.length,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: mockStats.completedTrials,
|
||||
color: "success",
|
||||
value: trials.filter((t) => t.status === "completed").length,
|
||||
},
|
||||
{
|
||||
label: "Success Rate",
|
||||
value: mockStats.successRate,
|
||||
color: "success",
|
||||
},
|
||||
{
|
||||
label: "Avg. Duration",
|
||||
value: mockStats.averageDuration,
|
||||
label: "In Progress",
|
||||
value: trials.filter((t) => t.status === "in_progress")
|
||||
.length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -399,11 +415,7 @@ export default function ExperimentDetailPage({
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.type || "Not specified",
|
||||
},
|
||||
{
|
||||
label: "Connection",
|
||||
value: experiment.robot.connectionType || "Not configured",
|
||||
value: experiment.robot.description ?? "Not specified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -411,29 +423,24 @@ export default function ExperimentDetailPage({
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Settings">
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "View All Trials",
|
||||
icon: "Play",
|
||||
href: `/trials?experimentId=${experiment.id}`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Share",
|
||||
icon: "Download" as const,
|
||||
href: `/experiments/${experiment.id}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit",
|
||||
icon: "Edit" as const,
|
||||
href: `/experiments/${experiment.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Protocol Designer",
|
||||
icon: "Settings",
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/experiments/${experiment.id}/designer`,
|
||||
},
|
||||
]
|
||||
@@ -441,7 +448,7 @@ export default function ExperimentDetailPage({
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
FileText,
|
||||
Mail,
|
||||
Play,
|
||||
Shield,
|
||||
Trash2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -44,8 +39,31 @@ export default function ParticipantDetailPage({
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [participant, setParticipant] = useState<any>(null);
|
||||
const [trials, setTrials] = useState<any[]>([]);
|
||||
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,
|
||||
@@ -56,7 +74,7 @@ export default function ParticipantDetailPage({
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: participantData } = api.participants.get.useQuery(
|
||||
@@ -86,7 +104,7 @@ export default function ParticipantDetailPage({
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Participants", href: "/participants" },
|
||||
{
|
||||
label: participant?.name || participant?.participantCode || "Participant",
|
||||
label: participant?.name ?? participant?.participantCode ?? "Participant",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -116,7 +134,7 @@ export default function ParticipantDetailPage({
|
||||
canEdit && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/participants/${resolvedParams.id}/edit`}>
|
||||
<Link href={`/participants/${resolvedParams?.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
@@ -147,16 +165,16 @@ export default function ParticipantDetailPage({
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
value: participant.name || "Not provided",
|
||||
value: participant?.name ?? "Not provided",
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: participant.email ? (
|
||||
value: participant?.email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a
|
||||
href={`mailto:${participant.email}`}
|
||||
className="hover:underline"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{participant.email}
|
||||
</a>
|
||||
@@ -167,7 +185,7 @@ export default function ParticipantDetailPage({
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
value: participant.study ? (
|
||||
value: participant?.study ? (
|
||||
<Link
|
||||
href={`/studies/${participant.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
@@ -182,43 +200,53 @@ export default function ParticipantDetailPage({
|
||||
/>
|
||||
|
||||
{/* Demographics */}
|
||||
{participant.demographics &&
|
||||
typeof participant.demographics === "object" &&
|
||||
participant.demographics !== null &&
|
||||
Object.keys(participant.demographics).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
|
||||
>;
|
||||
return [
|
||||
demo.age && {
|
||||
label: "Age",
|
||||
value:
|
||||
typeof demo.age === "number"
|
||||
? demo.age.toString()
|
||||
: String(demo.age),
|
||||
},
|
||||
demo.gender && {
|
||||
label: "Gender",
|
||||
value: String(demo.gender),
|
||||
},
|
||||
].filter(Boolean) as Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
{participant?.notes && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Notes
|
||||
@@ -238,7 +266,9 @@ export default function ParticipantDetailPage({
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams?.id}`}
|
||||
>
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -257,7 +287,7 @@ export default function ParticipantDetailPage({
|
||||
href={`/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{trial.experiment?.name || "Trial"}
|
||||
{trial.experiment?.name ?? "Trial"}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -283,7 +313,7 @@ export default function ParticipantDetailPage({
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span>{Math.round(trial.duration / 60)} minutes</span>
|
||||
<span>{Math.round(trial.duration / 60)} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +328,7 @@ export default function ParticipantDetailPage({
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
href={`/trials/new?participantId=${resolvedParams?.id}`}
|
||||
>
|
||||
Schedule First Trial
|
||||
</Link>
|
||||
@@ -318,9 +348,11 @@ export default function ParticipantDetailPage({
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Informed Consent</span>
|
||||
<Badge
|
||||
variant={participant.consentGiven ? "default" : "destructive"}
|
||||
variant={
|
||||
participant?.consentGiven ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{participant.consentGiven ? (
|
||||
{participant?.consentGiven ? (
|
||||
<>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Given
|
||||
@@ -334,10 +366,10 @@ export default function ParticipantDetailPage({
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{participant.consentDate && (
|
||||
{participant?.consentDate && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Consented:{" "}
|
||||
{formatDistanceToNow(participant.consentDate, {
|
||||
{formatDistanceToNow(new Date(participant.consentDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
@@ -361,7 +393,7 @@ export default function ParticipantDetailPage({
|
||||
items={[
|
||||
{
|
||||
label: "Registered",
|
||||
value: formatDistanceToNow(participant.createdAt, {
|
||||
value: formatDistanceToNow(participant?.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
@@ -388,17 +420,17 @@ export default function ParticipantDetailPage({
|
||||
{
|
||||
label: "Schedule Trial",
|
||||
icon: "Play",
|
||||
href: `/trials/new?participantId=${resolvedParams.id}`,
|
||||
href: `/trials/new?participantId=${resolvedParams?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Edit Information",
|
||||
icon: "Edit",
|
||||
href: `/participants/${resolvedParams.id}/edit`,
|
||||
href: `/participants/${resolvedParams?.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "FileText",
|
||||
href: `/participants/${resolvedParams.id}/export`,
|
||||
href: `/participants/${resolvedParams?.id}/export`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
5
src/app/(dashboard)/plugins/browse/page.tsx
Normal file
5
src/app/(dashboard)/plugins/browse/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PluginStoreBrowse } from "~/components/plugins/plugin-store-browse";
|
||||
|
||||
export default function PluginStoreBrowsePage() {
|
||||
return <PluginStoreBrowse />;
|
||||
}
|
||||
5
src/app/(dashboard)/plugins/page.tsx
Normal file
5
src/app/(dashboard)/plugins/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PluginsDataTable } from "~/components/plugins/plugins-data-table";
|
||||
|
||||
export default function PluginsPage() {
|
||||
return <PluginsDataTable />;
|
||||
}
|
||||
@@ -1,26 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Building,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Edit,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Plus, Settings, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
@@ -32,7 +16,6 @@ import {
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -66,21 +49,40 @@ const statusConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
type Study = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
institution: string | null;
|
||||
irbProtocol: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type Member = {
|
||||
role: string;
|
||||
user: {
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [study, setStudy] = useState<any>(null);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [study, setStudy] = useState<Study | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
@@ -109,7 +111,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name || "Study" },
|
||||
{ label: study?.name ?? "Study" },
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
@@ -120,7 +122,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const statusInfo = statusConfig[study.status];
|
||||
const statusInfo = statusConfig[study.status as keyof typeof statusConfig];
|
||||
|
||||
// TODO: Get actual stats from API
|
||||
const mockStats = {
|
||||
@@ -135,12 +137,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={study.name}
|
||||
subtitle={study.description}
|
||||
subtitle={study.description ?? undefined}
|
||||
icon="Building"
|
||||
status={{
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "FileText",
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
@@ -169,11 +171,11 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
items={[
|
||||
{
|
||||
label: "Institution",
|
||||
value: study.institution,
|
||||
value: study.institution ?? "Not specified",
|
||||
},
|
||||
{
|
||||
label: "IRB Protocol",
|
||||
value: study.irbProtocol || "Not specified",
|
||||
value: study.irbProtocol ?? "Not required",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
@@ -244,9 +246,9 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
{members.map((member, index) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
key={`${member.user.email}-${index}`}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
|
||||
export default function StudyParticipantsPage() {
|
||||
const params = useParams();
|
||||
const studyId = typeof params.id === "string" ? params.id : "";
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
|
||||
export default function StudyTrialsPage() {
|
||||
const params = useParams();
|
||||
const studyId = typeof params.id === "string" ? params.id : "";
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
|
||||
@@ -1,56 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bot,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Info,
|
||||
Play,
|
||||
Settings,
|
||||
Share,
|
||||
Target,
|
||||
Timer,
|
||||
User,
|
||||
Users,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface TrialDetailPageProps {
|
||||
params: Promise<{
|
||||
trialId: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
}>;
|
||||
params: Promise<{ trialId: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
@@ -72,22 +52,57 @@ const statusConfig = {
|
||||
failed: {
|
||||
label: "Failed",
|
||||
variant: "destructive" as const,
|
||||
icon: "XCircle" as const,
|
||||
icon: "AlertCircle" as const,
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
variant: "destructive" as const,
|
||||
icon: "XCircle" as const,
|
||||
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 [trial, setTrial] = useState<any>(null);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [trial, setTrial] = useState<Trial | null>(null);
|
||||
const [events, setEvents] = useState<TrialEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{
|
||||
trialId: string;
|
||||
@@ -97,90 +112,110 @@ export default function TrialDetailPage({
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolvedP = await params;
|
||||
const resolvedSP = await searchParams;
|
||||
setResolvedParams(resolvedP);
|
||||
setResolvedSearchParams(resolvedSP);
|
||||
}
|
||||
resolveParams();
|
||||
}, [params, searchParams]);
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: trialData } = api.trials.get.useQuery(
|
||||
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 { data: eventsData } = api.trials.getEvents.useQuery(
|
||||
const eventsQuery = api.trials.getEvents.useQuery(
|
||||
{ trialId: resolvedParams?.trialId ?? "" },
|
||||
{ enabled: !!resolvedParams?.trialId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialData) {
|
||||
setTrial(trialData);
|
||||
if (trialQuery.data) {
|
||||
setTrial(trialQuery.data as Trial);
|
||||
}
|
||||
if (eventsData) {
|
||||
setEvents(eventsData);
|
||||
}, [trialQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventsQuery.data) {
|
||||
setEvents(eventsQuery.data as TrialEvent[]);
|
||||
}
|
||||
if (trialData !== undefined) {
|
||||
}, [eventsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialQuery.isLoading || eventsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [trialData, eventsData]);
|
||||
}, [trialQuery.isLoading, eventsQuery.isLoading]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Trials", href: "/trials" },
|
||||
{ label: trial ? `Trial #${trial.id.slice(-6)}` : "Trial" },
|
||||
{
|
||||
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 (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (trialQuery.error || !trial) return <div>Trial not found</div>;
|
||||
|
||||
if (loading || !trial) {
|
||||
return <div>Loading...</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 userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
const canControl = ["administrator", "researcher", "wizard"].includes(
|
||||
userRole,
|
||||
);
|
||||
|
||||
const statusInfo = statusConfig[trial.status];
|
||||
|
||||
// Calculate trial stats
|
||||
const totalEvents = events.length;
|
||||
const errorEvents = events.filter((e) => e.eventType === "error").length;
|
||||
const completedSteps = events.filter(
|
||||
(e) => e.eventType === "step_completed",
|
||||
).length;
|
||||
const progress = trial.experiment
|
||||
? (completedSteps / (trial.experiment._count?.steps || 1)) * 100
|
||||
: 0;
|
||||
const displayName = `Trial #${trial.id.slice(-6)}`;
|
||||
const experimentName = trial.experiment?.name ?? "Unknown Experiment";
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Error Alert */}
|
||||
{resolvedSearchParams.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{resolvedSearchParams?.error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{resolvedSearchParams.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={`Trial #${trial.id.slice(-6)}`}
|
||||
subtitle={trial.experiment?.name || "No experiment assigned"}
|
||||
icon="Target"
|
||||
status={{
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
}}
|
||||
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" && (
|
||||
@@ -199,19 +234,11 @@ export default function TrialDetailPage({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/trials/${trial.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/trials/${trial.id}/analysis`}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -219,12 +246,12 @@ export default function TrialDetailPage({
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
<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="FileText">
|
||||
<EntityViewSection title="Trial Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Experiment",
|
||||
@@ -236,7 +263,7 @@ export default function TrialDetailPage({
|
||||
{trial.experiment.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No experiment assigned"
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -246,34 +273,34 @@ export default function TrialDetailPage({
|
||||
href={`/participants/${trial.participant.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{trial.participant.name ||
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</Link>
|
||||
) : (
|
||||
"No participant assigned"
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
value: trial.study ? (
|
||||
value: trial.experiment?.studyId ? (
|
||||
<Link
|
||||
href={`/studies/${trial.study.id}`}
|
||||
href={`/studies/${trial.experiment.studyId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{trial.study.name}
|
||||
Study
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
"Unknown"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Robot Platform",
|
||||
value: trial.experiment?.robot?.name || "Not specified",
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? trial.status,
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
value: trial.scheduledAt
|
||||
? format(trial.scheduledAt, "PPp")
|
||||
value: trial.createdAt
|
||||
? formatDistanceToNow(trial.createdAt, { addSuffix: true })
|
||||
: "Not scheduled",
|
||||
},
|
||||
{
|
||||
@@ -281,100 +308,59 @@ export default function TrialDetailPage({
|
||||
value: trial.duration
|
||||
? `${Math.round(trial.duration / 60)} minutes`
|
||||
: trial.status === "in_progress"
|
||||
? "In progress..."
|
||||
: "Not started",
|
||||
? "Ongoing"
|
||||
: "Not available",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Progress Bar for In-Progress Trials */}
|
||||
{trial.status === "in_progress" && trial.experiment && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Progress</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{completedSteps} of {trial.experiment._count?.steps || 0}{" "}
|
||||
steps
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trial Notes */}
|
||||
{trial.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">
|
||||
{trial.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Trial Timeline */}
|
||||
{/* 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="Trial Timeline"
|
||||
title="Event Timeline"
|
||||
icon="Activity"
|
||||
description="Real-time events and interactions"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/trials/${trial.id}/events`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View All Events
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
description={`${events.length} events recorded`}
|
||||
>
|
||||
{events.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{events.slice(-10).map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{event.eventType === "error" ? (
|
||||
<div className="rounded-full bg-red-100 p-1">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</div>
|
||||
) : event.eventType === "step_completed" ? (
|
||||
<div className="rounded-full bg-green-100 p-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full bg-blue-100 p-1">
|
||||
<Activity className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{event.eventType.replace("_", " ")}
|
||||
</p>
|
||||
<time className="text-muted-foreground text-xs">
|
||||
{format(event.timestamp, "HH:mm:ss")}
|
||||
</time>
|
||||
{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>
|
||||
{event.eventData && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{typeof event.eventData === "string"
|
||||
? event.eventData
|
||||
: JSON.stringify(event.eventData)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{events.length > 10 && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/trials/${trial.id}/events`}>
|
||||
View All {events.length} Events
|
||||
</Link>
|
||||
<div className="text-center">
|
||||
<Button variant="outline" size="sm">
|
||||
View All Events ({events.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -382,47 +368,22 @@ export default function TrialDetailPage({
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Activity"
|
||||
title="No Events Yet"
|
||||
description="Trial events will appear here once the trial begins"
|
||||
title="No events recorded"
|
||||
description="Events will appear here as the trial progresses"
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Trial Stats */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart3">
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Events",
|
||||
value: totalEvents,
|
||||
label: "Events",
|
||||
value: events.length,
|
||||
},
|
||||
{
|
||||
label: "Completed Steps",
|
||||
value: completedSteps,
|
||||
color: "success",
|
||||
},
|
||||
{
|
||||
label: "Error Events",
|
||||
value: errorEvents,
|
||||
color: errorEvents > 0 ? "error" : "default",
|
||||
},
|
||||
{
|
||||
label: "Progress",
|
||||
value: `${Math.round(progress)}%`,
|
||||
color: progress === 100 ? "success" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Session Details */}
|
||||
<EntityViewSection title="Session Details" icon="Clock">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(trial.createdAt, {
|
||||
@@ -432,35 +393,34 @@ export default function TrialDetailPage({
|
||||
{
|
||||
label: "Started",
|
||||
value: trial.startedAt
|
||||
? format(trial.startedAt, "PPp")
|
||||
? formatDistanceToNow(trial.startedAt, { addSuffix: true })
|
||||
: "Not started",
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trial.completedAt
|
||||
? format(trial.completedAt, "PPp")
|
||||
? formatDistanceToNow(trial.completedAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not completed",
|
||||
},
|
||||
{
|
||||
label: "Created By",
|
||||
value:
|
||||
trial.createdBy?.name ||
|
||||
trial.createdBy?.email ||
|
||||
"Unknown",
|
||||
value: "System",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Settings">
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
...(canControl && trial.status === "scheduled"
|
||||
? [
|
||||
{
|
||||
label: "Start Trial",
|
||||
icon: "Play",
|
||||
icon: "Play" as const,
|
||||
href: `/trials/${trial.id}/wizard`,
|
||||
variant: "default" as const,
|
||||
},
|
||||
@@ -470,7 +430,7 @@ export default function TrialDetailPage({
|
||||
? [
|
||||
{
|
||||
label: "Monitor Trial",
|
||||
icon: "Eye",
|
||||
icon: "Eye" as const,
|
||||
href: `/trials/${trial.id}/wizard`,
|
||||
},
|
||||
]
|
||||
@@ -479,29 +439,25 @@ export default function TrialDetailPage({
|
||||
? [
|
||||
{
|
||||
label: "View Analysis",
|
||||
icon: "BarChart3",
|
||||
icon: "BarChart" as const,
|
||||
href: `/trials/${trial.id}/analysis`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download",
|
||||
icon: "Download" as const,
|
||||
href: `/trials/${trial.id}/export`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Trial",
|
||||
icon: "Edit",
|
||||
href: `/trials/${trial.id}/edit`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Share Results",
|
||||
icon: "Share",
|
||||
href: `/trials/${trial.id}/share`,
|
||||
label: "View Events",
|
||||
icon: "Activity" as const,
|
||||
href: `/trials/${trial.id}/events`,
|
||||
},
|
||||
{
|
||||
label: "Export Report",
|
||||
icon: "FileText" as const,
|
||||
href: `/trials/${trial.id}/report`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -510,32 +466,22 @@ export default function TrialDetailPage({
|
||||
{/* Participant Info */}
|
||||
{trial.participant && (
|
||||
<EntityViewSection title="Participant" icon="User">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{trial.participant.name ||
|
||||
trial.participant.participantCode}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trial.participant.name
|
||||
? trial.participant.participantCode
|
||||
: "Participant"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||
<Link href={`/participants/${trial.participant.id}`}>
|
||||
View Profile
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Code",
|
||||
value: trial.participant.participantCode,
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
value: trial.participant.name ?? "Not provided",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user