mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Consolidate global routes into study-scoped architecture
Removed global participants, trials, and analytics routes. All entity management now flows through study-specific routes. Updated navigation, breadcrumbs, and forms. Added helpful redirect pages for moved routes. Eliminated duplicate table components and unified navigation patterns. Fixed dashboard route structure and layout inheritance.
This commit is contained in:
@@ -54,10 +54,10 @@ export function DashboardContent({
|
||||
...(canControl
|
||||
? [
|
||||
{
|
||||
title: "Schedule Trial",
|
||||
description: "Plan a new trial session",
|
||||
title: "Browse Studies",
|
||||
description: "View and manage studies",
|
||||
icon: Calendar,
|
||||
href: "/trials/new",
|
||||
href: "/studies",
|
||||
variant: "default" as const,
|
||||
},
|
||||
]
|
||||
@@ -84,8 +84,8 @@ export function DashboardContent({
|
||||
variant: "success" as const,
|
||||
...(canControl && {
|
||||
action: {
|
||||
label: "Control",
|
||||
href: "/trials?status=in_progress",
|
||||
label: "View",
|
||||
href: "/studies",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Users,
|
||||
UserCheck,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
@@ -72,16 +70,7 @@ const navigationItems = [
|
||||
url: "/experiments",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
title: "Participants",
|
||||
url: "/participants",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
url: "/trials",
|
||||
icon: TestTube,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "/plugins",
|
||||
|
||||
@@ -145,13 +145,6 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/trials`}>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
View Trials
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Experiment ID
|
||||
|
||||
@@ -126,19 +126,22 @@ export function ParticipantForm({
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Participants", href: "/participants" },
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
@@ -228,7 +231,7 @@ export function ParticipantForm({
|
||||
|
||||
try {
|
||||
await deleteParticipantMutation.mutateAsync({ id: participantId });
|
||||
router.push("/participants");
|
||||
router.push(`/studies/${contextStudyId}/participants`);
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to delete participant: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
@@ -483,8 +486,8 @@ export function ParticipantForm({
|
||||
mode={mode}
|
||||
entityName="Participant"
|
||||
entityNamePlural="Participants"
|
||||
backUrl="/participants"
|
||||
listUrl="/participants"
|
||||
backUrl={`/studies/${contextStudyId}/participants`}
|
||||
listUrl={`/studies/${contextStudyId}/participants`}
|
||||
title={
|
||||
mode === "create"
|
||||
? "Register New Participant"
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
createdAt: Date;
|
||||
trialCount: number;
|
||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
|
||||
function ParticipantActionsCell({ participant }: { participant: Participant }) {
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to delete participant "${participant.name ?? participant.participantCode}"?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement delete participant mutation
|
||||
toast.success("Participant deleted successfully");
|
||||
} catch {
|
||||
toast.error("Failed to delete participant");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(participant.id);
|
||||
toast.success("Participant ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
void navigator.clipboard.writeText(participant.participantCode);
|
||||
toast.success("Participant code copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{participant.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Participant ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyCode}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Participant Code
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!participant.consentGiven && (
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send Consent Form
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{participant.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Participant
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "participantCode",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Code" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/participants/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.getValue("participantCode")}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name;
|
||||
const email = row.original.email;
|
||||
return (
|
||||
<div className="max-w-[160px] space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className="truncate font-medium"
|
||||
title={name ?? "No name provided"}
|
||||
>
|
||||
{name ?? "No name provided"}
|
||||
</span>
|
||||
</div>
|
||||
{email && (
|
||||
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
|
||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate" title={email ?? ""}>
|
||||
{email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "consentGiven",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Consent" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const consentGiven = row.getValue("consentGiven");
|
||||
const consentDate = row.original.consentDate;
|
||||
|
||||
if (consentGiven) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 whitespace-nowrap text-green-800"
|
||||
title={
|
||||
consentDate
|
||||
? `Consented on ${consentDate.toLocaleDateString()}`
|
||||
: "Consented"
|
||||
}
|
||||
>
|
||||
Consented
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-100 whitespace-nowrap text-red-800"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
const consentGiven = row.getValue(id);
|
||||
if (value === "consented") return !!consentGiven;
|
||||
if (value === "pending") return !consentGiven;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trialCount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trials" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trialCount = row.original.trialCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-sm whitespace-nowrap">
|
||||
<TestTube className="text-muted-foreground h-3 w-3" />
|
||||
<span>{trialCount ?? 0}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <ParticipantActionsCell participant={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { participantsColumns, type Participant } from "./participants-columns";
|
||||
|
||||
export function ParticipantsDataTable() {
|
||||
const [consentFilter, setConsentFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: participantsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.participants.getUserParticipants.useQuery(
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh participants when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{ enabled: !!selectedStudyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Participants" },
|
||||
]
|
||||
: [{ label: "Participants" }]),
|
||||
]);
|
||||
|
||||
// Transform participants data to match the Participant type expected by columns
|
||||
const participants: Participant[] = React.useMemo(() => {
|
||||
if (!participantsData?.participants) return [];
|
||||
|
||||
return participantsData.participants.map((p) => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven:
|
||||
(p as unknown as { hasConsent?: boolean }).hasConsent ?? false,
|
||||
consentDate: (p as unknown as { latestConsent?: { signedAt: string } })
|
||||
.latestConsent?.signedAt
|
||||
? new Date(
|
||||
(
|
||||
p as unknown as { latestConsent: { signedAt: string } }
|
||||
).latestConsent.signedAt,
|
||||
)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: (p as unknown as { trialCount?: number }).trialCount ?? 0,
|
||||
userRole: undefined,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
}));
|
||||
}, [participantsData]);
|
||||
|
||||
// Consent filter options
|
||||
const consentOptions = [
|
||||
{ label: "All Participants", value: "all" },
|
||||
{ label: "Consented", value: "consented" },
|
||||
{ label: "Pending Consent", value: "pending" },
|
||||
];
|
||||
|
||||
// Filter participants based on selected filters
|
||||
const filteredParticipants = React.useMemo(() => {
|
||||
return participants.filter((participant) => {
|
||||
if (consentFilter === "all") return true;
|
||||
if (consentFilter === "consented") return participant.consentGiven;
|
||||
if (consentFilter === "pending") return !participant.consentGiven;
|
||||
return true;
|
||||
});
|
||||
}, [participants, consentFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={consentFilter} onValueChange={setConsentFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Consent Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{consentOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Participants"
|
||||
description="Manage participant registration, consent, and trial assignments"
|
||||
icon={Users}
|
||||
actions={
|
||||
<ActionButton href="/participants/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Participant
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Participants
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading participants."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Participants"
|
||||
description="Manage participant registration, consent, and trial assignments"
|
||||
icon={Users}
|
||||
actions={
|
||||
<ActionButton href="/participants/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Participant
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={participantsColumns}
|
||||
data={filteredParticipants}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search participants..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Trials", href: "/trials" },
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
@@ -426,8 +426,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
mode={mode}
|
||||
entityName="Trial"
|
||||
entityNamePlural="Trials"
|
||||
backUrl="/trials"
|
||||
listUrl="/trials"
|
||||
backUrl={`/studies/${contextStudyId}/trials`}
|
||||
listUrl={`/studies/${contextStudyId}/trials`}
|
||||
title={
|
||||
mode === "create"
|
||||
? "Schedule New Trial"
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
BarChart3,
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
StopCircle,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export type Trial = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
studyId: string;
|
||||
experimentId: string;
|
||||
participantId: string;
|
||||
wizardId: string | null;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
participantCode?: string;
|
||||
};
|
||||
wizard: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
duration?: number; // in minutes
|
||||
_count?: {
|
||||
actions: number;
|
||||
logs: number;
|
||||
};
|
||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||
canAccess?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canExecute?: boolean;
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
description: "Trial is scheduled for future execution",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
description: "Trial is currently running",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Trial has been completed successfully",
|
||||
},
|
||||
aborted: {
|
||||
label: "Aborted",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
description: "Trial was aborted before completion",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
description: "Trial failed due to an error",
|
||||
},
|
||||
};
|
||||
|
||||
function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const startTrialMutation = api.trials.start.useMutation();
|
||||
const completeTrialMutation = api.trials.complete.useMutation();
|
||||
const abortTrialMutation = api.trials.abort.useMutation();
|
||||
// const deleteTrialMutation = api.trials.delete.useMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// await deleteTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial deletion not yet implemented");
|
||||
// window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to delete trial");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(trial.id);
|
||||
toast.success("Trial ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial started successfully");
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
} catch {
|
||||
toast.error("Failed to start trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
// For now, pausing means completing the trial
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial paused/completed");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to pause trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTrial = async () => {
|
||||
if (window.confirm("Are you sure you want to stop this trial?")) {
|
||||
try {
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial stopped");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to stop trial");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = trial.status === "scheduled" && trial.canExecute;
|
||||
const canPause = trial.status === "in_progress" && trial.canExecute;
|
||||
const canStop = trial.status === "in_progress" && trial.canExecute;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{trial.canAccess ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details (Restricted)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{trial.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{canStart && (
|
||||
<DropdownMenuItem onClick={handleStartTrial}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canPause && (
|
||||
<DropdownMenuItem onClick={handlePauseTrial}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canStop && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleStopTrial}
|
||||
className="text-orange-600 focus:text-orange-600"
|
||||
>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
Stop Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/wizard`}>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/analysis`}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Trial ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
{trial.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Trial
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trial Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
return (
|
||||
<div className="max-w-[140px] min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.canAccess ? (
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
title={trial.name}
|
||||
>
|
||||
{trial.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className="text-muted-foreground block cursor-not-allowed truncate font-medium"
|
||||
title={`${trial.name} (View access restricted)`}
|
||||
>
|
||||
{trial.name}
|
||||
</div>
|
||||
)}
|
||||
{!trial.canAccess && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
|
||||
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
|
||||
>
|
||||
{trial.userRole === "observer" ? "View Only" : "Restricted"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const trial = row.original;
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${config.className} whitespace-nowrap`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{trial.userRole && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={`Your role in this study: ${trial.userRole}`}
|
||||
>
|
||||
{trial.userRole}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return value.includes(status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "participant",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Participant" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const participant = row.original.participant;
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
title={
|
||||
participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"
|
||||
}
|
||||
>
|
||||
{participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "experiment",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Experiment" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.original.experiment;
|
||||
return (
|
||||
<div className="flex max-w-[140px] items-center space-x-2">
|
||||
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/experiments/${experiment?.id ?? ""}`}
|
||||
className="truncate text-sm hover:underline"
|
||||
title={experiment?.name ?? "Unnamed Experiment"}
|
||||
>
|
||||
{experiment?.name ?? "Unnamed Experiment"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "wizard",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Wizard" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wizard = row.original.wizard;
|
||||
if (!wizard) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not assigned</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[120px] space-y-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={wizard.name ?? ""}
|
||||
>
|
||||
{wizard.name ?? ""}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={wizard.email ?? ""}
|
||||
>
|
||||
{wizard.email ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Scheduled" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
if (!date) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not scheduled</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
|
||||
if (
|
||||
trial.status === "completed" &&
|
||||
trial.startedAt &&
|
||||
trial.completedAt
|
||||
) {
|
||||
const duration = Math.round(
|
||||
(trial.completedAt.getTime() - trial.startedAt.getTime()) /
|
||||
(1000 * 60),
|
||||
);
|
||||
return <div className="text-sm whitespace-nowrap">{duration}m</div>;
|
||||
}
|
||||
|
||||
if (trial.status === "in_progress" && trial.startedAt) {
|
||||
const duration = Math.round(
|
||||
(Date.now() - trial.startedAt.getTime()) / (1000 * 60),
|
||||
);
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap text-blue-600">
|
||||
{duration}m
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trial.duration) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
~{trial.duration}m
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
header: "Data",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
const counts = trial._count;
|
||||
|
||||
return (
|
||||
<div className="flex space-x-3 text-sm">
|
||||
<div className="flex items-center space-x-1" title="Actions recorded">
|
||||
<TestTube className="text-muted-foreground h-3 w-3" />
|
||||
<span>{counts?.actions ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1" title="Log entries">
|
||||
<BarChart3 className="text-muted-foreground h-3 w-3" />
|
||||
<span>{counts?.logs ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
defaultHidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <TrialActionsCell trial={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Plus, TestTube, Eye } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
import { trialsColumns, type Trial } from "./trials-columns";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function TrialsDataTable() {
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: trialsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.trials.getUserTrials.useQuery(
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
studyId: selectedStudyId ?? undefined,
|
||||
status:
|
||||
statusFilter === "all"
|
||||
? undefined
|
||||
: (statusFilter as
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed"),
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds for real-time updates
|
||||
enabled: !!selectedStudyId, // Only fetch when a study is selected
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh trials when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{ enabled: !!selectedStudyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Trials" },
|
||||
]
|
||||
: [{ label: "Trials" }]),
|
||||
]);
|
||||
|
||||
// Transform trials data to match the Trial type expected by columns
|
||||
const trials: Trial[] = React.useMemo(() => {
|
||||
if (!trialsData?.trials) return [];
|
||||
|
||||
return trialsData.trials.map((trial) => ({
|
||||
id: trial.id,
|
||||
name: trial.notes
|
||||
? `Trial: ${trial.notes}`
|
||||
: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
description: trial.notes,
|
||||
status: trial.status,
|
||||
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : null,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
createdAt: trial.createdAt,
|
||||
updatedAt: trial.updatedAt,
|
||||
studyId: trial.experiment?.studyId ?? "",
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId ?? "",
|
||||
wizardId: trial.wizardId,
|
||||
study: {
|
||||
id: trial.experiment?.studyId ?? "",
|
||||
name: trial.experiment?.study?.name ?? "",
|
||||
},
|
||||
experiment: {
|
||||
id: trial.experimentId,
|
||||
name: trial.experiment?.name ?? "",
|
||||
},
|
||||
participant: {
|
||||
id: trial.participantId ?? "",
|
||||
name:
|
||||
trial.participant?.name ?? trial.participant?.participantCode ?? "",
|
||||
email: trial.participant?.email ?? "",
|
||||
},
|
||||
wizard: trial.wizard
|
||||
? {
|
||||
id: trial.wizard.id,
|
||||
name: trial.wizard.name,
|
||||
email: trial.wizard.email,
|
||||
}
|
||||
: null,
|
||||
duration: trial.duration ? Math.round(trial.duration / 60) : undefined,
|
||||
_count: {
|
||||
actions: trial._count?.events ?? 0,
|
||||
logs: trial._count?.mediaCaptures ?? 0,
|
||||
},
|
||||
userRole: trial.userRole,
|
||||
canAccess: trial.canAccess ?? false,
|
||||
canEdit:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "aborted"),
|
||||
canDelete:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed"),
|
||||
canExecute:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "in_progress"),
|
||||
}));
|
||||
}, [trialsData]);
|
||||
|
||||
// Status filter options
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "all" },
|
||||
{ label: "Scheduled", value: "scheduled" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Aborted", value: "aborted" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
];
|
||||
|
||||
// Filter trials based on selected filters
|
||||
const filteredTrials = React.useMemo(() => {
|
||||
return trials.filter((trial) => {
|
||||
const statusMatch =
|
||||
statusFilter === "all" || trial.status === statusFilter;
|
||||
return statusMatch;
|
||||
});
|
||||
}, [trials, statusFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Trials
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading your trials."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filteredTrials.some((trial) => !trial.canAccess) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="rounded-full bg-amber-100 p-1">
|
||||
<Eye className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
Limited Trial Access
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
Some trials are marked as “View Only” or
|
||||
“Restricted” because you have observer-level
|
||||
access to their studies. Only researchers, wizards, and study
|
||||
owners can view detailed trial information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={trialsColumns}
|
||||
data={filteredTrials}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search trials..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -113,7 +112,7 @@ export function WizardInterface({
|
||||
{ label: studyData.name, href: `/studies/${studyData.id}` },
|
||||
{ label: "Trials", href: `/studies/${studyData.id}/trials` },
|
||||
]
|
||||
: [{ label: "Trials", href: "/trials" }]),
|
||||
: []),
|
||||
{
|
||||
label: `Trial ${trial.participant.participantCode}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
|
||||
Reference in New Issue
Block a user