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
|
- Follow the established file naming: PascalCase for components, camelCase for utilities
|
||||||
- Use Server Components by default, minimize 'use client' directives
|
- Use Server Components by default, minimize 'use client' directives
|
||||||
- Implement proper loading states and error boundaries for all async operations
|
- 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
|
### TypeScript Standards
|
||||||
- 100% type safety - NO `any` types allowed in production code
|
- 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
|
- Minimize database queries with efficient joins and proper indexing
|
||||||
- Follow WCAG 2.1 AA accessibility standards throughout
|
- 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
|
## Development Commands
|
||||||
- `bun install` - Install dependencies
|
- `bun install` - Install dependencies
|
||||||
- `bun dev` - Start development server (ONLY when actively developing)
|
- `bun dev` - Start development server (ONLY when actively developing)
|
||||||
|
|||||||
@@ -97,5 +97,9 @@
|
|||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
}
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
studyId: study1.id,
|
studyId: study1.id,
|
||||||
userId: seanUser.id, // Sean (observer)
|
userId: seanUser.id, // Sean (researcher)
|
||||||
role: "observer" as const,
|
role: "researcher" as const,
|
||||||
joinedAt: new Date(),
|
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 { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
Edit,
|
Edit,
|
||||||
FileText,
|
FileText,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -10,21 +13,26 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
EntityView,
|
||||||
CardContent,
|
EntityViewHeader,
|
||||||
CardDescription,
|
EntityViewSection,
|
||||||
CardHeader,
|
EntityViewSidebar,
|
||||||
CardTitle,
|
EmptyState,
|
||||||
} from "~/components/ui/card";
|
InfoGrid,
|
||||||
import { auth } from "~/server/auth";
|
QuickActions,
|
||||||
import { api } from "~/trpc/server";
|
} 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 {
|
interface ParticipantDetailPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -32,415 +40,372 @@ interface ParticipantDetailPageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ParticipantDetailPage({
|
export default function ParticipantDetailPage({
|
||||||
params,
|
params,
|
||||||
}: ParticipantDetailPageProps) {
|
}: ParticipantDetailPageProps) {
|
||||||
const resolvedParams = await params;
|
const { data: session } = useSession();
|
||||||
const session = await auth();
|
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) {
|
if (!session?.user) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (loading || !participant) {
|
||||||
const participant = await api.participants.get({ id: resolvedParams.id });
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!participant) {
|
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||||
return notFound();
|
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||||
}
|
|
||||||
|
|
||||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
return (
|
||||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
<EntityView>
|
||||||
// canDelete removed - not used in component
|
{/* 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
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
const trials = await api.trials.list({
|
{/* Main Content */}
|
||||||
participantId: resolvedParams.id,
|
<div className="space-y-6 lg:col-span-2">
|
||||||
limit: 10,
|
{/* 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 (
|
{/* Demographics */}
|
||||||
<div className="container mx-auto max-w-6xl px-4 py-8">
|
{participant.demographics &&
|
||||||
{/* Header */}
|
typeof participant.demographics === "object" &&
|
||||||
<div className="mb-8">
|
participant.demographics !== null &&
|
||||||
<div className="mb-4 flex items-center gap-4">
|
Object.keys(participant.demographics).length > 0 && (
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<div className="border-t pt-4">
|
||||||
<Link href="/participants">
|
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Demographics
|
||||||
Back to Participants
|
</h4>
|
||||||
</Link>
|
<InfoGrid
|
||||||
</Button>
|
items={(() => {
|
||||||
</div>
|
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">
|
{/* Notes */}
|
||||||
<div className="flex items-center gap-4">
|
{participant.notes && (
|
||||||
<div className="bg-primary text-primary-foreground flex h-16 w-16 items-center justify-center rounded-lg">
|
<div className="border-t pt-4">
|
||||||
<Users className="h-8 w-8" />
|
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||||
</div>
|
Notes
|
||||||
<div>
|
</h4>
|
||||||
<h1 className="text-foreground text-3xl font-bold">
|
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
|
||||||
{participant.name ?? participant.participantCode}
|
{participant.notes}
|
||||||
</h1>
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</EntityViewSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
{/* Trial History */}
|
||||||
{/* Main Content */}
|
<EntityViewSection
|
||||||
<div className="space-y-6 lg:col-span-2">
|
title="Trial History"
|
||||||
{/* Participant Information */}
|
icon="Play"
|
||||||
<Card>
|
description="Experimental sessions for this participant"
|
||||||
<CardHeader>
|
actions={
|
||||||
<CardTitle className="flex items-center gap-2">
|
canEdit && (
|
||||||
<FileText className="h-5 w-5" />
|
<Button size="sm" asChild>
|
||||||
Participant Information
|
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||||
</CardTitle>
|
Schedule Trial
|
||||||
</CardHeader>
|
</Link>
|
||||||
<CardContent className="space-y-4">
|
</Button>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
)
|
||||||
<div>
|
}
|
||||||
<h4 className="text-muted-foreground text-sm font-medium">
|
>
|
||||||
Participant Code
|
{trials.length > 0 ? (
|
||||||
</h4>
|
<div className="space-y-3">
|
||||||
<p className="bg-muted rounded px-2 py-1 font-mono text-sm">
|
{trials.map((trial) => (
|
||||||
{participant.participantCode}
|
<div
|
||||||
</p>
|
key={trial.id}
|
||||||
</div>
|
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
{participant.name && (
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<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">
|
|
||||||
<Link
|
<Link
|
||||||
href={`/studies/${participant.study?.id}`}
|
href={`/trials/${trial.id}`}
|
||||||
className="text-primary hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{participant.study?.name}
|
{trial.experiment?.name || "Trial"}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
<Badge
|
||||||
</div>
|
variant={
|
||||||
</div>
|
trial.status === "completed"
|
||||||
|
? "default"
|
||||||
{participant.demographics &&
|
: trial.status === "in_progress"
|
||||||
typeof participant.demographics === "object" &&
|
? "secondary"
|
||||||
participant.demographics !== null &&
|
: trial.status === "failed"
|
||||||
Object.keys(participant.demographics).length > 0 ? (
|
? "destructive"
|
||||||
<div className="border-t pt-4">
|
: "outline"
|
||||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
}
|
||||||
Demographics
|
>
|
||||||
</h4>
|
{trial.status.replace("_", " ")}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
</Badge>
|
||||||
{(() => {
|
</div>
|
||||||
const demo = participant.demographics as Record<
|
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||||
string,
|
<span className="flex items-center gap-1">
|
||||||
unknown
|
<Calendar className="h-4 w-4" />
|
||||||
>;
|
{trial.createdAt
|
||||||
return (
|
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||||
<>
|
addSuffix: true,
|
||||||
{demo.age && (
|
})
|
||||||
<div>
|
: "Not scheduled"}
|
||||||
<span className="text-sm font-medium">
|
</span>
|
||||||
Age:
|
{trial.duration && (
|
||||||
</span>{" "}
|
<span>{Math.round(trial.duration / 60)} minutes</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
))}
|
||||||
|
</div>
|
||||||
{/* Notes */}
|
) : (
|
||||||
{participant.notes && (
|
<EmptyState
|
||||||
<div className="border-t pt-4">
|
icon="Play"
|
||||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
title="No Trials Yet"
|
||||||
Notes
|
description="This participant hasn't been assigned to any trials."
|
||||||
</h4>
|
action={
|
||||||
<p className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
|
canEdit && (
|
||||||
{participant.notes}
|
<Button asChild>
|
||||||
</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>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||||
>
|
>
|
||||||
Schedule Trial
|
Schedule First Trial
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
</EntityView>
|
||||||
} catch {
|
);
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Building,
|
Building,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
FileText,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
EntityView,
|
||||||
CardContent,
|
EntityViewHeader,
|
||||||
CardDescription,
|
EntityViewSection,
|
||||||
CardHeader,
|
EntityViewSidebar,
|
||||||
CardTitle,
|
EmptyState,
|
||||||
} from "~/components/ui/card";
|
InfoGrid,
|
||||||
|
QuickActions,
|
||||||
|
StatsGrid,
|
||||||
|
} from "~/components/ui/entity-view";
|
||||||
import { Separator } from "~/components/ui/separator";
|
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 {
|
interface StudyDetailPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -32,326 +46,281 @@ interface StudyDetailPageProps {
|
|||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
className: "bg-gray-100 text-gray-800",
|
variant: "secondary" as const,
|
||||||
icon: "📝",
|
icon: "FileText" as const,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
className: "bg-green-100 text-green-800",
|
variant: "default" as const,
|
||||||
icon: "🟢",
|
icon: "CheckCircle" as const,
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
className: "bg-blue-100 text-blue-800",
|
variant: "outline" as const,
|
||||||
icon: "✅",
|
icon: "CheckCircle" as const,
|
||||||
},
|
},
|
||||||
archived: {
|
archived: {
|
||||||
label: "Archived",
|
label: "Archived",
|
||||||
className: "bg-orange-100 text-orange-800",
|
variant: "destructive" as const,
|
||||||
icon: "📦",
|
icon: "XCircle" as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function StudyDetailPage({
|
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||||
params,
|
const { data: session } = useSession();
|
||||||
}: StudyDetailPageProps) {
|
const [study, setStudy] = useState<any>(null);
|
||||||
try {
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
const resolvedParams = await params;
|
const [loading, setLoading] = useState(true);
|
||||||
const study = await api.studies.get({ id: resolvedParams.id });
|
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||||
const members = await api.studies.getMembers({
|
null,
|
||||||
studyId: resolvedParams.id,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!study) {
|
useEffect(() => {
|
||||||
notFound();
|
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 (
|
const { data: membersData } = api.studies.getMembers.useQuery(
|
||||||
<div className="p-8">
|
{ studyId: resolvedParams?.id ?? "" },
|
||||||
{/* Header */}
|
{ enabled: !!resolvedParams?.id },
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
useEffect(() => {
|
||||||
<div className="min-w-0 flex-1">
|
if (studyData) {
|
||||||
<div className="mb-2 flex items-center space-x-3">
|
setStudy(studyData);
|
||||||
<h1 className="truncate text-3xl font-bold text-slate-900">
|
}
|
||||||
{study.name}
|
if (membersData) {
|
||||||
</h1>
|
setMembers(membersData);
|
||||||
<Badge className={statusInfo.className} variant="secondary">
|
}
|
||||||
<span className="mr-1">{statusInfo.icon}</span>
|
if (studyData !== undefined) {
|
||||||
{statusInfo.label}
|
setLoading(false);
|
||||||
</Badge>
|
}
|
||||||
</div>
|
}, [studyData, membersData]);
|
||||||
<p className="text-lg text-slate-600">{study.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4 flex items-center space-x-2">
|
// Set breadcrumbs
|
||||||
<Button asChild variant="outline">
|
useBreadcrumbsEffect([
|
||||||
<Link href={`/studies/${study.id}/edit`}>
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
{ label: "Studies", href: "/studies" },
|
||||||
Edit Study
|
{ label: study?.name || "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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
if (!session?.user) {
|
||||||
{/* Main Content */}
|
return notFound();
|
||||||
<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 (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 */}
|
{/* Total Users */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Total Users
|
Total Users
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -62,7 +62,7 @@ export function SystemStats() {
|
|||||||
{/* Total Studies */}
|
{/* Total Studies */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Studies
|
Studies
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -79,7 +79,7 @@ export function SystemStats() {
|
|||||||
{/* Total Experiments */}
|
{/* Total Experiments */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Experiments
|
Experiments
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -98,7 +98,7 @@ export function SystemStats() {
|
|||||||
{/* Total Trials */}
|
{/* Total Trials */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Trials
|
Trials
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -115,7 +115,7 @@ export function SystemStats() {
|
|||||||
{/* System Health */}
|
{/* System Health */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
System Health
|
System Health
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -124,11 +124,11 @@ export function SystemStats() {
|
|||||||
<div className="flex h-3 w-3 items-center justify-center">
|
<div className="flex h-3 w-3 items-center justify-center">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||||
</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"}
|
{displayStats.systemHealth === "healthy" ? "Healthy" : "Issues"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
<div className="text-muted-foreground mt-1 text-xs">
|
||||||
All services operational
|
All services operational
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -137,41 +137,49 @@ export function SystemStats() {
|
|||||||
{/* Uptime */}
|
{/* Uptime */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Uptime
|
Uptime
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm font-medium">{displayStats.uptime}</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Storage Usage */}
|
{/* Storage Usage */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Storage Used
|
Storage Used
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm font-medium">{displayStats.storageUsed}</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-600">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs text-slate-600">2 trials started today</div>
|
<div className="text-muted-foreground text-xs">
|
||||||
<div className="text-xs text-slate-600">1 new user registered</div>
|
2 trials started today
|
||||||
<div className="text-xs text-slate-600">
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
1 new user registered
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
3 experiments published
|
3 experiments published
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
|
||||||
Users,
|
Users,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
TestTube,
|
TestTube,
|
||||||
@@ -40,6 +39,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "~/components/ui/sidebar";
|
} from "~/components/ui/sidebar";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
|
||||||
import { Logo } from "~/components/ui/logo";
|
import { Logo } from "~/components/ui/logo";
|
||||||
|
|
||||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||||
@@ -292,18 +292,60 @@ export function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton size="lg">
|
<SidebarMenuButton
|
||||||
<User className="h-4 w-4" />
|
size="lg"
|
||||||
<span>{session?.user?.name ?? "User"}</span>
|
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"
|
||||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
>
|
||||||
|
<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>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-[--radix-popper-anchor-width]"
|
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||||
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
{session?.user?.name ?? "User"}
|
<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>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type Trial = {
|
|||||||
logs: number;
|
logs: number;
|
||||||
};
|
};
|
||||||
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||||
|
canAccess?: boolean;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
canExecute?: boolean;
|
canExecute?: boolean;
|
||||||
@@ -162,12 +163,19 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
|||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
{trial.canAccess ? (
|
||||||
<Link href={`/trials/${trial.id}`}>
|
<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" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View Details
|
View Details (Restricted)
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
|
|
||||||
{trial.canEdit && (
|
{trial.canEdit && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
@@ -272,13 +280,33 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
|||||||
const trial = row.original;
|
const trial = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[140px] min-w-0">
|
<div className="max-w-[140px] min-w-0">
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
href={`/trials/${trial.id}`}
|
{trial.canAccess ? (
|
||||||
className="block truncate font-medium hover:underline"
|
<Link
|
||||||
title={trial.name}
|
href={`/trials/${trial.id}`}
|
||||||
>
|
className="block truncate font-medium hover:underline"
|
||||||
{trial.name}
|
title={trial.name}
|
||||||
</Link>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -290,16 +318,28 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.getValue("status") as Trial["status"];
|
const status = row.getValue("status") as Trial["status"];
|
||||||
|
const trial = row.original;
|
||||||
const config = statusConfig[status];
|
const config = statusConfig[status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<div className="flex flex-col gap-1">
|
||||||
variant="secondary"
|
<Badge
|
||||||
className={`${config.className} whitespace-nowrap`}
|
variant="secondary"
|
||||||
title={config.description}
|
className={`${config.className} whitespace-nowrap`}
|
||||||
>
|
title={config.description}
|
||||||
{config.label}
|
>
|
||||||
</Badge>
|
{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[]) => {
|
filterFn: (row, id, value: string[]) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Plus, TestTube } from "lucide-react";
|
import { Plus, TestTube, Eye } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable } from "~/components/ui/data-table";
|
import { DataTable } from "~/components/ui/data-table";
|
||||||
@@ -111,14 +111,19 @@ export function TrialsDataTable() {
|
|||||||
actions: trial._count?.events ?? 0,
|
actions: trial._count?.events ?? 0,
|
||||||
logs: trial._count?.mediaCaptures ?? 0,
|
logs: trial._count?.mediaCaptures ?? 0,
|
||||||
},
|
},
|
||||||
userRole: undefined,
|
userRole: trial.userRole,
|
||||||
canEdit: trial.status === "scheduled" || trial.status === "aborted",
|
canAccess: trial.canAccess ?? false,
|
||||||
|
canEdit:
|
||||||
|
trial.canAccess &&
|
||||||
|
(trial.status === "scheduled" || trial.status === "aborted"),
|
||||||
canDelete:
|
canDelete:
|
||||||
trial.status === "scheduled" ||
|
trial.canAccess &&
|
||||||
trial.status === "aborted" ||
|
(trial.status === "scheduled" ||
|
||||||
trial.status === "failed",
|
trial.status === "aborted" ||
|
||||||
|
trial.status === "failed"),
|
||||||
canExecute:
|
canExecute:
|
||||||
trial.status === "scheduled" || trial.status === "in_progress",
|
trial.canAccess &&
|
||||||
|
(trial.status === "scheduled" || trial.status === "in_progress"),
|
||||||
}));
|
}));
|
||||||
}, [trialsData]);
|
}, [trialsData]);
|
||||||
|
|
||||||
@@ -204,6 +209,29 @@ export function TrialsDataTable() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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
|
<DataTable
|
||||||
columns={trialsColumns}
|
columns={trialsColumns}
|
||||||
data={filteredTrials}
|
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 { Bot } from "lucide-react";
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
className?: string
|
className?: string;
|
||||||
iconSize?: "sm" | "md" | "lg"
|
iconSize?: "sm" | "md" | "lg";
|
||||||
showText?: boolean
|
showText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconSizes = {
|
const iconSizes = {
|
||||||
sm: "h-4 w-4",
|
sm: "h-4 w-4",
|
||||||
md: "h-6 w-6",
|
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 (
|
return (
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
<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]} />
|
<Bot className={iconSizes[iconSize]} />
|
||||||
</div>
|
</div>
|
||||||
{showText && (
|
{showText && (
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<div className="flex items-baseline gap-0">
|
<div className="flex items-baseline gap-0">
|
||||||
<span className="text-base font-extrabold tracking-tight">HRI</span>
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,23 +128,24 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
id: participants.id,
|
id: participants.id,
|
||||||
participantCode: participants.participantCode,
|
participantCode: participants.participantCode,
|
||||||
},
|
},
|
||||||
|
userRole: studyMembers.role,
|
||||||
})
|
})
|
||||||
.from(trials)
|
.from(trials)
|
||||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||||
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
||||||
.where(
|
.where(and(eq(studyMembers.userId, userId), ...conditions))
|
||||||
and(
|
|
||||||
eq(studyMembers.userId, userId),
|
|
||||||
inArray(studyMembers.role, ["owner", "researcher", "wizard"]),
|
|
||||||
...conditions,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(trials.createdAt))
|
.orderBy(desc(trials.createdAt))
|
||||||
.limit(input.limit)
|
.limit(input.limit)
|
||||||
.offset(input.offset);
|
.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
|
get: protectedProcedure
|
||||||
@@ -575,15 +576,19 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const userId = ctx.session.user.id;
|
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({
|
const userStudies = await ctx.db.query.studyMembers.findMany({
|
||||||
where: eq(studyMembers.userId, userId),
|
where: eq(studyMembers.userId, userId),
|
||||||
columns: {
|
columns: {
|
||||||
studyId: true,
|
studyId: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let studyIds = userStudies.map((membership) => membership.studyId);
|
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 is provided, filter to just that study (if user has access)
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
@@ -704,14 +709,22 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||||
|
|
||||||
// Transform data to include counts
|
// Transform data to include counts and permission information
|
||||||
const transformedTrials = filteredTrials.map((trial) => ({
|
const transformedTrials = filteredTrials.map((trial) => {
|
||||||
...trial,
|
const userRole = userStudyRoles.get(trial.experiment.studyId);
|
||||||
_count: {
|
const canAccess =
|
||||||
events: trial.events?.length ?? 0,
|
userRole && ["owner", "researcher", "wizard"].includes(userRole);
|
||||||
mediaCaptures: trial.mediaCaptures?.length ?? 0,
|
|
||||||
},
|
return {
|
||||||
}));
|
...trial,
|
||||||
|
_count: {
|
||||||
|
events: trial.events?.length ?? 0,
|
||||||
|
mediaCaptures: trial.mediaCaptures?.length ?? 0,
|
||||||
|
},
|
||||||
|
userRole,
|
||||||
|
canAccess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trials: transformedTrials,
|
trials: transformedTrials,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@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";
|
"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-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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 {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius:
|
||||||
--background: oklch(1 0 0);
|
0rem;
|
||||||
--foreground: oklch(0.145 0 0);
|
--background:
|
||||||
--card: oklch(1 0 0);
|
oklch(1.0000 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--foreground:
|
||||||
--popover: oklch(1 0 0);
|
oklch(0.1448 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--card:
|
||||||
--primary: oklch(0.205 0 0);
|
oklch(1.0000 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--card-foreground:
|
||||||
--secondary: oklch(0.97 0 0);
|
oklch(0.1448 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--popover:
|
||||||
--muted: oklch(0.97 0 0);
|
oklch(1.0000 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--popover-foreground:
|
||||||
--accent: oklch(0.97 0 0);
|
oklch(0.1448 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--primary:
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
oklch(0.5555 0 0);
|
||||||
--border: oklch(0.922 0 0);
|
--primary-foreground:
|
||||||
--input: oklch(0.922 0 0);
|
oklch(0.9851 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--secondary:
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
oklch(0.9702 0 0);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--secondary-foreground:
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
oklch(0.2046 0 0);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--muted:
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
oklch(0.9702 0 0);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--muted-foreground:
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
oklch(0.5486 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--accent:
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
oklch(0.9702 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--accent-foreground:
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
oklch(0.2046 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--destructive:
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
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 {
|
@media (prefers-color-scheme: dark) {
|
||||||
--background: oklch(0.145 0 0);
|
:root {
|
||||||
--foreground: oklch(0.985 0 0);
|
--background: 2 6 23;
|
||||||
--card: oklch(0.205 0 0);
|
--foreground: 248 250 252;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card: 15 23 42;
|
||||||
--popover: oklch(0.205 0 0);
|
--card-foreground: 248 250 252;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover: 15 23 42;
|
||||||
--primary: oklch(0.922 0 0);
|
--popover-foreground: 248 250 252;
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary: 148 163 184;
|
||||||
--secondary: oklch(0.269 0 0);
|
--primary-foreground: 15 23 42;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary: 30 41 59;
|
||||||
--muted: oklch(0.269 0 0);
|
--secondary-foreground: 248 250 252;
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted: 30 41 59;
|
||||||
--accent: oklch(0.269 0 0);
|
--muted-foreground: 148 163 184;
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent: 30 41 59;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--accent-foreground: 248 250 252;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--destructive: 239 68 68;
|
||||||
--input: oklch(1 0 0 / 15%);
|
--border: 51 65 85;
|
||||||
--ring: oklch(0.556 0 0);
|
--input: 51 65 85;
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--ring: 148 163 184;
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-1: 148 163 184;
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-2: 100 116 139;
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-3: 239 68 68;
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-4: 245 158 11;
|
||||||
--sidebar: oklch(0.205 0 0);
|
--chart-5: 34 197 94;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar: 15 23 42;
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-foreground: 148 163 184;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary: 148 163 184;
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-primary-foreground: 15 23 42;
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent: 30 41 59;
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-accent-foreground: 248 250 252;
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-border: 51 65 85;
|
||||||
|
--sidebar-ring: 148 163 184;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -121,5 +235,114 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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