Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements

- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields;
  add AudienceVoter model; make LiveVote.userId nullable for audience voters
- Backend: Criteria-based voting with weighted scores, audience registration/voting
  with token-based dedup, configurable jury/audience weight in results
- Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode
- Public audience voting page at /vote/[sessionId] with mobile-first design
- Admin live voting: Tabbed layout (Session/Config/Results), criteria config,
  audience settings, weight-adjustable results with tie detection
- Round type settings: Visual card selector replacing dropdown, feature tags
- Round detail page: Live event status section, type-specific stats and actions
- Round pipeline view: Horizontal visualization with bottleneck detection,
  List/Pipeline toggle on rounds page
- SSE: Separate jury/audience vote events, audience vote tracking
- Field visibility: Hide irrelevant fields per round type in create/edit forms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 14:27:49 +01:00
parent b5d90d3c26
commit 2a5fa463b3
14 changed files with 2518 additions and 456 deletions

View File

@@ -0,0 +1,203 @@
'use client'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Filter,
ClipboardCheck,
Zap,
CheckCircle2,
Clock,
Archive,
ChevronRight,
FileText,
Users,
AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PipelineRound = {
id: string
name: string
status: string
roundType: string
_count?: {
projects: number
assignments: number
}
}
interface RoundPipelineProps {
rounds: PipelineRound[]
programName?: string
}
const typeIcons: Record<string, typeof Filter> = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
const typeColors: Record<string, { bg: string; text: string; border: string }> = {
FILTERING: {
bg: 'bg-amber-50 dark:bg-amber-950/30',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
},
EVALUATION: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-200 dark:border-blue-800',
},
LIVE_EVENT: {
bg: 'bg-violet-50 dark:bg-violet-950/30',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-200 dark:border-violet-800',
},
}
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2; label: string }> = {
DRAFT: { color: 'text-muted-foreground', icon: Clock, label: 'Draft' },
ACTIVE: { color: 'text-green-600', icon: CheckCircle2, label: 'Active' },
CLOSED: { color: 'text-amber-600', icon: Archive, label: 'Closed' },
ARCHIVED: { color: 'text-muted-foreground', icon: Archive, label: 'Archived' },
}
export function RoundPipeline({ rounds }: RoundPipelineProps) {
if (rounds.length === 0) return null
// Detect bottlenecks: rounds with many more incoming projects than outgoing
const projectCounts = rounds.map((r) => r._count?.projects || 0)
return (
<div className="w-full overflow-x-auto pb-2">
<div className="flex items-stretch gap-1 min-w-max px-1 py-2">
{rounds.map((round, index) => {
const TypeIcon = typeIcons[round.roundType] || ClipboardCheck
const colors = typeColors[round.roundType] || typeColors.EVALUATION
const status = statusConfig[round.status] || statusConfig.DRAFT
const StatusIcon = status.icon
const projectCount = round._count?.projects || 0
const prevCount = index > 0 ? projectCounts[index - 1] : 0
const dropRate = prevCount > 0 ? Math.round(((prevCount - projectCount) / prevCount) * 100) : 0
const isBottleneck = dropRate > 50 && index > 0
return (
<div key={round.id} className="flex items-center">
{/* Round Card */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/admin/rounds/${round.id}`}
className={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 px-5 py-4 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 min-w-[140px]',
colors.bg,
colors.border,
round.status === 'ACTIVE' && 'ring-2 ring-green-500/30'
)}
>
{/* Status indicator dot */}
<div className="absolute -top-1.5 -right-1.5">
<div className={cn(
'h-3.5 w-3.5 rounded-full border-2 border-background',
round.status === 'ACTIVE' ? 'bg-green-500' :
round.status === 'CLOSED' ? 'bg-amber-500' :
round.status === 'DRAFT' ? 'bg-muted-foreground/40' :
'bg-muted-foreground/20'
)} />
</div>
{/* Type Icon */}
<div className={cn(
'rounded-lg p-2',
round.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-background'
)}>
<TypeIcon className={cn('h-5 w-5', colors.text)} />
</div>
{/* Round Name */}
<p className="text-sm font-medium text-center line-clamp-2 leading-tight max-w-[120px]">
{round.name}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{projectCount}
</span>
{round._count?.assignments !== undefined && round._count.assignments > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{round._count.assignments}
</span>
)}
</div>
{/* Status Badge */}
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', status.color)}
>
<StatusIcon className="mr-1 h-2.5 w-2.5" />
{status.label}
</Badge>
{/* Bottleneck indicator */}
{isBottleneck && (
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-medium">{round.name}</p>
<p className="text-xs capitalize">
{round.roundType.toLowerCase().replace('_', ' ')} &middot; {round.status.toLowerCase()}
</p>
<p className="text-xs">
{projectCount} projects
{round._count?.assignments ? `, ${round._count.assignments} assignments` : ''}
</p>
{isBottleneck && (
<p className="text-xs text-amber-600">
{dropRate}% drop from previous round
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Arrow connector */}
{index < rounds.length - 1 && (
<div className="flex flex-col items-center px-2">
<ChevronRight className="h-5 w-5 text-muted-foreground/40" />
{prevCount > 0 && index > 0 && dropRate > 0 && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
-{dropRate}%
</span>
)}
{index === 0 && projectCounts[0] > 0 && projectCounts[1] !== undefined && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
{projectCounts[0]} &rarr; {projectCounts[1] || '?'}
</span>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -18,7 +18,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info } from 'lucide-react'
import { Filter, ClipboardCheck, Zap, Info, Users, ListOrdered } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
@@ -43,6 +43,12 @@ const roundTypeIcons = {
LIVE_EVENT: Zap,
}
const roundTypeFeatures: Record<string, string[]> = {
FILTERING: ['AI screening', 'Auto-elimination', 'Batch processing'],
EVALUATION: ['Jury reviews', 'Criteria scoring', 'Voting window'],
LIVE_EVENT: ['Real-time voting', 'Audience votes', 'Presentations'],
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
@@ -67,13 +73,6 @@ export function RoundTypeSettings({
...(settings as Partial<LiveEventRoundSettings>),
})
const updateSetting = <T extends Record<string, unknown>>(
key: keyof T,
value: T[keyof T]
) => {
onSettingsChange({ ...settings, [key]: value })
}
return (
<Card>
<CardHeader>
@@ -86,30 +85,52 @@ export function RoundTypeSettings({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector */}
<div className="space-y-2">
{/* Round Type Selector - Visual Cards */}
<div className="space-y-3">
<Label>Round Type</Label>
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
return (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<TypeIcon className="h-4 w-4" />
{roundTypeLabels[type]}
<div className="grid gap-3 sm:grid-cols-3">
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
const isSelected = roundType === type
const features = roundTypeFeatures[type]
return (
<button
key={type}
type="button"
onClick={() => onRoundTypeChange(type)}
className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-muted hover:border-muted-foreground/30'
}`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{roundTypeDescriptions[roundType]}
</p>
)}
<div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
<TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
{roundTypeLabels[type]}
</p>
<p className="text-xs text-muted-foreground mt-1">
{roundTypeDescriptions[type]}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{features.map((f) => (
<span key={f} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{f}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
{/* Type-specific settings */}
@@ -440,6 +461,39 @@ function LiveEventSettings({
</p>
</div>
<div className="space-y-2">
<Label>Voting Mode</Label>
<Select
value={settings.votingMode}
onValueChange={(v) =>
onChange({ ...settings, votingMode: v as 'simple' | 'criteria' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4" />
Simple (1-10 score)
</div>
</SelectItem>
<SelectItem value="criteria">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
Criteria-Based (per-criterion scoring)
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{settings.votingMode === 'simple'
? 'Jurors give a single 1-10 score per project'
: 'Jurors score each criterion separately, weighted into a final score'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
@@ -456,6 +510,105 @@ function LiveEventSettings({
</div>
</div>
{/* Audience Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium flex items-center gap-2">
<Users className="h-4 w-4" />
Audience Voting
</h5>
<div className="space-y-2">
<Label>Audience Voting Mode</Label>
<Select
value={settings.audienceVotingMode}
onValueChange={(v) =>
onChange({
...settings,
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">Disabled</SelectItem>
<SelectItem value="per_project">Per Project (1-10 score)</SelectItem>
<SelectItem value="per_category">Per Category (vote best-in-category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How audience members can participate in voting
</p>
</div>
{settings.audienceVotingMode !== 'disabled' && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="flex items-center justify-between">
<div>
<Label>Require Identification</Label>
<p className="text-sm text-muted-foreground">
Audience must provide email or name to vote
</p>
</div>
<Switch
checked={settings.audienceRequireId}
onCheckedChange={(v) =>
onChange({ ...settings, audienceRequireId: v })
}
/>
</div>
{settings.audienceVotingMode === 'favorites' && (
<div className="space-y-2">
<Label htmlFor="maxFavorites">Max Favorites</Label>
<Input
id="maxFavorites"
type="number"
min="1"
max="20"
value={settings.audienceMaxFavorites}
onChange={(e) =>
onChange({
...settings,
audienceMaxFavorites: parseInt(e.target.value) || 3,
})
}
/>
<p className="text-xs text-muted-foreground">
Number of favorites each audience member can select
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="audienceDuration">
Audience Voting Duration (minutes)
</Label>
<Input
id="audienceDuration"
type="number"
min="1"
max="600"
value={settings.audienceVotingDuration || ''}
placeholder="Same as jury"
onChange={(e) => {
const val = parseInt(e.target.value)
onChange({
...settings,
audienceVotingDuration: isNaN(val) ? null : val,
})
}}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the same window as jury voting
</p>
</div>
</div>
)}
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
@@ -504,7 +657,7 @@ function LiveEventSettings({
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order can be configured in the Live Voting section once the round
Presentation order and criteria can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>