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

@@ -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>