mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables.
This commit is contained in:
@@ -1,190 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Search,
|
||||
Filter,
|
||||
PlayCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
User,
|
||||
LayoutGrid
|
||||
} from "lucide-react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { api } from "~/trpc/react";
|
||||
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
// -- Sub-Components --
|
||||
|
||||
function AnalyticsContent({
|
||||
selectedTrialId,
|
||||
setSelectedTrialId,
|
||||
trialsList,
|
||||
isLoadingList
|
||||
}: {
|
||||
selectedTrialId: string | null;
|
||||
setSelectedTrialId: (id: string | null) => void;
|
||||
trialsList: any[];
|
||||
isLoadingList: boolean;
|
||||
}) {
|
||||
|
||||
// Fetch full details of selected trial
|
||||
const {
|
||||
data: selectedTrial,
|
||||
isLoading: isLoadingTrial,
|
||||
error: trialError
|
||||
} = api.trials.get.useQuery(
|
||||
{ id: selectedTrialId! },
|
||||
{ enabled: !!selectedTrialId }
|
||||
);
|
||||
|
||||
// Transform trial data
|
||||
const trialData = selectedTrial ? {
|
||||
...selectedTrial,
|
||||
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
|
||||
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
|
||||
eventCount: (selectedTrial as any).eventCount,
|
||||
mediaCount: (selectedTrial as any).mediaCount,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col">
|
||||
{selectedTrialId ? (
|
||||
isLoadingTrial ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
|
||||
<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 trial data...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : trialError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
|
||||
<div className="max-w-md text-center">
|
||||
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
|
||||
<p className="text-sm opacity-80">{trialError.message}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
|
||||
Return to Overview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : trialData ? (
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
|
||||
<StudyOverviewPlaceholder
|
||||
trials={trialsList ?? []}
|
||||
onSelect={(id) => setSelectedTrialId(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
|
||||
const recentTrials = [...trials].sort((a, b) =>
|
||||
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
|
||||
).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="h-full p-8 grid place-items-center bg-muted/5">
|
||||
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
|
||||
{/* Left: Illustration / Prompt */}
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
|
||||
<BarChart3 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Select a session from the top right to review video recordings, event logs, and metrics.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
Feature-rich playback
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
Synchronized timeline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Recent Sessions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<div className="px-4 pb-4 space-y-1">
|
||||
{recentTrials.map(trial => (
|
||||
<button
|
||||
key={trial.id}
|
||||
onClick={() => onSelect(trial.id)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
|
||||
{trial.sessionNumber}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{trial.participant?.participantCode ?? "Unknown"}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
|
||||
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}>
|
||||
{trial.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(trial.createdAt).toLocaleDateString()}
|
||||
<span className="text-muted-foreground top-[1px] relative text-[10px]">•</span>
|
||||
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
{recentTrials.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No sessions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Main Page --
|
||||
import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
|
||||
|
||||
export default function StudyAnalyticsPage() {
|
||||
const params = useParams();
|
||||
@@ -192,11 +17,8 @@ export default function StudyAnalyticsPage() {
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// State lifted up
|
||||
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
|
||||
|
||||
// Fetch list of trials for the selector
|
||||
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
|
||||
// Fetch list of trials
|
||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
);
|
||||
@@ -217,50 +39,30 @@ export default function StudyAnalyticsPage() {
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
|
||||
<div className="flex-none">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Analyze trial data and replay sessions"
|
||||
title="Analysis"
|
||||
description="View and analyze session data across all trials"
|
||||
icon={BarChart3}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Session Selector in Header */}
|
||||
<div className="w-[300px]">
|
||||
<Select
|
||||
value={selectedTrialId ?? "overview"}
|
||||
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select Session" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[400px]" align="end">
|
||||
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
|
||||
Show Study Overview
|
||||
</SelectItem>
|
||||
{trialsList?.map((trial) => (
|
||||
<SelectItem key={trial.id} value={trial.id} className="text-xs">
|
||||
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
|
||||
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 bg-transparent">
|
||||
<div className="bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
<AnalyticsContent
|
||||
selectedTrialId={selectedTrialId}
|
||||
setSelectedTrialId={setSelectedTrialId}
|
||||
trialsList={trialsList ?? []}
|
||||
isLoadingList={isLoadingList}
|
||||
/>
|
||||
{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>
|
||||
</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),
|
||||
}))} />
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Button } from "~/components/ui/button";
|
||||
import { Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
@@ -61,6 +63,13 @@ export default async function ParticipantDetailPage({
|
||||
|
||||
<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>
|
||||
|
||||
@@ -95,25 +95,10 @@ function AnalysisPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial Details
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
<TrialAnalysisView
|
||||
trial={trialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
319
src/components/analytics/study-analytics-data-table.tsx
Normal file
319
src/components/analytics/study-analytics-data-table.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Calendar,
|
||||
Clock,
|
||||
Activity,
|
||||
Eye,
|
||||
Video
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export type AnalyticsTrial = {
|
||||
id: string;
|
||||
sessionNumber: number;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
eventCount: number;
|
||||
mediaCount: number;
|
||||
experimentId: string;
|
||||
participant: {
|
||||
participantCode: string;
|
||||
};
|
||||
experiment: {
|
||||
name: string;
|
||||
studyId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<AnalyticsTrial>[] = [
|
||||
{
|
||||
accessorKey: "sessionNumber",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Session
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "participant.participantCode",
|
||||
header: "Participant",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize ${status === "completed"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: status === "in_progress"
|
||||
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
: "bg-slate-500/10 text-slate-500 border-slate-500/20"
|
||||
}`}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Date
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.getValue("createdAt"));
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{date.toLocaleDateString()}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => {
|
||||
const duration = row.getValue("duration") as number | null;
|
||||
if (!duration) return <span className="text-muted-foreground">-</span>;
|
||||
const m = Math.floor(duration / 60);
|
||||
const s = Math.floor(duration % 60);
|
||||
return <div className="font-mono">{`${m}m ${s}s`}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "eventCount",
|
||||
header: "Events",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{row.getValue("eventCount")}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "mediaCount",
|
||||
header: "Media",
|
||||
cell: ({ row }) => {
|
||||
const count = row.getValue("mediaCount") as number;
|
||||
if (count === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Video className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
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>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
|
||||
View Trial Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface StudyAnalyticsDataTableProps {
|
||||
data: AnalyticsTrial[];
|
||||
}
|
||||
|
||||
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter participants..."
|
||||
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -243,8 +243,17 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.original;
|
||||
cell: ({ row }) => <ExperimentActions experiment={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
||||
const utils = api.useUtils();
|
||||
const deleteMutation = api.experiments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.experiments.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -256,51 +265,34 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
Edit Metadata
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
Design
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-700"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this experiment?")) {
|
||||
deleteMutation.mutate({ id: experiment.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ExperimentsTable() {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Experiment = {
|
||||
id: string;
|
||||
@@ -78,27 +79,23 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
const handleDelete = async () => {
|
||||
const utils = api.useUtils();
|
||||
const deleteMutation = api.experiments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment deleted successfully");
|
||||
utils.experiments.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete experiment: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement delete experiment mutation
|
||||
toast.success("Experiment deleted successfully");
|
||||
} catch {
|
||||
toast.error("Failed to delete experiment");
|
||||
deleteMutation.mutate({ id: experiment.id });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(experiment.id);
|
||||
toast.success("Experiment ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = () => {
|
||||
// Navigate to new trial creation with this experiment pre-selected
|
||||
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -111,45 +108,20 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Metadata
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
Open Designer
|
||||
Design
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{experiment.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Experiment
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{experiment.status === "ready" && (
|
||||
<DropdownMenuItem onClick={handleStartTrial}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start New Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Experiment ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
{experiment.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Experiment
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date as Date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
|
||||
190
src/components/participants/ConsentUploadForm.tsx
Normal file
190
src/components/participants/ConsentUploadForm.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "~/components/ui/use-toast";
|
||||
import { cn } from "~/lib/utils";
|
||||
import axios from "axios";
|
||||
|
||||
interface ConsentUploadFormProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
consentFormId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConsentUploadForm({
|
||||
studyId,
|
||||
participantId,
|
||||
consentFormId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: ConsentUploadFormProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Mutations
|
||||
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const selectedFile = e.target.files[0];
|
||||
// Validate size (10MB)
|
||||
if (selectedFile.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: "File too large",
|
||||
description: "Maximum file size is 10MB",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Validate type
|
||||
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
|
||||
if (!allowedTypes.includes(selectedFile.type)) {
|
||||
toast({
|
||||
title: "Invalid file type",
|
||||
description: "Please upload a PDF, PNG, or JPG file",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// 1. Get Presigned URL
|
||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||
studyId,
|
||||
participantId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO
|
||||
await axios.put(url, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
setUploadProgress(percentCompleted);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Record Consent in DB
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId,
|
||||
storagePath: key,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Consent Recorded",
|
||||
description: "The consent form has been uploaded and recorded successfully.",
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
toast({
|
||||
title: "Upload Failed",
|
||||
description: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!file ? (
|
||||
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
|
||||
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
|
||||
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
||||
Drag and drop or click to select<br />
|
||||
PDF, PNG, JPG up to 10MB
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
id="consent-file-upload"
|
||||
className="hidden"
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
|
||||
Select File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4 bg-muted/5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isUploading && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload & Record
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/participants/ParticipantConsentManager.tsx
Normal file
161
src/components/participants/ParticipantConsentManager.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { ConsentUploadForm } from "./ConsentUploadForm";
|
||||
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ParticipantConsentManagerProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
existingConsent: {
|
||||
id: string;
|
||||
storagePath: string | null;
|
||||
signedAt: Date;
|
||||
consentForm: {
|
||||
title: string;
|
||||
version: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ParticipantConsentManager({
|
||||
studyId,
|
||||
participantId,
|
||||
consentGiven,
|
||||
consentDate,
|
||||
existingConsent,
|
||||
}: ParticipantConsentManagerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Fetch active consent forms to know which form to sign/upload against
|
||||
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
|
||||
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
|
||||
|
||||
// Helper to get download URL
|
||||
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
|
||||
{ storagePath: existingConsent?.storagePath ?? "" },
|
||||
{ enabled: false }
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!existingConsent?.storagePath) return;
|
||||
try {
|
||||
const result = await fetchDownloadUrl();
|
||||
if (result.data?.url) {
|
||||
window.open(result.data.url, "_blank");
|
||||
} else {
|
||||
toast.error("Error", { description: "Could not retrieve document" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error", { description: "Failed to get download URL" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setIsOpen(false);
|
||||
utils.participants.get.invalidate({ id: participantId });
|
||||
toast.success("Success", { description: "Consent recorded successfully" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Consent Status
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage participant consent and forms.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={consentGiven ? "default" : "destructive"}>
|
||||
{consentGiven ? "Consent Given" : "Not Recorded"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
{consentGiven ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
|
||||
</div>
|
||||
{existingConsent && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
No consent recorded for this participant.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{consentGiven && existingConsent?.storagePath && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{consentGiven ? "Update Consent" : "Record Consent"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Signed Consent Form</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload the signed PDF or image of the consent form for this participant.
|
||||
{activeForm && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
Active Form: {activeForm.title} (v{activeForm.version})
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{activeForm ? (
|
||||
<ConsentUploadForm
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
consentFormId={activeForm.id}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-4 text-center text-muted-foreground">
|
||||
No active consent form found for this study. Please create one first.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -148,30 +148,7 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -195,23 +172,12 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(participant.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit participant
|
||||
</Link >
|
||||
</DropdownMenuItem >
|
||||
<DropdownMenuItem disabled>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send consent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -331,33 +331,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
if (!date)
|
||||
return <span className="text-muted-foreground text-sm">Unknown</span>;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
|
||||
107
src/components/trials/analysis/events-columns.tsx
Normal file
107
src/components/trials/analysis/events-columns.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
|
||||
|
||||
// Define the shape of our data (matching schema)
|
||||
export interface TrialEvent {
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: Date | string;
|
||||
data: any;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
// Helper to format timestamp relative to start
|
||||
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
|
||||
if (!startTime) return "--:--";
|
||||
const date = new Date(timestamp);
|
||||
const diff = date.getTime() - startTime.getTime();
|
||||
if (diff < 0) return "0:00";
|
||||
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
// Optional: extended formatting for longer durations
|
||||
const h = Math.floor(m / 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
||||
{
|
||||
id: "timestamp",
|
||||
header: "Time",
|
||||
accessorKey: "timestamp",
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.timestamp);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{formatRelativeTime(row.original.timestamp, startTime)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
|
||||
{date.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "eventType",
|
||||
header: "Event Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("eventType") as string;
|
||||
const isError = type.includes("error");
|
||||
const isIntervention = type.includes("intervention");
|
||||
const isRobot = type.includes("robot");
|
||||
const isStep = type.includes("step");
|
||||
|
||||
let Icon = Activity;
|
||||
if (isError) Icon = AlertTriangle;
|
||||
else if (isIntervention) Icon = User; // Wizard/Intervention often User
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (type.includes("note")) Icon = MessageSquare;
|
||||
else if (type.includes("completed")) Icon = CheckCircle;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(
|
||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
|
||||
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "data",
|
||||
header: "Details",
|
||||
cell: ({ row }) => {
|
||||
const data = row.original.data;
|
||||
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
|
||||
|
||||
// Simplistic view for now: JSON stringify but truncated?
|
||||
// Or meaningful extraction based on event type.
|
||||
return (
|
||||
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
|
||||
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
101
src/components/trials/analysis/events-data-table.tsx
Normal file
101
src/components/trials/analysis/events-data-table.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { type TrialEvent, eventsColumns } from "./events-columns";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
interface EventsDataTableProps {
|
||||
data: TrialEvent[];
|
||||
startTime?: Date;
|
||||
}
|
||||
|
||||
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
||||
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
|
||||
const [globalFilter, setGlobalFilter] = React.useState<string>("");
|
||||
|
||||
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
|
||||
|
||||
// Enhanced filtering logic
|
||||
const filteredData = React.useMemo(() => {
|
||||
return data.filter(event => {
|
||||
// Type filter
|
||||
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Global text search (checks type and data)
|
||||
if (globalFilter) {
|
||||
const searchLower = globalFilter.toLowerCase();
|
||||
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
|
||||
// Safe JSON stringify check
|
||||
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
|
||||
const dataMatch = dataString.includes(searchLower);
|
||||
|
||||
return typeMatch || dataMatch;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [data, eventTypeFilter, globalFilter]);
|
||||
|
||||
// Custom Filters UI
|
||||
const filters = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="All Events" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Events</SelectItem>
|
||||
<SelectItem value="intervention">Interventions</SelectItem>
|
||||
<SelectItem value="robot">Robot Actions</SelectItem>
|
||||
<SelectItem value="step">Step Changes</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
|
||||
Since we implemented custom external filtering for "type" dropdown and "global" search,
|
||||
we pass the filtered data directly.
|
||||
|
||||
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
|
||||
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
|
||||
DataTable's internal search or pass a custom filter.
|
||||
|
||||
The shared DataTable's `searchKey` only filters a specific column string value.
|
||||
Since "data" is an object, we can't easily use the built-in single-column search.
|
||||
So we'll implement our own search input and pass `filters={filters}` which creates
|
||||
additional dropdowns, but we might want to REPLACE the standard search input.
|
||||
|
||||
Looking at `DataTable` implementation:
|
||||
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
|
||||
no input is rendered, and we can put ours in `filters`.
|
||||
*/}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search event data..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
{filters}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
// No searchKey, we handle it externally
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||
import { EventTimeline } from "../playback/EventTimeline";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { EventsDataTable } from "../analysis/events-data-table";
|
||||
|
||||
interface TrialAnalysisViewProps {
|
||||
trial: {
|
||||
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
|
||||
mediaCount?: number;
|
||||
media?: { url: string; contentType: string }[];
|
||||
};
|
||||
backHref: string;
|
||||
}
|
||||
|
||||
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
// Fetch events for timeline
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||
trialId: trial.id,
|
||||
@@ -39,139 +44,153 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||
const videoUrl = videoMedia?.url;
|
||||
|
||||
// Metrics
|
||||
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
|
||||
const errorCount = events.filter(e => e.eventType.includes("error")).length;
|
||||
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
|
||||
|
||||
return (
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="flex h-full flex-col gap-4 p-4 text-sm">
|
||||
{/* Header Context */}
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
|
||||
<div className="flex items-center justify-between pb-2 border-b">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild className="-ml-2">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-base font-semibold leading-none">
|
||||
<h1 className="text-lg font-semibold leading-none tracking-tight">
|
||||
{trial.experiment.name}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground mt-1">
|
||||
<span className="font-mono">{trial.participant.participantCode}</span>
|
||||
<span>•</span>
|
||||
<span>Session {trial.id.slice(0, 4)}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
|
||||
<span className="text-xs font-mono">
|
||||
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
{trial.duration && (
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
||||
<Clock className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.duration ? (
|
||||
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
|
||||
) : (
|
||||
"--:--"
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Total session time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
|
||||
<Bot className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{robotActionCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{interventionCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.status === 'completed' ? '100%' : 'Incomplete'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
|
||||
)} />
|
||||
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Resizable Workspace */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
|
||||
{/* LEFT: Video & Timeline */}
|
||||
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
|
||||
{/* Main Workspace: Vertical Layout */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
{/* Top: Video Player */}
|
||||
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
|
||||
|
||||
{/* TOP: Video & Timeline */}
|
||||
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
{videoUrl ? (
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
|
||||
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
|
||||
<p className="text-sm">No recording available.</p>
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
|
||||
<div className="bg-muted rounded-full p-4 mb-4">
|
||||
<VideoOff className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">No playback media available</h3>
|
||||
<p className="text-sm max-w-sm mt-2">
|
||||
There is no video recording associated with this trial session.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Bottom: Timeline Track */}
|
||||
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
|
||||
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div className="absolute inset-0 p-2 overflow-hidden">
|
||||
|
||||
{/* Timeline Control */}
|
||||
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
|
||||
<EventTimeline />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
<ResizableHandle withHandle className="bg-border/50" />
|
||||
|
||||
{/* RIGHT: Logs & Metrics */}
|
||||
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
|
||||
{/* Metrics Strip */}
|
||||
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
|
||||
<Card className="shadow-none border-dashed bg-transparent">
|
||||
<CardContent className="p-3 py-2">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
|
||||
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||
{events.filter(e => e.eventType.includes("intervention")).length}
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
|
||||
{/* BOTTOM: Events Table */}
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">Event Log</h3>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none border-dashed bg-transparent">
|
||||
<CardContent className="p-3 py-2">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
|
||||
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||
{trial.status === 'completed' ? 'PASS' : 'INC'}
|
||||
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
|
||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Title */}
|
||||
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
|
||||
<span className="text-xs font-semibold flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-primary" />
|
||||
Event Log
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Event List */}
|
||||
<div className="flex-1 min-h-0 relative bg-background/50">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="divide-y divide-border/50">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
|
||||
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
|
||||
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
|
||||
{event.eventType.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
{!!event.data && (
|
||||
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
|
||||
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="p-8 text-center text-xs text-muted-foreground italic">
|
||||
No events found in log.
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -406,6 +406,32 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
});
|
||||
|
||||
const pauseTrialMutation = api.trials.pause.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Trial paused");
|
||||
// Optionally update local state if needed, though status might not change on backend strictly to "paused"
|
||||
// depending on enum. But we logged the event.
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to pause trial", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const archiveTrialMutation = api.trials.archive.useMutation({
|
||||
onSuccess: () => {
|
||||
console.log("Trial archived successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to archive trial", error);
|
||||
},
|
||||
});
|
||||
|
||||
const logEventMutation = api.trials.logEvent.useMutation({
|
||||
onSuccess: () => {
|
||||
// toast.success("Event logged"); // Too noisy
|
||||
},
|
||||
});
|
||||
|
||||
// Action handlers
|
||||
const handleStartTrial = async () => {
|
||||
console.log(
|
||||
@@ -443,8 +469,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
// TODO: Implement pause functionality
|
||||
console.log("Pause trial");
|
||||
try {
|
||||
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to pause trial:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
@@ -498,6 +527,19 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
// Log step change
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "step_changed",
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: nextIndex,
|
||||
fromStepId: currentStep?.id,
|
||||
toStepId: steps[nextIndex]?.id,
|
||||
stepName: steps[nextIndex]?.name,
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentStepIndex(nextIndex);
|
||||
} else {
|
||||
handleCompleteTrial();
|
||||
@@ -507,6 +549,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handleCompleteTrial = async () => {
|
||||
try {
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
// Trigger archive in background
|
||||
archiveTrialMutation.mutate({ id: trial.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to complete trial:", error);
|
||||
}
|
||||
@@ -543,10 +587,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
};
|
||||
|
||||
// Mutation for events (Acknowledge)
|
||||
const logEventMutation = api.trials.logEvent.useMutation({
|
||||
onSuccess: () => toast.success("Event logged"),
|
||||
});
|
||||
|
||||
|
||||
// Mutation for interventions
|
||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
@@ -87,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, studyId);
|
||||
|
||||
const conditions = [eq(experiments.studyId, studyId)];
|
||||
const conditions = [
|
||||
eq(experiments.studyId, studyId),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
}
|
||||
@@ -224,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [inArray(experiments.studyId, studyIds)];
|
||||
const conditions = [
|
||||
inArray(experiments.studyId, studyIds),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { db } from "~/server/db";
|
||||
import {
|
||||
activityLogs, consentForms, participantConsents, participants, studyMembers, trials
|
||||
} from "~/server/db/schema";
|
||||
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
|
||||
|
||||
// Helper function to check study access
|
||||
async function checkStudyAccess(
|
||||
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getConsentUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
participantId: z.string().uuid(),
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number().max(10 * 1024 * 1024), // 10MB limit
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { studyId, participantId, filename, contentType, size } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, studyId, ["owner", "researcher", "wizard"]);
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
|
||||
const validation = validateFile(filename, size, allowedTypes);
|
||||
if (!validation.valid) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: validation.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate key: studies/{studyId}/participants/{participantId}/consent/{timestamp}-{filename}
|
||||
const key = `studies/${studyId}/participants/${participantId}/consent/${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
|
||||
|
||||
// Generate presigned URL
|
||||
const url = await getUploadUrl(key, contentType);
|
||||
|
||||
return { url, key };
|
||||
}),
|
||||
|
||||
recordConsent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId: z.string().uuid(),
|
||||
signatureData: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
storagePath: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, signatureData, ipAddress } = input;
|
||||
const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId,
|
||||
signatureData,
|
||||
ipAddress,
|
||||
storagePath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env";
|
||||
import { uploadFile } from "~/lib/storage/minio";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
async function checkTrialAccess(
|
||||
@@ -542,6 +543,14 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Log trial start event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_started",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
@@ -625,9 +634,136 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Log trial abort event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_aborted",
|
||||
timestamp: new Date(),
|
||||
data: { userId, reason: input.reason },
|
||||
});
|
||||
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
pause: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// Log trial paused event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_paused",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const trial = await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// 1. Fetch full trial data
|
||||
const trialData = await db.query.trials.findFirst({
|
||||
where: eq(trials.id, input.id),
|
||||
with: {
|
||||
experiment: true,
|
||||
participant: true,
|
||||
wizard: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!trialData) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial data not found",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch all events
|
||||
const events = await db
|
||||
.select()
|
||||
.from(trialEvents)
|
||||
.where(eq(trialEvents.trialId, input.id))
|
||||
.orderBy(asc(trialEvents.timestamp));
|
||||
|
||||
// 3. Fetch all interventions
|
||||
const interventions = await db
|
||||
.select()
|
||||
.from(wizardInterventions)
|
||||
.where(eq(wizardInterventions.trialId, input.id))
|
||||
.orderBy(asc(wizardInterventions.timestamp));
|
||||
|
||||
// 4. Construct Archive Object
|
||||
const archiveObject = {
|
||||
trial: trialData,
|
||||
events,
|
||||
interventions,
|
||||
archivedAt: new Date().toISOString(),
|
||||
archivedBy: userId,
|
||||
};
|
||||
|
||||
// 5. Upload to MinIO
|
||||
const filename = `archive-${input.id}-${Date.now()}.json`;
|
||||
const key = `trials/${input.id}/${filename}`;
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadFile({
|
||||
key,
|
||||
body: JSON.stringify(archiveObject, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
// 6. Update Trial Metadata with Archive URL/Key
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const currentMetadata = (trialData.metadata as any) || {};
|
||||
await db
|
||||
.update(trials)
|
||||
.set({
|
||||
metadata: {
|
||||
...currentMetadata,
|
||||
archiveKey: uploadResult.key,
|
||||
archiveUrl: uploadResult.url,
|
||||
archivedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.where(eq(trials.id, input.id));
|
||||
|
||||
return { success: true, url: uploadResult.url };
|
||||
} catch (error) {
|
||||
console.error("Failed to archive trial:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to upload archive to storage",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
logEvent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user