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:
@@ -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 && (() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user