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

@@ -30,7 +30,7 @@ import {
AlertCircle,
} from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import { formatDate, truncate } from '@/lib/utils'
import { cn, formatDate, truncate } from '@/lib/utils'
function getCriteriaProgress(evaluation: {
criterionScoresJson: unknown
@@ -47,6 +47,17 @@ function getCriteriaProgress(evaluation: {
return { completed, total }
}
function getDeadlineUrgency(deadline: Date | null): { label: string; className: string } | null {
if (!deadline) return null
const now = new Date()
const diff = deadline.getTime() - now.getTime()
const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (daysLeft < 0) return { label: 'Overdue', className: 'text-muted-foreground' }
if (daysLeft <= 2) return { label: `${daysLeft}d left`, className: 'text-red-600 font-semibold' }
if (daysLeft <= 7) return { label: `${daysLeft}d left`, className: 'text-amber-600 font-medium' }
return { label: `${daysLeft}d left`, className: 'text-muted-foreground' }
}
async function AssignmentsContent({
roundId,
}: {
@@ -134,8 +145,46 @@ async function AssignmentsContent({
const now = new Date()
const completedCount = assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length
const inProgressCount = assignments.filter(a => a.evaluation?.status === 'DRAFT').length
const pendingCount = assignments.filter(a => !a.evaluation).length
const overallProgress = assignments.length > 0 ? Math.round((completedCount / assignments.length) * 100) : 0
return (
<div className="space-y-6">
{/* Progress Summary */}
<Card className="bg-muted/30">
<CardContent className="py-4">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
<div>
<p className="text-2xl font-bold">{inProgressCount}</p>
<p className="text-xs text-muted-foreground">In Progress</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
<div className="ml-auto">
<Progress value={overallProgress} className="h-2 w-32" />
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
</div>
</div>
</CardContent>
</Card>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
@@ -185,15 +234,22 @@ async function AssignmentsContent({
</TableCell>
<TableCell>
{assignment.round.votingEndAt ? (
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
<div>
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
) : (
<span className="text-muted-foreground">No deadline</span>
)}
@@ -309,7 +365,14 @@ async function AssignmentsContent({
{assignment.round.votingEndAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{formatDate(assignment.round.votingEndAt)}</span>
<div className="text-right">
<span>{formatDate(assignment.round.votingEndAt)}</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
</div>
)}
{isDraft && (() => {

View File

@@ -31,6 +31,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import {
AlertCircle,
ArrowLeft,
GitCompare,
MapPin,
@@ -354,6 +355,44 @@ export default function CompareProjectsPage() {
scales={data.scales}
/>
)}
{/* Divergence Summary */}
{data.criteria && (() => {
const scCriteria = data.criteria.filter((c) => c.type !== 'section_header')
const getMaxForCriterion = (criterion: Criterion) => {
if (criterion.scale && data.scales && data.scales[criterion.scale]) return data.scales[criterion.scale].max
return 10
}
const getScoreForItem = (item: ComparisonItem, criterionId: string): number | null => {
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
if (!scores) return null
const val = scores[criterionId]
if (val == null) return null
const num = Number(val)
return isNaN(num) ? null : num
}
const divergentCount = scCriteria.filter(criterion => {
const scores = data.items.map(item => getScoreForItem(item, criterion.id)).filter((s): s is number => s !== null)
if (scores.length < 2) return false
const max = Math.max(...scores)
const min = Math.min(...scores)
const range = getMaxForCriterion(criterion)
return range > 0 && (max - min) / range >= 0.4
}).length
if (divergentCount === 0) return null
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-amber-600" />
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{divergentCount} criterion{divergentCount > 1 ? 'a' : ''} with significant score divergence ({'>'}40% range difference)
</p>
</div>
</div>
)
})()}
</div>
)}
</div>
@@ -550,17 +589,22 @@ function CriterionComparisonTable({
const itemScores = items.map((item) => getScore(item, criterion.id))
const validScores = itemScores.filter((s): s is number => s !== null)
const highestScore = validScores.length > 0 ? Math.max(...validScores) : null
const minScore = validScores.length > 0 ? Math.min(...validScores) : null
const divergence = highestScore !== null && minScore !== null ? highestScore - minScore : 0
const maxPossibleDivergence = max
const isDivergent = validScores.length >= 2 && maxPossibleDivergence > 0 && (divergence / maxPossibleDivergence) >= 0.4
return (
<TableRow key={criterion.id}>
<TableRow key={criterion.id} className={cn(isDivergent && 'bg-amber-50 dark:bg-amber-950/20')}>
<TableCell className="font-medium">
<div>
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm">{criterion.label}</span>
{criterion.weight && criterion.weight > 1 && (
<span className="text-xs text-muted-foreground ml-1">
(x{criterion.weight})
</span>
)}
{isDivergent && <Badge variant="outline" className="text-[10px] ml-1.5 text-amber-600 border-amber-300">Divergent</Badge>}
</div>
</TableCell>
{items.map((item, idx) => {

View File

@@ -244,7 +244,7 @@ async function JuryDashboardContent() {
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.label}>
<Card key={stat.label} className="transition-all hover:shadow-md">
<CardContent className="flex items-center gap-4 py-4">
<div className={cn('rounded-full p-2.5', stat.iconBg)}>
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
@@ -432,7 +432,7 @@ async function JuryDashboardContent() {
<div
key={round.id}
className={cn(
'rounded-lg border p-4 space-y-3 transition-colors',
'rounded-lg border p-4 space-y-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
isUrgent && 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
)}
>