Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening
UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,14 +64,14 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Shuffle,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
UserPlus,
|
||||
Cpu,
|
||||
Brain,
|
||||
Calculator,
|
||||
Workflow,
|
||||
Search,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
@@ -829,7 +829,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
<Shuffle className="h-5 w-5 text-amber-500" />
|
||||
Smart Assignment Suggestions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -844,7 +844,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="algorithm" className="gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<Calculator className="h-4 w-4" />
|
||||
Algorithm
|
||||
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
@@ -853,7 +853,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
|
||||
<Brain className="h-4 w-4" />
|
||||
<Workflow className="h-4 w-4" />
|
||||
AI Powered
|
||||
{aiSuggestions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
@@ -983,7 +983,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
/>
|
||||
) : !hasStoredAISuggestions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||
<Workflow className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No AI analysis yet</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Click "Start Analysis" to generate AI-powered suggestions
|
||||
@@ -995,7 +995,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
{startAIJob.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
<Workflow className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Start AI Analysis
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
GripVertical,
|
||||
Loader2,
|
||||
FileCheck,
|
||||
Brain,
|
||||
SlidersHorizontal,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -56,7 +56,7 @@ const RULE_TYPE_LABELS: Record<RuleType, string> = {
|
||||
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
|
||||
FIELD_BASED: <Filter className="h-4 w-4" />,
|
||||
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
|
||||
AI_SCREENING: <Brain className="h-4 w-4" />,
|
||||
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
|
||||
@@ -81,13 +81,17 @@ import {
|
||||
AlertTriangle,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Sparkles,
|
||||
FileSearch,
|
||||
LayoutTemplate,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
RotateCcw,
|
||||
Zap,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
@@ -126,6 +130,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
const [jobPollInterval, setJobPollInterval] = useState(2000)
|
||||
|
||||
// Inline filtering results state
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
||||
@@ -140,7 +145,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
|
||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
|
||||
const progress = round?.progress
|
||||
|
||||
// Check if this is a filtering round - roundType is stored directly on the round
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
@@ -149,7 +155,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
||||
trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound, staleTime: 0 }
|
||||
{ enabled: isFilteringRound, staleTime: 30_000 }
|
||||
)
|
||||
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
||||
{ roundId },
|
||||
@@ -162,31 +168,41 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound, staleTime: 0 }
|
||||
{ enabled: isFilteringRound, staleTime: 30_000 }
|
||||
)
|
||||
|
||||
// Poll for job status when there's an active job
|
||||
// Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap)
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
{ jobId: activeJobId! },
|
||||
{
|
||||
enabled: !!activeJobId,
|
||||
refetchInterval: activeJobId ? 2000 : false,
|
||||
refetchInterval: activeJobId ? jobPollInterval : false,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
|
||||
// Increase polling interval over time (exponential backoff)
|
||||
useEffect(() => {
|
||||
if (!activeJobId) {
|
||||
setJobPollInterval(2000)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setJobPollInterval((prev) => Math.min(prev * 2, 15000))
|
||||
}, jobPollInterval)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeJobId, jobPollInterval])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.round.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
},
|
||||
})
|
||||
const deleteRound = trpc.round.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Round deleted')
|
||||
utils.program.list.invalidate()
|
||||
utils.round.list.invalidate()
|
||||
router.push('/admin/rounds')
|
||||
},
|
||||
@@ -200,7 +216,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -218,7 +233,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
},
|
||||
{
|
||||
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
|
||||
staleTime: 0,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
)
|
||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||
@@ -286,6 +301,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const handleStartFiltering = async () => {
|
||||
try {
|
||||
const result = await startJob.mutateAsync({ roundId })
|
||||
setJobPollInterval(2000)
|
||||
setActiveJobId(result.jobId)
|
||||
toast.info('Filtering job started. Progress will update automatically.')
|
||||
} catch (error) {
|
||||
@@ -309,8 +325,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
}
|
||||
refetchFilteringStats()
|
||||
refetchRound()
|
||||
utils.project.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -340,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
setOverrideReason('')
|
||||
refetchResults()
|
||||
refetchFilteringStats()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to override result')
|
||||
}
|
||||
@@ -352,7 +365,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
toast.success('Project reinstated')
|
||||
refetchResults()
|
||||
refetchFilteringStats()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to reinstate project')
|
||||
}
|
||||
@@ -548,11 +560,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<AnimatedCard index={0}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 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>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.projects}</div>
|
||||
@@ -562,10 +577,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.assignments}</div>
|
||||
@@ -577,10 +594,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round.requiredReviews}</div>
|
||||
@@ -588,10 +607,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
@@ -603,12 +624,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && progress.totalAssignments > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Evaluation Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
@@ -616,7 +644,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
<span>Overall Completion</span>
|
||||
<span>{progress.completionPercentage}%</span>
|
||||
</div>
|
||||
<Progress value={progress.completionPercentage} />
|
||||
<Progress value={progress.completionPercentage} gradient />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
@@ -631,12 +659,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Voting Window */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<Clock className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Voting Window
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -723,15 +758,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Filtering Section (for FILTERING rounds) */}
|
||||
{isFilteringRound && (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<Filter className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Project Filtering
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -782,7 +821,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<Progress value={progressPercent} className="h-2" gradient />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1226,12 +1265,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Management */}
|
||||
@@ -1275,6 +1321,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Live Voting
|
||||
</Link>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1287,7 +1339,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
@@ -1319,6 +1371,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AssignProjectsDialog
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type RoundData = {
|
||||
id: string
|
||||
@@ -108,8 +109,10 @@ function RoundsContent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program) => (
|
||||
<ProgramRounds key={program.id} program={program} />
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<ProgramRounds program={program} />
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -485,7 +488,7 @@ function SortableRoundRow({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card transition-all',
|
||||
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
||||
isReordering && !isDragging && 'opacity-50'
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user