mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -20,7 +20,7 @@ export default function StudyAnalyticsPage() {
|
||||
// Fetch list of trials
|
||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
{ enabled: !!studyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
@@ -49,19 +49,23 @@ export default function StudyAnalyticsPage() {
|
||||
<div className="bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-2 animate-pulse">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">Loading session data...</span>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="flex animate-pulse flex-col items-center gap-2">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Loading session data...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
|
||||
...t,
|
||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
createdAt: new Date(t.createdAt),
|
||||
}))} />
|
||||
<StudyAnalyticsDataTable
|
||||
data={(trialsList ?? []).map((t) => ({
|
||||
...t,
|
||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
createdAt: new Date(t.createdAt),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DesignerPageClient({
|
||||
const stepCount = initialDesign.steps.length;
|
||||
const actionCount = initialDesign.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return { stepCount, actionCount };
|
||||
|
||||
@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
|
||||
}: ExperimentDesignerPageProps) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
@@ -36,13 +38,13 @@ export default async function ExperimentDesignerPage({
|
||||
// Only pass initialDesign if there's existing visual design data
|
||||
let initialDesign:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (existingDesign?.steps && existingDesign.steps.length > 0) {
|
||||
@@ -220,7 +222,9 @@ export default async function ExperimentDesignerPage({
|
||||
};
|
||||
};
|
||||
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
|
||||
const actions: ExperimentAction[] = s.actions.map((a) =>
|
||||
hydrateAction(a),
|
||||
);
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
@@ -278,7 +282,9 @@ export async function generateMetadata({
|
||||
}> {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit,
|
||||
Play,
|
||||
Settings,
|
||||
Users,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -9,13 +17,13 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -23,436 +31,443 @@ import { useSession } from "next-auth/react";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "TestTube" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertTriangle" as const,
|
||||
},
|
||||
draft: {
|
||||
label: "Draft",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "TestTube" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertTriangle" as const,
|
||||
},
|
||||
};
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: { id: string; name: string };
|
||||
robot: { id: string; name: string; description: string | null } | null;
|
||||
protocol?: { blocks: unknown[] } | null;
|
||||
visualDesign?: unknown;
|
||||
studyId: string;
|
||||
createdBy: string;
|
||||
robotId: string | null;
|
||||
version: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: { id: string; name: string };
|
||||
robot: { id: string; name: string; description: string | null } | null;
|
||||
protocol?: { blocks: unknown[] } | null;
|
||||
visualDesign?: unknown;
|
||||
studyId: string;
|
||||
createdBy: string;
|
||||
robotId: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
participant: {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
experiment: { name: string } | null;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string | null;
|
||||
updatedAt: Date;
|
||||
canAccess: boolean;
|
||||
userRole: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
experiment: { name: string } | null;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string | null;
|
||||
updatedAt: Date;
|
||||
canAccess: boolean;
|
||||
userRole: string;
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
params,
|
||||
params,
|
||||
}: ExperimentDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const { selectStudy } = useStudyManagement();
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{
|
||||
id: string;
|
||||
experimentId: string;
|
||||
} | null>(null);
|
||||
const { selectStudy } = useStudyManagement();
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
// Ensure study context is synced
|
||||
if (resolved.id) {
|
||||
void selectStudy(resolved.id);
|
||||
}
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params, selectStudy]);
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
// Ensure study context is synced
|
||||
if (resolved.id) {
|
||||
void selectStudy(resolved.id);
|
||||
}
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params, selectStudy]);
|
||||
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.data]);
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
}, [trialsQuery.data]);
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
}, [trialsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.isLoading || trialsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
|
||||
useEffect(() => {
|
||||
if (experimentQuery.isLoading || trialsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.study?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment?.study?.id}/experiments`,
|
||||
},
|
||||
{
|
||||
label: experiment?.name ?? "Experiment",
|
||||
},
|
||||
]);
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.study?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment?.study?.id}/experiments`,
|
||||
},
|
||||
{
|
||||
label: experiment?.name ?? "Experiment",
|
||||
},
|
||||
]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (experimentQuery.error) return notFound();
|
||||
if (!experiment) return notFound();
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (experimentQuery.error) return notFound();
|
||||
if (!experiment) return notFound();
|
||||
|
||||
const displayName = experiment.name ?? "Untitled Experiment";
|
||||
const description = experiment.description;
|
||||
const displayName = experiment.name ?? "Untitled Experiment";
|
||||
const description = experiment.description;
|
||||
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
description={description ?? undefined}
|
||||
icon={TestTube}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<EntityViewSection title="Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
value: experiment.study ? (
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? "Unknown",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(experiment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Section */}
|
||||
<EntityViewSection
|
||||
title="Experiment Protocol"
|
||||
icon="FileText"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Protocol
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{experiment.protocol &&
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Protocol contains{" "}
|
||||
{Array.isArray(
|
||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||
)
|
||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||
.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No protocol defined"
|
||||
description="Create an experiment protocol using the visual designer"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
Open Designer
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Trials */}
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.slice(0, 5).map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.charAt(0).toUpperCase() +
|
||||
trial.status.slice(1).replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{Math.round(trial.duration / 60)} min
|
||||
</span>
|
||||
)}
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No trials yet"
|
||||
description="Start your first trial to collect data"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: trials.length,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trials.filter((t) => t.status === "completed").length,
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: trials.filter((t) => t.status === "in_progress")
|
||||
.length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Robot Information */}
|
||||
{experiment.robot && (
|
||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Platform",
|
||||
value: experiment.robot.name,
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.description ?? "Not specified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
description={description ?? undefined}
|
||||
icon={TestTube}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<EntityViewSection title="Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
value: experiment.study ? (
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? "Unknown",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(experiment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Section */}
|
||||
<EntityViewSection
|
||||
title="Experiment Protocol"
|
||||
icon="FileText"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Protocol
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{experiment.protocol &&
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Protocol contains{" "}
|
||||
{Array.isArray(
|
||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||
)
|
||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||
.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No protocol defined"
|
||||
description="Create an experiment protocol using the visual designer"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
Open Designer
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Trials */}
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.slice(0, 5).map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.charAt(0).toUpperCase() +
|
||||
trial.status.slice(1).replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{Math.round(trial.duration / 60)} min
|
||||
</span>
|
||||
)}
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No trials yet"
|
||||
description="Start your first trial to collect data"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: trials.length,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trials.filter((t) => t.status === "completed").length,
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: trials.filter((t) => t.status === "in_progress")
|
||||
.length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Robot Information */}
|
||||
{experiment.robot && (
|
||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Platform",
|
||||
value: experiment.robot.name,
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.description ?? "Not specified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
|
||||
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||
|
||||
const Toolbar = ({ editor }: { editor: any }) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StudyFormsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||
const [editorTarget, setEditorTarget] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: study } = api.studies.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
||||
api.studies.getActiveConsentForm.useQuery(
|
||||
{ studyId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
// Only sync once when form loads to avoid resetting user edits
|
||||
useEffect(() => {
|
||||
if (activeConsentForm && !editorTarget) {
|
||||
setEditorTarget(activeConsentForm.content);
|
||||
}
|
||||
}, [activeConsentForm, editorTarget]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
content: editorTarget || '',
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
// @ts-ignore
|
||||
setEditorTarget(editor.storage.markdown.getMarkdown());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
||||
useEffect(() => {
|
||||
if (editor && editorTarget && editor.isEmpty) {
|
||||
editor.commands.setContent(editorTarget);
|
||||
}
|
||||
}, [editorTarget, editor]);
|
||||
|
||||
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Default Consent Form Generated!");
|
||||
setEditorTarget(data.content);
|
||||
editor?.commands.setContent(data.content);
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error generating consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Consent Form Saved Successfully!");
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error saving consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadConsent = async () => {
|
||||
if (!activeConsentForm || !study || !editor) return;
|
||||
|
||||
try {
|
||||
toast.loading("Generating Document...", { id: "pdf-gen" });
|
||||
await downloadPdfFromHtml(editor.getHTML(), {
|
||||
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
|
||||
});
|
||||
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
||||
} catch (error) {
|
||||
toast.error("Error generating PDF", { id: "pdf-gen" });
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
||||
{ label: "Forms" },
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!study) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title="Study Forms"
|
||||
description="Manage consent forms and future questionnaires for this study"
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<EntityViewSection
|
||||
title="Consent Document"
|
||||
icon="FileText"
|
||||
description="Design and manage the consent form that participants must sign before participating in your trials."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
|
||||
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
|
||||
>
|
||||
{generateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate Default Template
|
||||
</Button>
|
||||
{activeConsentForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
|
||||
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
|
||||
>
|
||||
{updateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConsentForm ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeConsentForm.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
v{activeConsentForm.version} • Status: Active
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDownloadConsent}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
|
||||
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
|
||||
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
|
||||
<Toolbar editor={editor} />
|
||||
</div>
|
||||
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
|
||||
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No Consent Form"
|
||||
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ type Member = {
|
||||
|
||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [study, setStudy] = useState<Study | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -176,7 +177,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
}
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -301,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface EditParticipantPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditParticipantPage({
|
||||
params,
|
||||
params,
|
||||
}: EditParticipantPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant || participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
if (!participant || participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Transform data to match form expectations if needed, or pass directly
|
||||
return (
|
||||
<ParticipantForm
|
||||
mode="edit"
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
/>
|
||||
);
|
||||
// Transform data to match form expectations if needed, or pass directly
|
||||
return (
|
||||
<ParticipantForm
|
||||
mode="edit"
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { ParticipantDocuments } from "./participant-documents";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -17,104 +23,129 @@ import { PageHeader } from "~/components/ui/page-header";
|
||||
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
|
||||
export default async function ParticipantDetailPage({
|
||||
params,
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={participant.participantCode}
|
||||
description={participant.name ?? "Unnamed Participant"}
|
||||
icon={Users}
|
||||
badges={[
|
||||
{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary"
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={participant.participantCode}
|
||||
description={participant.name ?? "Unnamed Participant"}
|
||||
icon={Users}
|
||||
badges={[
|
||||
{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/studies/${studyId}/participants/${participantId}/edit`}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<ParticipantConsentManager
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
participantName={participant.name}
|
||||
participantCode={participant.participantCode}
|
||||
consentGiven={participant.consentGiven}
|
||||
consentDate={participant.consentDate}
|
||||
existingConsent={participant.consents[0] ?? null}
|
||||
/>
|
||||
<EntityViewSection title="Participant Information" icon="Info">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Code</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Name</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid gap-6 grid-cols-1">
|
||||
<ParticipantConsentManager
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
consentGiven={participant.consentGiven}
|
||||
consentDate={participant.consentDate}
|
||||
existingConsent={participant.consents[0] ?? null}
|
||||
/>
|
||||
<EntityViewSection title="Participant Information" icon="Info">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Code</span>
|
||||
<span className="font-medium text-base">{participant.participantCode}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Email
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.email || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Name</span>
|
||||
<span className="font-medium text-base">{participant.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Added
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{new Date(participant.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Email</span>
|
||||
<span className="font-medium text-base">{participant.email || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Age</span>
|
||||
<span className="text-base font-medium">
|
||||
{(participant.demographics as any)?.age || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Added</span>
|
||||
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Gender
|
||||
</span>
|
||||
<span className="text-base font-medium capitalize">
|
||||
{(participant.demographics as any)?.gender?.replace(
|
||||
"_",
|
||||
" ",
|
||||
) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Age</span>
|
||||
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Gender</span>
|
||||
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files">
|
||||
<EntityViewSection title="Documents" icon="FileText">
|
||||
<ParticipantDocuments participantId={participantId} />
|
||||
</EntityViewSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityView>
|
||||
);
|
||||
<TabsContent value="files">
|
||||
<EntityViewSection title="Documents" icon="FileText">
|
||||
<ParticipantDocuments participantId={participantId} />
|
||||
</EntityViewSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,184 +4,192 @@ import { useState } from "react";
|
||||
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatBytes } from "~/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ParticipantDocumentsProps {
|
||||
participantId: string;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
export function ParticipantDocuments({
|
||||
participantId,
|
||||
}: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
|
||||
const { data: documents, isLoading } =
|
||||
api.files.listParticipantDocuments.useQuery({
|
||||
participantId,
|
||||
});
|
||||
|
||||
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
|
||||
const registerUpload = api.files.registerUpload.useMutation();
|
||||
const deleteDocument = api.files.deleteDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Document deleted");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
|
||||
// Since presigned URLs are for PUT, we can use a direct fetch
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 1. Get presigned URL
|
||||
const { url, storagePath } = await getPresignedUrl.mutateAsync({
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
participantId,
|
||||
});
|
||||
});
|
||||
|
||||
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
|
||||
const registerUpload = api.files.registerUpload.useMutation();
|
||||
const deleteDocument = api.files.deleteDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Document deleted");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
});
|
||||
|
||||
// Since presigned URLs are for PUT, we can use a direct fetch
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 1. Get presigned URL
|
||||
const { url, storagePath } = await getPresignedUrl.mutateAsync({
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
participantId,
|
||||
});
|
||||
// 3. Register in DB
|
||||
await registerUpload.mutateAsync({
|
||||
participantId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
storagePath,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
toast.success("File uploaded successfully");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to upload file");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
const handleDownload = async (storagePath: string, filename: string) => {
|
||||
// We would typically get a temporary download URL here
|
||||
// For now assuming public bucket or implementing a separate download procedure
|
||||
// Let's implement a quick procedure call right here via client or assume the server router has it.
|
||||
// I added getDownloadUrl to the router in previous steps.
|
||||
try {
|
||||
const { url } = await utils.client.files.getDownloadUrl.query({
|
||||
storagePath,
|
||||
});
|
||||
window.open(url, "_blank");
|
||||
} catch (e) {
|
||||
toast.error("Could not get download URL");
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Register in DB
|
||||
await registerUpload.mutateAsync({
|
||||
participantId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
storagePath,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
toast.success("File uploaded successfully");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to upload file");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (storagePath: string, filename: string) => {
|
||||
// We would typically get a temporary download URL here
|
||||
// For now assuming public bucket or implementing a separate download procedure
|
||||
// Let's implement a quick procedure call right here via client or assume the server router has it.
|
||||
// I added getDownloadUrl to the router in previous steps.
|
||||
try {
|
||||
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
|
||||
window.open(url, "_blank");
|
||||
} catch (e) {
|
||||
toast.error("Could not get download URL");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Manage consent forms and other files for this participant.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={isUploading} asChild>
|
||||
<label className="cursor-pointer">
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Upload PDF
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : documents?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||
<p>No documents uploaded yet.</p>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Manage consent forms and other files for this participant.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={isUploading} asChild>
|
||||
<label className="cursor-pointer">
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents?.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{doc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(doc.fileSize ?? 0)} • {new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(doc.storagePath, doc.name)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this file?")) {
|
||||
deleteDocument.mutate({ id: doc.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
Upload PDF
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : documents?.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||
<p>No documents uploaded yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents?.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{doc.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatBytes(doc.fileSize ?? 0)} •{" "}
|
||||
{new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(doc.storagePath, doc.name)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm("Are you sure you want to delete this file?")
|
||||
) {
|
||||
deleteDocument.mutate({ id: doc.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,111 +13,112 @@ import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
function AnalysisPageContent() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{
|
||||
label: trial?.experiment.name ?? "Trial",
|
||||
href: `/studies/${studyId}/trials`,
|
||||
},
|
||||
{ label: "Analysis" },
|
||||
]);
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{
|
||||
label: trial?.experiment.name ?? "Trial",
|
||||
href: `/studies/${studyId}/trials`,
|
||||
},
|
||||
{ label: "Analysis" },
|
||||
]);
|
||||
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading analysis...</div>
|
||||
</div>
|
||||
);
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
if (error || !trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description="Analyze trial results"
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
{error ? "Error Loading Trial" : "Trial Not Found"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error?.message || "The requested trial could not be found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customTrialData = {
|
||||
...trial,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
eventCount: (trial as any).eventCount,
|
||||
mediaCount: (trial as any).mediaCount,
|
||||
media: trial.media?.map(m => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TrialAnalysisView
|
||||
trial={customTrialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading analysis...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description="Analyze trial results"
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
{error ? "Error Loading Trial" : "Trial Not Found"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error?.message || "The requested trial could not be found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customTrialData = {
|
||||
...trial,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
eventCount: (trial as any).eventCount,
|
||||
mediaCount: (trial as any).mediaCount,
|
||||
media:
|
||||
trial.media?.map((m) => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined,
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<TrialAnalysisView
|
||||
trial={customTrialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrialAnalysisPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
Zap,
|
||||
ArrowLeft,
|
||||
User,
|
||||
FlaskConical,
|
||||
LineChart,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -144,7 +151,7 @@ function TrialDetailContent() {
|
||||
{
|
||||
label: trial.status.replace("_", " ").toUpperCase(),
|
||||
variant: getStatusBadgeVariant(trial.status),
|
||||
}
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
@@ -156,13 +163,13 @@ function TrialDetailContent() {
|
||||
)}
|
||||
{(trial.status === "in_progress" ||
|
||||
trial.status === "scheduled") && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
|
||||
|
||||
@@ -211,11 +211,7 @@ function WizardPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
return <div>{renderView()}</div>;
|
||||
}
|
||||
|
||||
export default function TrialWizardPage() {
|
||||
|
||||
Reference in New Issue
Block a user