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:
@@ -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 · {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>
|
||||
|
||||
Reference in New Issue
Block a user