Comprehensive platform audit: security, UX, performance, and visual polish

Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions

Phase 2: Admin UX - search/filter for awards, learning, partners pages

Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions

Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting

Phase 5: Portals - observer charts, mentor search, login/onboarding polish

Phase 6: Messages preview dialog, CsvExportDialog with column selection

Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook

Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 22:05:01 +01:00
parent e0e4cb2a32
commit e73a676412
33 changed files with 3193 additions and 977 deletions

View File

@@ -16,6 +16,7 @@ import {
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import {
CircleDot,
ClipboardList,
@@ -25,13 +26,27 @@ import {
TrendingUp,
ArrowRight,
Layers,
Activity,
AlertTriangle,
ShieldAlert,
Plus,
Upload,
UserPlus,
FileEdit,
LogIn,
Send,
Eye,
Trash2,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { AnimatedCard } from '@/components/shared/animated-container'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import {
formatDateOnly,
formatEnumLabel,
formatRelativeTime,
truncate,
daysUntil,
} from '@/lib/utils'
@@ -104,6 +119,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
] = await Promise.all([
prisma.round.count({
where: { programId: editionId, status: 'ACTIVE' },
@@ -146,7 +165,13 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
submissionEndDate: true,
_count: {
select: {
projects: true,
@@ -188,6 +213,40 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { round: { programId: editionId } },
_count: true,
}),
// Recent activity feed (scoped to last 7 days for performance)
prisma.auditLog.findMany({
where: {
timestamp: { gte: sevenDaysAgo },
},
orderBy: { timestamp: 'desc' },
take: 8,
select: {
id: true,
action: true,
entityType: true,
timestamp: true,
user: { select: { name: true } },
},
}),
// Pending COI declarations (hasConflict declared but not yet reviewed)
prisma.conflictOfInterest.count({
where: {
hasConflict: true,
reviewedAt: null,
assignment: { round: { programId: editionId } },
},
}),
// Draft rounds needing activation
prisma.round.count({
where: { programId: editionId, status: 'DRAFT' },
}),
// Projects without assignments in active rounds
prisma.project.count({
where: {
round: { programId: editionId, status: 'ACTIVE' },
assignments: { none: {} },
},
}),
])
const submittedCount =
@@ -253,6 +312,40 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
// Helper: human-readable action descriptions for audit log
function formatAction(action: string, entityType: string | null): string {
const entity = entityType?.toLowerCase() || 'record'
const actionMap: Record<string, string> = {
CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`,
DELETE: `deleted a ${entity}`,
LOGIN: 'logged in',
EXPORT: `exported ${entity} data`,
SUBMIT: `submitted an ${entity}`,
ASSIGN: `assigned a ${entity}`,
INVITE: `invited a user`,
STATUS_CHANGE: `changed ${entity} status`,
BULK_UPDATE: `bulk updated ${entity}s`,
IMPORT: `imported ${entity}s`,
}
return actionMap[action] || `${action.toLowerCase()} ${entity}`
}
// Helper: pick an icon for an audit action
function getActionIcon(action: string) {
switch (action) {
case 'CREATE': return <Plus className="h-3.5 w-3.5" />
case 'UPDATE': return <FileEdit className="h-3.5 w-3.5" />
case 'DELETE': return <Trash2 className="h-3.5 w-3.5" />
case 'LOGIN': return <LogIn className="h-3.5 w-3.5" />
case 'EXPORT': return <ArrowRight className="h-3.5 w-3.5" />
case 'SUBMIT': return <Send className="h-3.5 w-3.5" />
case 'ASSIGN': return <Users className="h-3.5 w-3.5" />
case 'INVITE': return <UserPlus className="h-3.5 w-3.5" />
default: return <Eye className="h-3.5 w-3.5" />
}
}
return (
<>
{/* Header */}
@@ -265,69 +358,99 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
<AnimatedCard index={0}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/rounds/new">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Round
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/projects/new">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import Projects
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/members">
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Invite Jury
</Link>
</Button>
</div>
{/* Two-Column Content */}
@@ -374,22 +497,12 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
href={`/admin/rounds/${round.id}`}
className="block"
>
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'success'
: 'secondary'
}
>
{round.status}
</Badge>
<StatusBadge status={round.status} />
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
@@ -447,7 +560,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-start gap-3 rounded-lg p-3 transition-colors hover:bg-muted/50">
<div className="flex items-start gap-3 rounded-lg p-3 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-sm">
<ProjectLogo
project={project}
size="sm"
@@ -458,12 +571,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
<p className="font-medium text-sm leading-tight truncate">
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
@@ -500,6 +612,53 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Pending Actions Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Pending Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
</div>
<Badge variant="warning">{pendingCOIs}</Badge>
</Link>
)}
{unassignedProjects > 0 && (
<Link href="/admin/projects" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-orange-500" />
<span className="text-sm">Projects without assignments</span>
</div>
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftRounds > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft rounds to activate</span>
</div>
<Badge variant="secondary">{draftRounds}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Evaluation Progress Card */}
<Card>
<CardHeader>
@@ -604,6 +763,45 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
{recentActivity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No recent activity
</p>
</div>
) : (
<div className="space-y-3">
{recentActivity.map((log) => (
<div key={log.id} className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
{getActionIcon(log.action)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">{log.user?.name || 'System'}</span>
{' '}{formatAction(log.action, log.entityType)}
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(log.timestamp)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Upcoming Deadlines Card */}
<Card>
<CardHeader>