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:
2025-08-05 02:36:44 -04:00
parent 7cdc1a2340
commit 544207e9a2
16 changed files with 3643 additions and 1531 deletions

View File

@@ -1,8 +1,11 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
ArrowLeft,
Calendar,
CheckCircle,
Edit,
FileText,
Mail,
@@ -10,21 +13,26 @@ import {
Shield,
Trash2,
Users,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
interface ParticipantDetailPageProps {
params: Promise<{
@@ -32,415 +40,372 @@ interface ParticipantDetailPageProps {
}>;
}
export default async function ParticipantDetailPage({
export default function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const resolvedParams = await params;
const session = await auth();
const { data: session } = useSession();
const [participant, setParticipant] = useState<any>(null);
const [trials, setTrials] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
}, [params]);
const { data: participantData } = api.participants.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ participantId: resolvedParams?.id ?? "", limit: 10 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (participantData) {
setParticipant(participantData);
}
if (trialsData) {
setTrials(trialsData);
}
if (participantData !== undefined) {
setLoading(false);
}
}, [participantData, trialsData]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants", href: "/participants" },
{
label: participant?.name || participant?.participantCode || "Participant",
},
]);
if (!session?.user) {
return notFound();
}
try {
const participant = await api.participants.get({ id: resolvedParams.id });
if (loading || !participant) {
return <div>Loading...</div>;
}
if (!participant) {
return notFound();
}
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
// canDelete removed - not used in component
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={participant.name ?? participant.participantCode}
subtitle={
participant.name
? `Code: ${participant.participantCode}`
: "Participant"
}
icon="Users"
actions={
canEdit && (
<>
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</>
)
}
/>
// Get participant's trials
const trials = await api.trials.list({
participantId: resolvedParams.id,
limit: 10,
});
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Participant Information */}
<EntityViewSection title="Participant Information" icon="FileText">
<InfoGrid
items={[
{
label: "Participant Code",
value: (
<code className="bg-muted rounded px-2 py-1 font-mono text-sm">
{participant.participantCode}
</code>
),
},
{
label: "Name",
value: participant.name || "Not provided",
},
{
label: "Email",
value: participant.email ? (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4" />
<a
href={`mailto:${participant.email}`}
className="hover:underline"
>
{participant.email}
</a>
</div>
) : (
"Not provided"
),
},
{
label: "Study",
value: participant.study ? (
<Link
href={`/studies/${participant.study.id}`}
className="text-primary hover:underline"
>
{participant.study.name}
</Link>
) : (
"No study assigned"
),
},
]}
/>
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/participants">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Participants
</Link>
</Button>
</div>
{/* Demographics */}
{participant.demographics &&
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics).length > 0 && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
Demographics
</h4>
<InfoGrid
items={(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
return [
demo.age && {
label: "Age",
value:
typeof demo.age === "number"
? demo.age.toString()
: String(demo.age),
},
demo.gender && {
label: "Gender",
value: String(demo.gender),
},
].filter(Boolean) as Array<{
label: string;
value: string;
}>;
})()}
/>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-primary text-primary-foreground flex h-16 w-16 items-center justify-center rounded-lg">
<Users className="h-8 w-8" />
</div>
<div>
<h1 className="text-foreground text-3xl font-bold">
{participant.name ?? participant.participantCode}
</h1>
<p className="text-muted-foreground text-lg">
{participant.name
? `Code: ${participant.participantCode}`
: "Participant"}
</p>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
{/* Notes */}
{participant.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
</h4>
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{participant.notes}
</div>
</div>
)}
</div>
</div>
</EntityViewSection>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Participant Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Participant Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Participant Code
</h4>
<p className="bg-muted rounded px-2 py-1 font-mono text-sm">
{participant.participantCode}
</p>
</div>
{participant.name && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Name
</h4>
<p className="text-sm">{participant.name}</p>
</div>
)}
{participant.email && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Email
</h4>
<p className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4" />
<a
href={`mailto:${participant.email}`}
className="hover:underline"
>
{participant.email}
</a>
</p>
</div>
)}
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Study
</h4>
<p className="text-sm">
{/* Trial History */}
<EntityViewSection
title="Trial History"
icon="Play"
description="Experimental sessions for this participant"
actions={
canEdit && (
<Button size="sm" asChild>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
Schedule Trial
</Link>
</Button>
)
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${participant.study?.id}`}
className="text-primary hover:underline"
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{participant.study?.name}
{trial.experiment?.name || "Trial"}
</Link>
</p>
</div>
</div>
{participant.demographics &&
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics).length > 0 ? (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Demographics
</h4>
<div className="grid gap-4 md:grid-cols-2">
{(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
return (
<>
{demo.age && (
<div>
<span className="text-sm font-medium">
Age:
</span>{" "}
<span className="text-sm">
{typeof demo.age === "number"
? demo.age.toString()
: String(demo.age)}
</span>
</div>
)}
{demo.gender && (
<div>
<span className="text-sm font-medium">
Gender:
</span>{" "}
<span className="text-sm">
{String(demo.gender)}
</span>
</div>
)}
</>
);
})()}
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{trial.createdAt
? formatDistanceToNow(new Date(trial.createdAt), {
addSuffix: true,
})
: "Not scheduled"}
</span>
{trial.duration && (
<span>{Math.round(trial.duration / 60)} minutes</span>
)}
</div>
</div>
) : null}
{/* Notes */}
{participant.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
</h4>
<p className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{participant.notes}
</p>
</div>
)}
</CardContent>
</Card>
{/* Trial History */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Play className="h-5 w-5" />
Trial History
</CardTitle>
{canEdit && (
<Button size="sm" asChild>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No Trials Yet"
description="This participant hasn't been assigned to any trials."
action={
canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule Trial
Schedule First Trial
</Link>
</Button>
)}
</div>
<CardDescription>
Experimental sessions for this participant
</CardDescription>
</CardHeader>
<CardContent>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{trial.experiment?.name || "Trial"}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{trial.createdAt
? formatDistanceToNow(new Date(trial.createdAt), {
addSuffix: true,
})
: "Not scheduled"}
</span>
{trial.duration && (
<span>
{Math.round(trial.duration / 60)} minutes
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="py-8 text-center">
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 font-medium">No Trials Yet</h3>
<p className="text-muted-foreground mb-4 text-sm">
This participant hasn&apos;t been assigned to any trials.
</p>
{canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule First Trial
</Link>
</Button>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Consent Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-4 w-4" />
Consent Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={
participant.consentGiven ? "default" : "destructive"
}
>
{participant.consentGiven ? "Given" : "Not Given"}
</Badge>
</div>
{participant.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(participant.consentDate, {
addSuffix: true,
})}
</div>
)}
{!participant.consentGiven && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Consent required before trials can be conducted.
</AlertDescription>
</Alert>
)}
</div>
</CardContent>
</Card>
{/* Registration Details */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Registration Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Registered
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.createdAt, {
addSuffix: true,
})}
</p>
</div>
{participant.updatedAt &&
participant.updatedAt !== participant.createdAt && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Last Updated
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.updatedAt, {
addSuffix: true,
})}
</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Actions */}
{canEdit && (
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
<Play className="mr-2 h-4 w-4" />
Schedule Trial
</Link>
</Button>
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Information
</Link>
</Button>
<Button variant="outline" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Export Data
</Button>
</CardContent>
</Card>
)
}
/>
)}
</div>
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Consent Status */}
<EntityViewSection title="Consent Status" icon="Shield">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={participant.consentGiven ? "default" : "destructive"}
>
{participant.consentGiven ? (
<>
<CheckCircle className="mr-1 h-3 w-3" />
Given
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" />
Not Given
</>
)}
</Badge>
</div>
{participant.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(participant.consentDate, {
addSuffix: true,
})}
</div>
)}
{!participant.consentGiven && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Consent required before trials can be conducted.
</AlertDescription>
</Alert>
)}
</div>
</EntityViewSection>
{/* Registration Details */}
<EntityViewSection title="Registration Details" icon="Calendar">
<InfoGrid
columns={1}
items={[
{
label: "Registered",
value: formatDistanceToNow(participant.createdAt, {
addSuffix: true,
}),
},
...(participant.updatedAt &&
participant.updatedAt !== participant.createdAt
? [
{
label: "Last Updated",
value: formatDistanceToNow(participant.updatedAt, {
addSuffix: true,
}),
},
]
: []),
]}
/>
</EntityViewSection>
{/* Quick Actions */}
{canEdit && (
<EntityViewSection title="Quick Actions" icon="Edit">
<QuickActions
actions={[
{
label: "Schedule Trial",
icon: "Play",
href: `/trials/new?participantId=${resolvedParams.id}`,
},
{
label: "Edit Information",
icon: "Edit",
href: `/participants/${resolvedParams.id}/edit`,
},
{
label: "Export Data",
icon: "FileText",
href: `/participants/${resolvedParams.id}/export`,
},
]}
/>
</EntityViewSection>
)}
</EntityViewSidebar>
</div>
);
} catch {
return notFound();
}
</EntityView>
);
}