mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Enhance development standards and UI components
- Updated .rules to enforce stricter UI/UX standards, including exclusive use of Lucide icons and consistent patterns for entity view pages. - Added new UI components for entity views, including headers, sections, and quick actions to improve layout and reusability. - Refactored existing pages (experiments, participants, studies, trials) to utilize the new entity view components, enhancing consistency across the dashboard. - Improved accessibility and user experience by implementing loading states and error boundaries in async operations. - Updated package dependencies to ensure compatibility and performance improvements. Features: - Comprehensive guidelines for component reusability and visual consistency. - Enhanced user interface with new entity view components for better organization and navigation. Breaking Changes: None - existing functionality remains intact.
This commit is contained in:
10
.rules
10
.rules
@@ -157,6 +157,9 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
- Follow the established file naming: PascalCase for components, camelCase for utilities
|
||||
- Use Server Components by default, minimize 'use client' directives
|
||||
- Implement proper loading states and error boundaries for all async operations
|
||||
- NEVER use emojis - use Lucide icons exclusively for all visual elements
|
||||
- Maximize component reusability - create shared patterns and abstractions
|
||||
- ALL entity view pages must follow consistent patterns and layouts
|
||||
|
||||
### TypeScript Standards
|
||||
- 100% type safety - NO `any` types allowed in production code
|
||||
@@ -179,6 +182,13 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
- Minimize database queries with efficient joins and proper indexing
|
||||
- Follow WCAG 2.1 AA accessibility standards throughout
|
||||
|
||||
## UI/UX Standards
|
||||
- **Icons**: Use Lucide icons exclusively - NO emojis anywhere in the codebase
|
||||
- **Reusability**: Maximize component reuse through shared patterns and abstractions
|
||||
- **Entity Views**: All entity view pages (studies, experiments, participants, trials) must follow consistent patterns
|
||||
- **Page Structure**: Use global page headers, breadcrumbs, and consistent layout patterns
|
||||
- **Visual Consistency**: Maintain consistent spacing, typography, and interaction patterns
|
||||
|
||||
## Development Commands
|
||||
- `bun install` - Install dependencies
|
||||
- `bun dev` - Start development server (ONLY when actively developing)
|
||||
|
||||
@@ -97,5 +97,9 @@
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -233,8 +233,8 @@ async function main() {
|
||||
},
|
||||
{
|
||||
studyId: study1.id,
|
||||
userId: seanUser.id, // Sean (observer)
|
||||
role: "observer" as const,
|
||||
userId: seanUser.id, // Sean (researcher)
|
||||
role: "researcher" as const,
|
||||
joinedAt: new Date(),
|
||||
},
|
||||
|
||||
|
||||
448
src/app/(dashboard)/experiments/[id]/page.tsx
Normal file
448
src/app/(dashboard)/experiments/[id]/page.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Play,
|
||||
Settings,
|
||||
Share,
|
||||
Target,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "FlaskConical" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertTriangle" as const,
|
||||
},
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
params,
|
||||
}: ExperimentDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<any>(null);
|
||||
const [trials, setTrials] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: experimentData } = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: trialsData } = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.id ?? "", limit: 10 },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentData) {
|
||||
setExperiment(experimentData);
|
||||
}
|
||||
if (trialsData) {
|
||||
setTrials(trialsData);
|
||||
}
|
||||
if (experimentData !== undefined) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentData, trialsData]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
{ label: experiment?.name || "Experiment" },
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (loading || !experiment) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
|
||||
const statusInfo = statusConfig[experiment.status];
|
||||
|
||||
// TODO: Get actual stats from API
|
||||
const mockStats = {
|
||||
totalTrials: trials.length,
|
||||
completedTrials: trials.filter((t) => t.status === "completed").length,
|
||||
averageDuration: "—",
|
||||
successRate:
|
||||
trials.length > 0
|
||||
? `${Math.round((trials.filter((t) => t.status === "completed").length / trials.length) * 100)}%`
|
||||
: "—",
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={experiment.name}
|
||||
subtitle={experiment.description}
|
||||
icon="FlaskConical"
|
||||
status={{
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
}}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
{/* Experiment Information */}
|
||||
<EntityViewSection title="Experiment Information" icon="FlaskConical">
|
||||
<InfoGrid
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
value: experiment.study ? (
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Robot Platform",
|
||||
value: experiment.robot?.name || "Not specified",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(experiment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Overview */}
|
||||
<EntityViewSection
|
||||
title="Protocol Overview"
|
||||
icon="Target"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Protocol
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{experiment.protocol &&
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<h4 className="mb-2 font-medium">Protocol Structure</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Visual protocol designed with{" "}
|
||||
{Array.isArray((experiment.protocol as any).blocks)
|
||||
? (experiment.protocol as any).blocks.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Target"
|
||||
title="No Protocol Defined"
|
||||
description="Use the experiment designer to create your protocol"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
Open Designer
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Trials */}
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
description="Latest experimental sessions"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.slice(0, 5).map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ||
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{trials.length > 5 && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/trials?experimentId=${experiment.id}`}>
|
||||
View All Trials ({trials.length})
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No Trials Yet"
|
||||
description="Start your first trial to begin collecting data"
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
Start First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Quick Stats */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart3">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: mockStats.totalTrials,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: mockStats.completedTrials,
|
||||
color: "success",
|
||||
},
|
||||
{
|
||||
label: "Success Rate",
|
||||
value: mockStats.successRate,
|
||||
color: "success",
|
||||
},
|
||||
{
|
||||
label: "Avg. Duration",
|
||||
value: mockStats.averageDuration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Robot Information */}
|
||||
{experiment.robot && (
|
||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Platform",
|
||||
value: experiment.robot.name,
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.type || "Not specified",
|
||||
},
|
||||
{
|
||||
label: "Connection",
|
||||
value: experiment.robot.connectionType || "Not configured",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Settings">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "View All Trials",
|
||||
icon: "Play",
|
||||
href: `/trials?experimentId=${experiment.id}`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Share",
|
||||
href: `/experiments/${experiment.id}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit",
|
||||
href: `/experiments/${experiment.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Protocol Designer",
|
||||
icon: "Settings",
|
||||
href: `/experiments/${experiment.id}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
FileText,
|
||||
Mail,
|
||||
@@ -10,21 +13,26 @@ import {
|
||||
Shield,
|
||||
Trash2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api } from "~/trpc/server";
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -32,415 +40,372 @@ interface ParticipantDetailPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ParticipantDetailPage({
|
||||
export default function ParticipantDetailPage({
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const resolvedParams = await params;
|
||||
const session = await auth();
|
||||
const { data: session } = useSession();
|
||||
const [participant, setParticipant] = useState<any>(null);
|
||||
const [trials, setTrials] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: participantData } = api.participants.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: trialsData } = api.trials.list.useQuery(
|
||||
{ participantId: resolvedParams?.id ?? "", limit: 10 },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (participantData) {
|
||||
setParticipant(participantData);
|
||||
}
|
||||
if (trialsData) {
|
||||
setTrials(trialsData);
|
||||
}
|
||||
if (participantData !== undefined) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [participantData, trialsData]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Participants", href: "/participants" },
|
||||
{
|
||||
label: participant?.name || participant?.participantCode || "Participant",
|
||||
},
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
const participant = await api.participants.get({ id: resolvedParams.id });
|
||||
if (loading || !participant) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!participant) {
|
||||
return notFound();
|
||||
}
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
// canDelete removed - not used in component
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={participant.name ?? participant.participantCode}
|
||||
subtitle={
|
||||
participant.name
|
||||
? `Code: ${participant.participantCode}`
|
||||
: "Participant"
|
||||
}
|
||||
icon="Users"
|
||||
actions={
|
||||
canEdit && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/participants/${resolvedParams.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
// Get participant's trials
|
||||
const trials = await api.trials.list({
|
||||
participantId: resolvedParams.id,
|
||||
limit: 10,
|
||||
});
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Participant Information */}
|
||||
<EntityViewSection title="Participant Information" icon="FileText">
|
||||
<InfoGrid
|
||||
items={[
|
||||
{
|
||||
label: "Participant Code",
|
||||
value: (
|
||||
<code className="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{participant.participantCode}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
value: participant.name || "Not provided",
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: participant.email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a
|
||||
href={`mailto:${participant.email}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{participant.email}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
"Not provided"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Study",
|
||||
value: participant.study ? (
|
||||
<Link
|
||||
href={`/studies/${participant.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{participant.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/participants">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Participants
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Demographics */}
|
||||
{participant.demographics &&
|
||||
typeof participant.demographics === "object" &&
|
||||
participant.demographics !== null &&
|
||||
Object.keys(participant.demographics).length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
|
||||
Demographics
|
||||
</h4>
|
||||
<InfoGrid
|
||||
items={(() => {
|
||||
const demo = participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return [
|
||||
demo.age && {
|
||||
label: "Age",
|
||||
value:
|
||||
typeof demo.age === "number"
|
||||
? demo.age.toString()
|
||||
: String(demo.age),
|
||||
},
|
||||
demo.gender && {
|
||||
label: "Gender",
|
||||
value: String(demo.gender),
|
||||
},
|
||||
].filter(Boolean) as Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary text-primary-foreground flex h-16 w-16 items-center justify-center rounded-lg">
|
||||
<Users className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-foreground text-3xl font-bold">
|
||||
{participant.name ?? participant.participantCode}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{participant.name
|
||||
? `Code: ${participant.participantCode}`
|
||||
: "Participant"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/participants/${resolvedParams.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
{/* Notes */}
|
||||
{participant.notes && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Notes
|
||||
</h4>
|
||||
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
|
||||
{participant.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Participant Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Participant Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Participant Code
|
||||
</h4>
|
||||
<p className="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{participant.participantCode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{participant.name && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Name
|
||||
</h4>
|
||||
<p className="text-sm">{participant.name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{participant.email && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</h4>
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a
|
||||
href={`mailto:${participant.email}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{participant.email}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Study
|
||||
</h4>
|
||||
<p className="text-sm">
|
||||
{/* Trial History */}
|
||||
<EntityViewSection
|
||||
title="Trial History"
|
||||
icon="Play"
|
||||
description="Experimental sessions for this participant"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/studies/${participant.study?.id}`}
|
||||
className="text-primary hover:underline"
|
||||
href={`/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{participant.study?.name}
|
||||
{trial.experiment?.name || "Trial"}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{participant.demographics &&
|
||||
typeof participant.demographics === "object" &&
|
||||
participant.demographics !== null &&
|
||||
Object.keys(participant.demographics).length > 0 ? (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Demographics
|
||||
</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(() => {
|
||||
const demo = participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (
|
||||
<>
|
||||
{demo.age && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Age:
|
||||
</span>{" "}
|
||||
<span className="text-sm">
|
||||
{typeof demo.age === "number"
|
||||
? demo.age.toString()
|
||||
: String(demo.age)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{demo.gender && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Gender:
|
||||
</span>{" "}
|
||||
<span className="text-sm">
|
||||
{String(demo.gender)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span>{Math.round(trial.duration / 60)} minutes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Notes */}
|
||||
{participant.notes && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Notes
|
||||
</h4>
|
||||
<p className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
|
||||
{participant.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trial History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Trial History
|
||||
</CardTitle>
|
||||
{canEdit && (
|
||||
<Button size="sm" asChild>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No Trials Yet"
|
||||
description="This participant hasn't been assigned to any trials."
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
Schedule Trial
|
||||
Schedule First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Experimental sessions for this participant
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{trial.experiment?.name || "Trial"}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span>
|
||||
{Math.round(trial.duration / 60)} minutes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 font-medium">No Trials Yet</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
This participant hasn't been assigned to any trials.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
Schedule First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Consent Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield className="h-4 w-4" />
|
||||
Consent Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Informed Consent</span>
|
||||
<Badge
|
||||
variant={
|
||||
participant.consentGiven ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{participant.consentGiven ? "Given" : "Not Given"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{participant.consentDate && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Consented:{" "}
|
||||
{formatDistanceToNow(participant.consentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!participant.consentGiven && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Consent required before trials can be conducted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Registration Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Registration Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Registered
|
||||
</h4>
|
||||
<p className="text-sm">
|
||||
{formatDistanceToNow(participant.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{participant.updatedAt &&
|
||||
participant.updatedAt !== participant.createdAt && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
Last Updated
|
||||
</h4>
|
||||
<p className="text-sm">
|
||||
{formatDistanceToNow(participant.updatedAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{canEdit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/participants/${resolvedParams.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Information
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Export Data
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Consent Status */}
|
||||
<EntityViewSection title="Consent Status" icon="Shield">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Informed Consent</span>
|
||||
<Badge
|
||||
variant={participant.consentGiven ? "default" : "destructive"}
|
||||
>
|
||||
{participant.consentGiven ? (
|
||||
<>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Given
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Not Given
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{participant.consentDate && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Consented:{" "}
|
||||
{formatDistanceToNow(participant.consentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!participant.consentGiven && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Consent required before trials can be conducted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Registration Details */}
|
||||
<EntityViewSection title="Registration Details" icon="Calendar">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Registered",
|
||||
value: formatDistanceToNow(participant.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
...(participant.updatedAt &&
|
||||
participant.updatedAt !== participant.createdAt
|
||||
? [
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(participant.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{canEdit && (
|
||||
<EntityViewSection title="Quick Actions" icon="Edit">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Schedule Trial",
|
||||
icon: "Play",
|
||||
href: `/trials/new?participantId=${resolvedParams.id}`,
|
||||
},
|
||||
{
|
||||
label: "Edit Information",
|
||||
icon: "Edit",
|
||||
href: `/participants/${resolvedParams.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "FileText",
|
||||
href: `/participants/${resolvedParams.id}/export`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Building,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Edit,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityViewSidebar,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/server";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface StudyDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -32,326 +46,281 @@ interface StudyDetailPageProps {
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: "📝",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: "🟢",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: "✅",
|
||||
variant: "outline" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
className: "bg-orange-100 text-orange-800",
|
||||
icon: "📦",
|
||||
variant: "destructive" as const,
|
||||
icon: "XCircle" as const,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function StudyDetailPage({
|
||||
params,
|
||||
}: StudyDetailPageProps) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const study = await api.studies.get({ id: resolvedParams.id });
|
||||
const members = await api.studies.getMembers({
|
||||
studyId: resolvedParams.id,
|
||||
});
|
||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [study, setStudy] = useState<any>(null);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!study) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
async function resolveParams() {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
}
|
||||
resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const statusInfo = statusConfig[study.status];
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center space-x-2 text-sm text-slate-600">
|
||||
<Link href="/studies" className="hover:text-slate-900">
|
||||
Studies
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-slate-900">{study.name}</span>
|
||||
</div>
|
||||
const { data: membersData } = api.studies.getMembers.useQuery(
|
||||
{ studyId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center space-x-3">
|
||||
<h1 className="truncate text-3xl font-bold text-slate-900">
|
||||
{study.name}
|
||||
</h1>
|
||||
<Badge className={statusInfo.className} variant="secondary">
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-lg text-slate-600">{study.description}</p>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (studyData) {
|
||||
setStudy(studyData);
|
||||
}
|
||||
if (membersData) {
|
||||
setMembers(membersData);
|
||||
}
|
||||
if (studyData !== undefined) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [studyData, membersData]);
|
||||
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${study.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Edit Study
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name || "Study" },
|
||||
]);
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
{/* Study Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Study Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Institution
|
||||
</label>
|
||||
<p className="text-slate-900">{study.institution}</p>
|
||||
</div>
|
||||
{study.irbProtocol && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
IRB Protocol
|
||||
</label>
|
||||
<p className="text-slate-900">{study.irbProtocol}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Created
|
||||
</label>
|
||||
<p className="text-slate-900">
|
||||
{formatDistanceToNow(study.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Last Updated
|
||||
</label>
|
||||
<p className="text-slate-900">
|
||||
{formatDistanceToNow(study.updatedAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experiments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
<span>Experiments</span>
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Design and manage experimental protocols for this study
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Placeholder for experiments list */}
|
||||
<div className="py-8 text-center">
|
||||
<FlaskConical className="mx-auto mb-4 h-12 w-12 text-slate-400" />
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing research
|
||||
protocols
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||
Create First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Recent Activity</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<Calendar className="mx-auto mb-4 h-12 w-12 text-slate-400" />
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Recent Activity
|
||||
</h3>
|
||||
<p className="text-slate-600">
|
||||
Activity will appear here once you start working on this
|
||||
study
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Team Members */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>Team</span>
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{members.length} team member{members.length !== 1 ? "s" : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{(member.user.name ?? member.user.email)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{member.user.name ?? member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 capitalize">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
{member.role === "owner" && (
|
||||
<Shield className="h-4 w-4 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-slate-600">Experiments:</span>
|
||||
<span className="font-medium">0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-slate-600">
|
||||
Total Trials:
|
||||
</span>
|
||||
<span className="font-medium">0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-slate-600">
|
||||
Participants:
|
||||
</span>
|
||||
<span className="font-medium">0</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-slate-600">
|
||||
Completion Rate:
|
||||
</span>
|
||||
<span className="font-medium text-green-600">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={`/studies/${study.id}/participants`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Participants
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={`/studies/${study.id}/trials`}>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Schedule Trials
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={`/studies/${study.id}/analytics`}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Analytics
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading study:", error);
|
||||
notFound();
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (loading || !study) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const statusInfo = statusConfig[study.status];
|
||||
|
||||
// TODO: Get actual stats from API
|
||||
const mockStats = {
|
||||
experiments: 0,
|
||||
totalTrials: 0,
|
||||
participants: 0,
|
||||
completionRate: "—",
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
title={study.name}
|
||||
subtitle={study.description}
|
||||
icon="Building"
|
||||
status={{
|
||||
label: statusInfo.label,
|
||||
variant: statusInfo.variant,
|
||||
icon: statusInfo.icon,
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${study.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Edit Study
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
{/* Study Information */}
|
||||
<EntityViewSection title="Study Information" icon="Building">
|
||||
<InfoGrid
|
||||
items={[
|
||||
{
|
||||
label: "Institution",
|
||||
value: study.institution,
|
||||
},
|
||||
{
|
||||
label: "IRB Protocol",
|
||||
value: study.irbProtocol || "Not specified",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(study.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(study.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Experiments */}
|
||||
<EntityViewSection
|
||||
title="Experiments"
|
||||
icon="FlaskConical"
|
||||
description="Design and manage experimental protocols for this study"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon="FlaskConical"
|
||||
title="No Experiments Yet"
|
||||
description="Create your first experiment to start designing research protocols"
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
||||
Create First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<EntityViewSection title="Recent Activity" icon="BarChart3">
|
||||
<EmptyState
|
||||
icon="Calendar"
|
||||
title="No Recent Activity"
|
||||
description="Activity will appear here once you start working on this study"
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<EntityViewSidebar>
|
||||
{/* Team Members */}
|
||||
<EntityViewSection
|
||||
title="Team"
|
||||
icon="Users"
|
||||
description={`${members.length} team member${members.length !== 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Invite
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{(member.user.name ?? member.user.email)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{member.user.name ?? member.user.email}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs capitalize">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
{member.role === "owner" && (
|
||||
<Shield className="h-4 w-4 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<EntityViewSection title="Quick Stats" icon="BarChart3">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Experiments",
|
||||
value: mockStats.experiments,
|
||||
},
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: mockStats.totalTrials,
|
||||
},
|
||||
{
|
||||
label: "Participants",
|
||||
value: mockStats.participants,
|
||||
},
|
||||
{
|
||||
label: "Completion Rate",
|
||||
value: mockStats.completionRate,
|
||||
color: "success",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Settings">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Manage Participants",
|
||||
icon: "Users",
|
||||
href: `/participants?studyId=${study.id}`,
|
||||
},
|
||||
{
|
||||
label: "Schedule Trials",
|
||||
icon: "Calendar",
|
||||
href: `/trials?studyId=${study.id}`,
|
||||
},
|
||||
{
|
||||
label: "View Analytics",
|
||||
icon: "BarChart3",
|
||||
href: `/analytics?studyId=${study.id}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</EntityViewSidebar>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ export function SystemStats() {
|
||||
{/* Total Users */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Total Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -62,7 +62,7 @@ export function SystemStats() {
|
||||
{/* Total Studies */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Studies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -79,7 +79,7 @@ export function SystemStats() {
|
||||
{/* Total Experiments */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Experiments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -98,7 +98,7 @@ export function SystemStats() {
|
||||
{/* Total Trials */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Trials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -115,7 +115,7 @@ export function SystemStats() {
|
||||
{/* System Health */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -124,11 +124,11 @@ export function SystemStats() {
|
||||
<div className="flex h-3 w-3 items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{displayStats.systemHealth === "healthy" ? "Healthy" : "Issues"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
All services operational
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -137,41 +137,49 @@ export function SystemStats() {
|
||||
{/* Uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Uptime
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-medium">{displayStats.uptime}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">Since last restart</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
Since last restart
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Storage Used
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-medium">{displayStats.storageUsed}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">Media & database</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
Media & database
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-600">2 trials started today</div>
|
||||
<div className="text-xs text-slate-600">1 new user registered</div>
|
||||
<div className="text-xs text-slate-600">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
2 trials started today
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
1 new user registered
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
3 experiments published
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
User,
|
||||
Users,
|
||||
UserCheck,
|
||||
TestTube,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "~/components/ui/sidebar";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
@@ -292,18 +292,60 @@ export function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton size="lg">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{session?.user?.name ?? "User"}</span>
|
||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{session?.user?.name ?? "User"}
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -70,6 +70,7 @@ export type Trial = {
|
||||
logs: number;
|
||||
};
|
||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||
canAccess?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canExecute?: boolean;
|
||||
@@ -162,12 +163,19 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
{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
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
View Details (Restricted)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{trial.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
@@ -272,13 +280,33 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
const trial = row.original;
|
||||
return (
|
||||
<div className="max-w-[140px] min-w-0">
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
title={trial.name}
|
||||
>
|
||||
{trial.name}
|
||||
</Link>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
@@ -290,16 +318,28 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as Trial["status"];
|
||||
const trial = row.original;
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${config.className} whitespace-nowrap`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<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[]) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Plus, TestTube } from "lucide-react";
|
||||
import { Plus, TestTube, Eye } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
@@ -111,14 +111,19 @@ export function TrialsDataTable() {
|
||||
actions: trial._count?.events ?? 0,
|
||||
logs: trial._count?.mediaCaptures ?? 0,
|
||||
},
|
||||
userRole: undefined,
|
||||
canEdit: trial.status === "scheduled" || trial.status === "aborted",
|
||||
userRole: trial.userRole,
|
||||
canAccess: trial.canAccess ?? false,
|
||||
canEdit:
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "aborted"),
|
||||
canDelete:
|
||||
trial.status === "scheduled" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed",
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed"),
|
||||
canExecute:
|
||||
trial.status === "scheduled" || trial.status === "in_progress",
|
||||
trial.canAccess &&
|
||||
(trial.status === "scheduled" || trial.status === "in_progress"),
|
||||
}));
|
||||
}, [trialsData]);
|
||||
|
||||
@@ -204,6 +209,29 @@ export function TrialsDataTable() {
|
||||
/>
|
||||
|
||||
<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}
|
||||
|
||||
260
src/components/ui/entity-view.tsx
Normal file
260
src/components/ui/entity-view.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
|
||||
function getIcon(iconName: IconName) {
|
||||
const Icon = LucideIcons[iconName] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>;
|
||||
return Icon;
|
||||
}
|
||||
|
||||
interface EntityViewHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: IconName;
|
||||
status?: {
|
||||
label: string;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
icon?: IconName;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
interface EntityViewSectionProps {
|
||||
title: string;
|
||||
icon: IconName;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface EntityViewSidebarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface EntityViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function EntityViewHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
status,
|
||||
actions,
|
||||
}: EntityViewHeaderProps) {
|
||||
const Icon = getIcon(icon);
|
||||
const StatusIcon = status?.icon ? getIcon(status.icon) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary text-primary-foreground flex h-16 w-16 items-center justify-center rounded-lg">
|
||||
<Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
{status && (
|
||||
<Badge variant={status.variant}>
|
||||
{StatusIcon && <StatusIcon className="mr-1 h-3 w-3" />}
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground text-lg">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityViewSection({
|
||||
title,
|
||||
icon,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: EntityViewSectionProps) {
|
||||
const Icon = getIcon(icon);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{title}</span>
|
||||
</CardTitle>
|
||||
{actions && actions}
|
||||
</div>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function EntityView({ children }: EntityViewProps) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
// Utility component for empty states
|
||||
interface EmptyStateProps {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
const Icon = getIcon(icon);
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<Icon className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 font-medium">{title}</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm">{description}</p>
|
||||
{action && action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility component for key-value information display
|
||||
interface InfoGridProps {
|
||||
items: Array<{
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}>;
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-4 ${
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "md:grid-cols-2"
|
||||
: "md:grid-cols-2 lg:grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={item.fullWidth ? "md:col-span-full" : undefined}
|
||||
>
|
||||
<h4 className="text-muted-foreground text-sm font-medium">
|
||||
{item.label}
|
||||
</h4>
|
||||
<div className="text-sm">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility component for statistics display
|
||||
interface StatsGridProps {
|
||||
stats: Array<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: "default" | "success" | "warning" | "error";
|
||||
}>;
|
||||
}
|
||||
|
||||
export function StatsGrid({ stats }: StatsGridProps) {
|
||||
const getValueColor = (color?: string) => {
|
||||
switch (color) {
|
||||
case "success":
|
||||
return "text-green-600";
|
||||
case "warning":
|
||||
return "text-amber-600";
|
||||
case "error":
|
||||
return "text-red-600";
|
||||
default:
|
||||
return "font-medium";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
<span className="text-muted-foreground text-sm">{stat.label}:</span>
|
||||
<span className={getValueColor(stat.color)}>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility component for quick actions
|
||||
interface QuickActionsProps {
|
||||
actions: Array<{
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
variant?: "default" | "outline" | "secondary" | "destructive";
|
||||
}>;
|
||||
}
|
||||
|
||||
export function QuickActions({ actions }: QuickActionsProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{actions.map((action, index) => {
|
||||
const ActionIcon = getIcon(action.icon);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant ?? "outline"}
|
||||
className="w-full justify-start"
|
||||
asChild={!!action.href}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.href ? (
|
||||
<a href={action.href}>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
{action.label}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
{action.label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,39 @@
|
||||
import { Bot } from "lucide-react"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string
|
||||
iconSize?: "sm" | "md" | "lg"
|
||||
showText?: boolean
|
||||
className?: string;
|
||||
iconSize?: "sm" | "md" | "lg";
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-6 w-6",
|
||||
lg: "h-8 w-8"
|
||||
}
|
||||
lg: "h-8 w-8",
|
||||
};
|
||||
|
||||
export function Logo({ className, iconSize = "md", showText = true }: LogoProps) {
|
||||
export function Logo({
|
||||
className,
|
||||
iconSize = "md",
|
||||
showText = true,
|
||||
}: LogoProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="flex aspect-square items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground p-1">
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1">
|
||||
<Bot className={iconSizes[iconSize]} />
|
||||
</div>
|
||||
{showText && (
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="flex items-baseline gap-0">
|
||||
<span className="text-base font-extrabold tracking-tight">HRI</span>
|
||||
<span className="text-base font-light tracking-tight">Studio</span>
|
||||
<span className="text-base font-normal tracking-tight">Studio</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">Research Platform</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
Research Platform
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -128,23 +128,24 @@ export const trialsRouter = createTRPCRouter({
|
||||
id: participants.id,
|
||||
participantCode: participants.participantCode,
|
||||
},
|
||||
userRole: studyMembers.role,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, ["owner", "researcher", "wizard"]),
|
||||
...conditions,
|
||||
),
|
||||
)
|
||||
.where(and(eq(studyMembers.userId, userId), ...conditions))
|
||||
.orderBy(desc(trials.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return await query;
|
||||
const results = await query;
|
||||
|
||||
// Add permission flags for each trial
|
||||
return results.map((trial) => ({
|
||||
...trial,
|
||||
canAccess: ["owner", "researcher", "wizard"].includes(trial.userRole),
|
||||
}));
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
@@ -575,15 +576,19 @@ export const trialsRouter = createTRPCRouter({
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get all studies user is a member of
|
||||
// Get all studies user is a member of with roles
|
||||
const userStudies = await ctx.db.query.studyMembers.findMany({
|
||||
where: eq(studyMembers.userId, userId),
|
||||
columns: {
|
||||
studyId: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
let studyIds = userStudies.map((membership) => membership.studyId);
|
||||
const userStudyRoles = new Map(
|
||||
userStudies.map((membership) => [membership.studyId, membership.role]),
|
||||
);
|
||||
|
||||
// If studyId is provided, filter to just that study (if user has access)
|
||||
if (studyId) {
|
||||
@@ -704,14 +709,22 @@ export const trialsRouter = createTRPCRouter({
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
// Transform data to include counts
|
||||
const transformedTrials = filteredTrials.map((trial) => ({
|
||||
...trial,
|
||||
_count: {
|
||||
events: trial.events?.length ?? 0,
|
||||
mediaCaptures: trial.mediaCaptures?.length ?? 0,
|
||||
},
|
||||
}));
|
||||
// Transform data to include counts and permission information
|
||||
const transformedTrials = filteredTrials.map((trial) => {
|
||||
const userRole = userStudyRoles.get(trial.experiment.studyId);
|
||||
const canAccess =
|
||||
userRole && ["owner", "researcher", "wizard"].includes(userRole);
|
||||
|
||||
return {
|
||||
...trial,
|
||||
_count: {
|
||||
events: trial.events?.length ?? 0,
|
||||
mediaCaptures: trial.mediaCaptures?.length ?? 0,
|
||||
},
|
||||
userRole,
|
||||
canAccess,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
trials: transformedTrials,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
--font-sans:
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
@@ -44,75 +45,188 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans:
|
||||
Geist Mono, monospace;
|
||||
--font-mono:
|
||||
Geist Mono, monospace;
|
||||
--font-serif:
|
||||
Geist Mono, monospace;
|
||||
--radius:
|
||||
0rem;
|
||||
--tracking-tighter:
|
||||
calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight:
|
||||
calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide:
|
||||
calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider:
|
||||
calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest:
|
||||
calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--radius:
|
||||
0rem;
|
||||
--background:
|
||||
oklch(1.0000 0 0);
|
||||
--foreground:
|
||||
oklch(0.1448 0 0);
|
||||
--card:
|
||||
oklch(1.0000 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.1448 0 0);
|
||||
--popover:
|
||||
oklch(1.0000 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.1448 0 0);
|
||||
--primary:
|
||||
oklch(0.5555 0 0);
|
||||
--primary-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--secondary:
|
||||
oklch(0.9702 0 0);
|
||||
--secondary-foreground:
|
||||
oklch(0.2046 0 0);
|
||||
--muted:
|
||||
oklch(0.9702 0 0);
|
||||
--muted-foreground:
|
||||
oklch(0.5486 0 0);
|
||||
--accent:
|
||||
oklch(0.9702 0 0);
|
||||
--accent-foreground:
|
||||
oklch(0.2046 0 0);
|
||||
--destructive:
|
||||
oklch(0.5830 0.2387 28.4765);
|
||||
--border:
|
||||
oklch(0.9219 0 0);
|
||||
--input:
|
||||
oklch(0.9219 0 0);
|
||||
--ring:
|
||||
oklch(0.7090 0 0);
|
||||
--chart-1:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-2:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-3:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-4:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-5:
|
||||
oklch(0.5555 0 0);
|
||||
--sidebar:
|
||||
oklch(0.9851 0 0);
|
||||
--sidebar-foreground:
|
||||
oklch(0.1448 0 0);
|
||||
--sidebar-primary:
|
||||
oklch(0.2046 0 0);
|
||||
--sidebar-primary-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--sidebar-accent:
|
||||
oklch(0.9702 0 0);
|
||||
--sidebar-accent-foreground:
|
||||
oklch(0.2046 0 0);
|
||||
--sidebar-border:
|
||||
oklch(0.9219 0 0);
|
||||
--sidebar-ring:
|
||||
oklch(0.7090 0 0);
|
||||
--destructive-foreground:
|
||||
oklch(0.9702 0 0);
|
||||
--font-sans:
|
||||
Geist Mono, monospace;
|
||||
--font-serif:
|
||||
Geist Mono, monospace;
|
||||
--font-mono:
|
||||
Geist Mono, monospace;
|
||||
--shadow-color:
|
||||
hsl(0 0% 0%);
|
||||
--shadow-opacity:
|
||||
0;
|
||||
--shadow-blur:
|
||||
0px;
|
||||
--shadow-spread:
|
||||
0px;
|
||||
--shadow-offset-x:
|
||||
0px;
|
||||
--shadow-offset-y:
|
||||
1px;
|
||||
--letter-spacing:
|
||||
0em;
|
||||
--spacing:
|
||||
0.25rem;
|
||||
--shadow-2xs:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--shadow-xs:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--shadow-sm:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-md:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-lg:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-2xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--tracking-normal:
|
||||
0em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 2 6 23;
|
||||
--foreground: 248 250 252;
|
||||
--card: 15 23 42;
|
||||
--card-foreground: 248 250 252;
|
||||
--popover: 15 23 42;
|
||||
--popover-foreground: 248 250 252;
|
||||
--primary: 148 163 184;
|
||||
--primary-foreground: 15 23 42;
|
||||
--secondary: 30 41 59;
|
||||
--secondary-foreground: 248 250 252;
|
||||
--muted: 30 41 59;
|
||||
--muted-foreground: 148 163 184;
|
||||
--accent: 30 41 59;
|
||||
--accent-foreground: 248 250 252;
|
||||
--destructive: 239 68 68;
|
||||
--border: 51 65 85;
|
||||
--input: 51 65 85;
|
||||
--ring: 148 163 184;
|
||||
--chart-1: 148 163 184;
|
||||
--chart-2: 100 116 139;
|
||||
--chart-3: 239 68 68;
|
||||
--chart-4: 245 158 11;
|
||||
--chart-5: 34 197 94;
|
||||
--sidebar: 15 23 42;
|
||||
--sidebar-foreground: 148 163 184;
|
||||
--sidebar-primary: 148 163 184;
|
||||
--sidebar-primary-foreground: 15 23 42;
|
||||
--sidebar-accent: 30 41 59;
|
||||
--sidebar-accent-foreground: 248 250 252;
|
||||
--sidebar-border: 51 65 85;
|
||||
--sidebar-ring: 148 163 184;
|
||||
--destructive-foreground: 255 255 255;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -121,5 +235,114 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing:
|
||||
var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background:
|
||||
oklch(0.1448 0 0);
|
||||
--foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--card:
|
||||
oklch(0.2134 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--popover:
|
||||
oklch(0.2686 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--primary:
|
||||
oklch(0.5555 0 0);
|
||||
--primary-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--secondary:
|
||||
oklch(0.2686 0 0);
|
||||
--secondary-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--muted:
|
||||
oklch(0.2686 0 0);
|
||||
--muted-foreground:
|
||||
oklch(0.7090 0 0);
|
||||
--accent:
|
||||
oklch(0.3715 0 0);
|
||||
--accent-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--destructive:
|
||||
oklch(0.7022 0.1892 22.2279);
|
||||
--destructive-foreground:
|
||||
oklch(0.2686 0 0);
|
||||
--border:
|
||||
oklch(0.3407 0 0);
|
||||
--input:
|
||||
oklch(0.4386 0 0);
|
||||
--ring:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-1:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-2:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-3:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-4:
|
||||
oklch(0.5555 0 0);
|
||||
--chart-5:
|
||||
oklch(0.5555 0 0);
|
||||
--sidebar:
|
||||
oklch(0.2046 0 0);
|
||||
--sidebar-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--sidebar-primary:
|
||||
oklch(0.9851 0 0);
|
||||
--sidebar-primary-foreground:
|
||||
oklch(0.2046 0 0);
|
||||
--sidebar-accent:
|
||||
oklch(0.2686 0 0);
|
||||
--sidebar-accent-foreground:
|
||||
oklch(0.9851 0 0);
|
||||
--sidebar-border:
|
||||
oklch(1.0000 0 0);
|
||||
--sidebar-ring:
|
||||
oklch(0.4386 0 0);
|
||||
--radius:
|
||||
0rem;
|
||||
--font-sans:
|
||||
Geist Mono, monospace;
|
||||
--font-serif:
|
||||
Geist Mono, monospace;
|
||||
--font-mono:
|
||||
Geist Mono, monospace;
|
||||
--shadow-color:
|
||||
hsl(0 0% 0%);
|
||||
--shadow-opacity:
|
||||
0;
|
||||
--shadow-blur:
|
||||
0px;
|
||||
--shadow-spread:
|
||||
0px;
|
||||
--shadow-offset-x:
|
||||
0px;
|
||||
--shadow-offset-y:
|
||||
1px;
|
||||
--letter-spacing:
|
||||
0em;
|
||||
--spacing:
|
||||
0.25rem;
|
||||
--shadow-2xs:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--shadow-xs:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--shadow-sm:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-md:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-lg:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
||||
--shadow-2xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
}
|
||||
Reference in New Issue
Block a user