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