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:
203
src/components/admin/round-pipeline.tsx
Normal file
203
src/components/admin/round-pipeline.tsx
Normal 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('_', ' ')} · {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]} → {projectCounts[1] || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user