Consolidate global routes into study-scoped architecture

Removed global participants, trials, and analytics routes. All entity
management now flows through study-specific routes. Updated navigation,
breadcrumbs, and forms. Added helpful redirect pages for moved routes.
Eliminated duplicate table components and unified navigation patterns.
Fixed dashboard route structure and layout inheritance.
This commit is contained in:
2025-09-23 23:52:34 -04:00
parent 4acbec6288
commit c2bfeb8db2
29 changed files with 344 additions and 3896 deletions

View File

@@ -54,10 +54,10 @@ export function DashboardContent({
...(canControl
? [
{
title: "Schedule Trial",
description: "Plan a new trial session",
title: "Browse Studies",
description: "View and manage studies",
icon: Calendar,
href: "/trials/new",
href: "/studies",
variant: "default" as const,
},
]
@@ -84,8 +84,8 @@ export function DashboardContent({
variant: "success" as const,
...(canControl && {
action: {
label: "Control",
href: "/trials?status=in_progress",
label: "View",
href: "/studies",
},
}),
},

View File

@@ -14,9 +14,7 @@ import {
MoreHorizontal,
Puzzle,
Settings,
Users,
UserCheck,
TestTube,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
@@ -72,16 +70,7 @@ const navigationItems = [
url: "/experiments",
icon: FlaskConical,
},
{
title: "Participants",
url: "/participants",
icon: Users,
},
{
title: "Trials",
url: "/trials",
icon: TestTube,
},
{
title: "Plugins",
url: "/plugins",

View File

@@ -145,13 +145,6 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/trials`}>
<TestTube className="mr-2 h-4 w-4" />
View Trials
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Experiment ID

View File

@@ -126,19 +126,22 @@ export function ParticipantForm({
? [
{
label: participant.name ?? participant.participantCode,
href: `/participants/${participant.id}`,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
: [
{ label: "Participants", href: "/participants" },
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/participants/${participant.id}`,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
@@ -228,7 +231,7 @@ export function ParticipantForm({
try {
await deleteParticipantMutation.mutateAsync({ id: participantId });
router.push("/participants");
router.push(`/studies/${contextStudyId}/participants`);
} catch (error) {
setError(
`Failed to delete participant: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -483,8 +486,8 @@ export function ParticipantForm({
mode={mode}
entityName="Participant"
entityNamePlural="Participants"
backUrl="/participants"
listUrl="/participants"
backUrl={`/studies/${contextStudyId}/participants`}
listUrl={`/studies/${contextStudyId}/participants`}
title={
mode === "create"
? "Register New Participant"

View File

@@ -1,283 +0,0 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
Copy,
Edit,
Eye,
Mail,
MoreHorizontal,
TestTube,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export type Participant = {
id: string;
participantCode: string;
email: string | null;
name: string | null;
consentGiven: boolean;
consentDate: Date | null;
createdAt: Date;
trialCount: number;
userRole?: "owner" | "researcher" | "wizard" | "observer";
canEdit?: boolean;
canDelete?: boolean;
};
function ParticipantActionsCell({ participant }: { participant: Participant }) {
const handleDelete = async () => {
if (
window.confirm(
`Are you sure you want to delete participant "${participant.name ?? participant.participantCode}"?`,
)
) {
try {
// TODO: Implement delete participant mutation
toast.success("Participant deleted successfully");
} catch {
toast.error("Failed to delete participant");
}
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(participant.id);
toast.success("Participant ID copied to clipboard");
};
const handleCopyCode = () => {
void navigator.clipboard.writeText(participant.participantCode);
toast.success("Participant code copied to clipboard");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
{participant.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Participant ID
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyCode}>
<Copy className="mr-2 h-4 w-4" />
Copy Participant Code
</DropdownMenuItem>
{!participant.consentGiven && (
<DropdownMenuItem>
<Mail className="mr-2 h-4 w-4" />
Send Consent Form
</DropdownMenuItem>
)}
{participant.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Participant
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const participantsColumns: ColumnDef<Participant>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "participantCode",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Code" />
),
cell: ({ row }) => (
<div className="font-mono text-sm">
<Link
href={`/participants/${row.original.id}`}
className="hover:underline"
>
{row.getValue("participantCode")}
</Link>
</div>
),
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const name = row.original.name;
const email = row.original.email;
return (
<div className="max-w-[160px] space-y-1">
<div className="flex items-center space-x-2">
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span
className="truncate font-medium"
title={name ?? "No name provided"}
>
{name ?? "No name provided"}
</span>
</div>
{email && (
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
<Mail className="h-3 w-3 flex-shrink-0" />
<span className="truncate" title={email ?? ""}>
{email ?? ""}
</span>
</div>
)}
</div>
);
},
},
{
accessorKey: "consentGiven",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Consent" />
),
cell: ({ row }) => {
const consentGiven = row.getValue("consentGiven");
const consentDate = row.original.consentDate;
if (consentGiven) {
return (
<Badge
variant="secondary"
className="bg-green-100 whitespace-nowrap text-green-800"
title={
consentDate
? `Consented on ${consentDate.toLocaleDateString()}`
: "Consented"
}
>
Consented
</Badge>
);
}
return (
<Badge
variant="secondary"
className="bg-red-100 whitespace-nowrap text-red-800"
>
Pending
</Badge>
);
},
filterFn: (row, id, value) => {
const consentGiven = row.getValue(id);
if (value === "consented") return !!consentGiven;
if (value === "pending") return !consentGiven;
return true;
},
},
{
accessorKey: "trialCount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trials" />
),
cell: ({ row }) => {
const trialCount = row.original.trialCount;
return (
<div className="flex items-center space-x-1 text-sm whitespace-nowrap">
<TestTube className="text-muted-foreground h-3 w-3" />
<span>{trialCount ?? 0}</span>
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.original.createdAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
</div>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <ParticipantActionsCell participant={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -1,189 +0,0 @@
"use client";
import { Plus, Users } from "lucide-react";
import React from "react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
import { participantsColumns, type Participant } from "./participants-columns";
export function ParticipantsDataTable() {
const [consentFilter, setConsentFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
const {
data: participantsData,
isLoading,
error,
refetch,
} = api.participants.getUserParticipants.useQuery(
{
page: 1,
limit: 50,
},
{
refetchOnWindowFocus: false,
},
);
// Auto-refresh participants when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Participants" },
]
: [{ label: "Participants" }]),
]);
// Transform participants data to match the Participant type expected by columns
const participants: Participant[] = React.useMemo(() => {
if (!participantsData?.participants) return [];
return participantsData.participants.map((p) => ({
id: p.id,
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven:
(p as unknown as { hasConsent?: boolean }).hasConsent ?? false,
consentDate: (p as unknown as { latestConsent?: { signedAt: string } })
.latestConsent?.signedAt
? new Date(
(
p as unknown as { latestConsent: { signedAt: string } }
).latestConsent.signedAt,
)
: null,
createdAt: p.createdAt,
trialCount: (p as unknown as { trialCount?: number }).trialCount ?? 0,
userRole: undefined,
canEdit: true,
canDelete: true,
}));
}, [participantsData]);
// Consent filter options
const consentOptions = [
{ label: "All Participants", value: "all" },
{ label: "Consented", value: "consented" },
{ label: "Pending Consent", value: "pending" },
];
// Filter participants based on selected filters
const filteredParticipants = React.useMemo(() => {
return participants.filter((participant) => {
if (consentFilter === "all") return true;
if (consentFilter === "consented") return participant.consentGiven;
if (consentFilter === "pending") return !participant.consentGiven;
return true;
});
}, [participants, consentFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={consentFilter} onValueChange={setConsentFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Consent Status" />
</SelectTrigger>
<SelectContent>
{consentOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// Show error state
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Participants"
description="Manage participant registration, consent, and trial assignments"
icon={Users}
actions={
<ActionButton href="/participants/new">
<Plus className="mr-2 h-4 w-4" />
Add Participant
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Participants
</h3>
<p className="mb-4">
{error.message || "An error occurred while loading participants."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Participants"
description="Manage participant registration, consent, and trial assignments"
icon={Users}
actions={
<ActionButton href="/participants/new">
<Plus className="mr-2 h-4 w-4" />
Add Participant
</ActionButton>
}
/>
<div className="space-y-4">
{/* Data Table */}
<DataTable
columns={participantsColumns}
data={filteredParticipants}
searchKey="name"
searchPlaceholder="Search participants..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -112,7 +112,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
: [{ label: "New Trial" }]),
]
: [
{ label: "Trials", href: "/trials" },
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
@@ -426,8 +426,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
mode={mode}
entityName="Trial"
entityNamePlural="Trials"
backUrl="/trials"
listUrl="/trials"
backUrl={`/studies/${contextStudyId}/trials`}
listUrl={`/studies/${contextStudyId}/trials`}
title={
mode === "create"
? "Schedule New Trial"

View File

@@ -1,572 +0,0 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
BarChart3,
Copy,
Edit,
Eye,
FlaskConical,
MoreHorizontal,
Pause,
Play,
StopCircle,
TestTube,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export type Trial = {
id: string;
name: string;
description: string | null;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
studyId: string;
experimentId: string;
participantId: string;
wizardId: string | null;
study: {
id: string;
name: string;
};
experiment: {
id: string;
name: string;
};
participant: {
id: string;
name: string;
email: string;
participantCode?: string;
};
wizard: {
id: string;
name: string | null;
email: string;
} | null;
duration?: number; // in minutes
_count?: {
actions: number;
logs: number;
};
userRole?: "owner" | "researcher" | "wizard" | "observer";
canAccess?: boolean;
canEdit?: boolean;
canDelete?: boolean;
canExecute?: boolean;
};
const statusConfig = {
scheduled: {
label: "Scheduled",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
description: "Trial is scheduled for future execution",
},
in_progress: {
label: "In Progress",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
description: "Trial is currently running",
},
completed: {
label: "Completed",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Trial has been completed successfully",
},
aborted: {
label: "Aborted",
className: "bg-red-100 text-red-800 hover:bg-red-200",
description: "Trial was aborted before completion",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800 hover:bg-red-200",
description: "Trial failed due to an error",
},
};
function TrialActionsCell({ trial }: { trial: Trial }) {
const startTrialMutation = api.trials.start.useMutation();
const completeTrialMutation = api.trials.complete.useMutation();
const abortTrialMutation = api.trials.abort.useMutation();
// const deleteTrialMutation = api.trials.delete.useMutation();
const handleDelete = async () => {
if (
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
) {
try {
// await deleteTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial deletion not yet implemented");
// window.location.reload();
} catch {
toast.error("Failed to delete trial");
}
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(trial.id);
toast.success("Trial ID copied to clipboard");
};
const handleStartTrial = async () => {
try {
await startTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial started successfully");
window.location.href = `/trials/${trial.id}/wizard`;
} catch {
toast.error("Failed to start trial");
}
};
const handlePauseTrial = async () => {
try {
// For now, pausing means completing the trial
await completeTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial paused/completed");
window.location.reload();
} catch {
toast.error("Failed to pause trial");
}
};
const handleStopTrial = async () => {
if (window.confirm("Are you sure you want to stop this trial?")) {
try {
await abortTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial stopped");
window.location.reload();
} catch {
toast.error("Failed to stop trial");
}
}
};
const canStart = trial.status === "scheduled" && trial.canExecute;
const canPause = trial.status === "in_progress" && trial.canExecute;
const canStop = trial.status === "in_progress" && trial.canExecute;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
{trial.canAccess ? (
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Eye className="mr-2 h-4 w-4" />
View Details (Restricted)
</DropdownMenuItem>
)}
{trial.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Trial
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{canStart && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</DropdownMenuItem>
)}
{canPause && (
<DropdownMenuItem onClick={handlePauseTrial}>
<Pause className="mr-2 h-4 w-4" />
Pause Trial
</DropdownMenuItem>
)}
{canStop && (
<DropdownMenuItem
onClick={handleStopTrial}
className="text-orange-600 focus:text-orange-600"
>
<StopCircle className="mr-2 h-4 w-4" />
Stop Trial
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/wizard`}>
<TestTube className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/analysis`}>
<BarChart3 className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Trial ID
</DropdownMenuItem>
{trial.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Trial
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const trialsColumns: ColumnDef<Trial>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trial Name" />
),
cell: ({ row }) => {
const trial = row.original;
return (
<div className="max-w-[140px] min-w-0">
<div className="flex items-center gap-2">
{trial.canAccess ? (
<Link
href={`/trials/${trial.id}`}
className="block truncate font-medium hover:underline"
title={trial.name}
>
{trial.name}
</Link>
) : (
<div
className="text-muted-foreground block cursor-not-allowed truncate font-medium"
title={`${trial.name} (View access restricted)`}
>
{trial.name}
</div>
)}
{!trial.canAccess && (
<Badge
variant="outline"
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
>
{trial.userRole === "observer" ? "View Only" : "Restricted"}
</Badge>
)}
</div>
</div>
);
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status");
const trial = row.original;
const config = statusConfig[status as keyof typeof statusConfig];
return (
<div className="flex flex-col gap-1">
<Badge
variant="secondary"
className={`${config.className} whitespace-nowrap`}
title={config.description}
>
{config.label}
</Badge>
{trial.userRole && (
<Badge
variant="outline"
className="text-xs"
title={`Your role in this study: ${trial.userRole}`}
>
{trial.userRole}
</Badge>
)}
</div>
);
},
filterFn: (row, id, value: string[]) => {
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return value.includes(status);
},
},
{
accessorKey: "participant",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Participant" />
),
cell: ({ row }) => {
const participant = row.original.participant;
return (
<div className="max-w-[120px]">
<div className="flex items-center space-x-1">
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span
className="truncate text-sm font-medium"
title={
participant?.name ??
participant?.participantCode ??
"Unnamed Participant"
}
>
{participant?.name ??
participant?.participantCode ??
"Unnamed Participant"}
</span>
</div>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "experiment",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Experiment" />
),
cell: ({ row }) => {
const experiment = row.original.experiment;
return (
<div className="flex max-w-[140px] items-center space-x-2">
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Link
href={`/experiments/${experiment?.id ?? ""}`}
className="truncate text-sm hover:underline"
title={experiment?.name ?? "Unnamed Experiment"}
>
{experiment?.name ?? "Unnamed Experiment"}
</Link>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "wizard",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Wizard" />
),
cell: ({ row }) => {
const wizard = row.original.wizard;
if (!wizard) {
return (
<span className="text-muted-foreground text-sm">Not assigned</span>
);
}
return (
<div className="max-w-[120px] space-y-1">
<div
className="truncate text-sm font-medium"
title={wizard.name ?? ""}
>
{wizard.name ?? ""}
</div>
<div
className="text-muted-foreground truncate text-xs"
title={wizard.email ?? ""}
>
{wizard.email ?? ""}
</div>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "scheduledAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scheduled" />
),
cell: ({ row }) => {
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
if (!date) {
return (
<span className="text-muted-foreground text-sm">Not scheduled</span>
);
}
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => {
const trial = row.original;
if (
trial.status === "completed" &&
trial.startedAt &&
trial.completedAt
) {
const duration = Math.round(
(trial.completedAt.getTime() - trial.startedAt.getTime()) /
(1000 * 60),
);
return <div className="text-sm whitespace-nowrap">{duration}m</div>;
}
if (trial.status === "in_progress" && trial.startedAt) {
const duration = Math.round(
(Date.now() - trial.startedAt.getTime()) / (1000 * 60),
);
return (
<div className="text-sm whitespace-nowrap text-blue-600">
{duration}m
</div>
);
}
if (trial.duration) {
return (
<div className="text-muted-foreground text-sm whitespace-nowrap">
~{trial.duration}m
</div>
);
}
return <span className="text-muted-foreground text-sm">-</span>;
},
enableSorting: false,
},
{
id: "stats",
header: "Data",
cell: ({ row }) => {
const trial = row.original;
const counts = trial._count;
return (
<div className="flex space-x-3 text-sm">
<div className="flex items-center space-x-1" title="Actions recorded">
<TestTube className="text-muted-foreground h-3 w-3" />
<span>{counts?.actions ?? 0}</span>
</div>
<div className="flex items-center space-x-1" title="Log entries">
<BarChart3 className="text-muted-foreground h-3 w-3" />
<span>{counts?.logs ?? 0}</span>
</div>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <TrialActionsCell trial={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -1,271 +0,0 @@
"use client";
import React from "react";
import { Plus, TestTube, Eye } from "lucide-react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { trialsColumns, type Trial } from "./trials-columns";
import { api } from "~/trpc/react";
export function TrialsDataTable() {
const [statusFilter, setStatusFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
const {
data: trialsData,
isLoading,
error,
refetch,
} = api.trials.getUserTrials.useQuery(
{
page: 1,
limit: 50,
studyId: selectedStudyId ?? undefined,
status:
statusFilter === "all"
? undefined
: (statusFilter as
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed"),
},
{
refetchOnWindowFocus: false,
refetchInterval: 30000, // Refetch every 30 seconds for real-time updates
enabled: !!selectedStudyId, // Only fetch when a study is selected
},
);
// Auto-refresh trials when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Trials" },
]
: [{ label: "Trials" }]),
]);
// Transform trials data to match the Trial type expected by columns
const trials: Trial[] = React.useMemo(() => {
if (!trialsData?.trials) return [];
return trialsData.trials.map((trial) => ({
id: trial.id,
name: trial.notes
? `Trial: ${trial.notes}`
: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
description: trial.notes,
status: trial.status,
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : null,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
createdAt: trial.createdAt,
updatedAt: trial.updatedAt,
studyId: trial.experiment?.studyId ?? "",
experimentId: trial.experimentId,
participantId: trial.participantId ?? "",
wizardId: trial.wizardId,
study: {
id: trial.experiment?.studyId ?? "",
name: trial.experiment?.study?.name ?? "",
},
experiment: {
id: trial.experimentId,
name: trial.experiment?.name ?? "",
},
participant: {
id: trial.participantId ?? "",
name:
trial.participant?.name ?? trial.participant?.participantCode ?? "",
email: trial.participant?.email ?? "",
},
wizard: trial.wizard
? {
id: trial.wizard.id,
name: trial.wizard.name,
email: trial.wizard.email,
}
: null,
duration: trial.duration ? Math.round(trial.duration / 60) : undefined,
_count: {
actions: trial._count?.events ?? 0,
logs: trial._count?.mediaCaptures ?? 0,
},
userRole: trial.userRole,
canAccess: trial.canAccess ?? false,
canEdit:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "aborted"),
canDelete:
trial.canAccess &&
(trial.status === "scheduled" ||
trial.status === "aborted" ||
trial.status === "failed"),
canExecute:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "in_progress"),
}));
}, [trialsData]);
// Status filter options
const statusOptions = [
{ label: "All Statuses", value: "all" },
{ label: "Scheduled", value: "scheduled" },
{ label: "In Progress", value: "in_progress" },
{ label: "Completed", value: "completed" },
{ label: "Aborted", value: "aborted" },
{ label: "Failed", value: "failed" },
];
// Filter trials based on selected filters
const filteredTrials = React.useMemo(() => {
return trials.filter((trial) => {
const statusMatch =
statusFilter === "all" || trial.status === statusFilter;
return statusMatch;
});
}, [trials, statusFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Trials"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
Schedule Trial
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Trials
</h3>
<p className="mb-4">
{error.message || "An error occurred while loading your trials."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Trials"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
Schedule Trial
</ActionButton>
}
/>
<div className="space-y-4">
{filteredTrials.some((trial) => !trial.canAccess) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<div className="rounded-full bg-amber-100 p-1">
<Eye className="h-4 w-4 text-amber-600" />
</div>
</div>
<div>
<h3 className="text-sm font-medium text-amber-800">
Limited Trial Access
</h3>
<p className="mt-1 text-sm text-amber-700">
Some trials are marked as &ldquo;View Only&rdquo; or
&ldquo;Restricted&rdquo; because you have observer-level
access to their studies. Only researchers, wizards, and study
owners can view detailed trial information.
</p>
</div>
</div>
</div>
)}
<DataTable
columns={trialsColumns}
data={filteredTrials}
searchKey="name"
searchPlaceholder="Search trials..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import {
User,
Activity,
Zap,
Settings,
} from "lucide-react";
@@ -113,7 +112,7 @@ export function WizardInterface({
{ label: studyData.name, href: `/studies/${studyData.id}` },
{ label: "Trials", href: `/studies/${studyData.id}/trials` },
]
: [{ label: "Trials", href: "/trials" }]),
: []),
{
label: `Trial ${trial.participant.participantCode}`,
href: `/trials/${trial.id}`,