Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements
Build and Push Docker Image / build (push) Successful in 10m53s
Details
Build and Push Docker Image / build (push) Successful in 10m53s
Details
- 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:
parent
b5d90d3c26
commit
2a5fa463b3
|
|
@ -1059,18 +1059,29 @@ model LiveVotingSession {
|
|||
votingEndsAt DateTime?
|
||||
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
|
||||
|
||||
// Criteria-based voting
|
||||
votingMode String @default("simple") // "simple" (1-10) | "criteria" (per-criterion scores)
|
||||
criteriaJson Json? @db.JsonB // Array of { id, label, description, scale, weight }
|
||||
|
||||
// Audience & presentation settings
|
||||
allowAudienceVotes Boolean @default(false)
|
||||
audienceVoteWeight Float @default(0) // 0.0 to 1.0
|
||||
tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote'
|
||||
presentationSettingsJson Json? @db.JsonB
|
||||
|
||||
// Audience voting configuration
|
||||
audienceVotingMode String @default("disabled") // "disabled" | "per_project" | "per_category" | "favorites"
|
||||
audienceMaxFavorites Int @default(3) // For "favorites" mode
|
||||
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
||||
audienceVotingDuration Int? // Minutes (null = same as jury)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
votes LiveVote[]
|
||||
audienceVoters AudienceVoter[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
|
@ -1079,19 +1090,46 @@ model LiveVote {
|
|||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
projectId String
|
||||
userId String
|
||||
score Int // 1-10
|
||||
userId String? // Nullable for audience voters without accounts
|
||||
score Int // 1-10 (or weighted score for criteria mode)
|
||||
isAudienceVote Boolean @default(false)
|
||||
votedAt DateTime @default(now())
|
||||
|
||||
// Criteria scores (used when votingMode="criteria")
|
||||
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
|
||||
|
||||
// Audience voter link
|
||||
audienceVoterId String?
|
||||
|
||||
// Relations
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
audienceVoter AudienceVoter? @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, projectId, userId])
|
||||
@@unique([sessionId, projectId, audienceVoterId])
|
||||
@@index([sessionId])
|
||||
@@index([projectId])
|
||||
@@index([userId])
|
||||
@@index([audienceVoterId])
|
||||
}
|
||||
|
||||
model AudienceVoter {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
token String @unique // Unique voting token (UUID)
|
||||
identifier String? // Optional: email, phone, or name
|
||||
identifierType String? // "email" | "phone" | "name" | "anonymous"
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
votes LiveVote[]
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
|
||||
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react'
|
||||
import {
|
||||
|
|
@ -202,17 +203,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
}, [evaluationForm, loadingForm, criteriaInitialized])
|
||||
|
||||
const onSubmit = async (data: UpdateRoundForm) => {
|
||||
const visibility = ROUND_FIELD_VISIBILITY[roundType]
|
||||
// Update round with type, settings, and notification
|
||||
await updateRound.mutateAsync({
|
||||
id: roundId,
|
||||
name: data.name,
|
||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
|
||||
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
||||
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
||||
roundType,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null,
|
||||
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
|
|
@ -301,7 +303,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
/>
|
||||
|
||||
{roundType !== 'FILTERING' && (
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiredReviews"
|
||||
|
|
@ -328,6 +330,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showAssignmentLimits && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -379,6 +382,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -24,6 +25,12 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -40,7 +47,10 @@ import {
|
|||
QrCode,
|
||||
Settings2,
|
||||
Scale,
|
||||
UserCheck,
|
||||
Trophy,
|
||||
BarChart3,
|
||||
ListOrdered,
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -59,8 +69,9 @@ import {
|
|||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
|
||||
import { useLiveVotingSSE, type VoteUpdate, type AudienceVoteUpdate } from '@/hooks/use-live-voting-sse'
|
||||
import { QRCodeDisplay } from '@/components/shared/qr-code-display'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -136,6 +147,8 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
const [votingDuration, setVotingDuration] = useState(30)
|
||||
const [liveVoteCount, setLiveVoteCount] = useState<number | null>(null)
|
||||
const [liveAvgScore, setLiveAvgScore] = useState<number | null>(null)
|
||||
const [liveAudienceVotes, setLiveAudienceVotes] = useState<number | null>(null)
|
||||
const [liveAudienceAvg, setLiveAudienceAvg] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
|
||||
|
|
@ -143,12 +156,23 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// Audience voter stats
|
||||
const { data: audienceStats } = trpc.liveVoting.getAudienceVoterStats.useQuery(
|
||||
{ sessionId: sessionData?.id || '' },
|
||||
{ enabled: !!sessionData?.id && !!sessionData?.allowAudienceVotes }
|
||||
)
|
||||
|
||||
// SSE for real-time vote updates
|
||||
const onVoteUpdate = useCallback((data: VoteUpdate) => {
|
||||
setLiveVoteCount(data.totalVotes)
|
||||
setLiveAvgScore(data.averageScore)
|
||||
}, [])
|
||||
|
||||
const onAudienceVote = useCallback((data: AudienceVoteUpdate) => {
|
||||
setLiveAudienceVotes(data.audienceVotes)
|
||||
setLiveAudienceAvg(data.audienceAverage)
|
||||
}, [])
|
||||
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
|
@ -156,6 +180,8 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
const onProjectChange = useCallback(() => {
|
||||
setLiveVoteCount(null)
|
||||
setLiveAvgScore(null)
|
||||
setLiveAudienceVotes(null)
|
||||
setLiveAudienceAvg(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
|
|
@ -163,6 +189,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
sessionData?.id || null,
|
||||
{
|
||||
onVoteUpdate,
|
||||
onAudienceVote,
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
}
|
||||
|
|
@ -218,6 +245,16 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
},
|
||||
})
|
||||
|
||||
const setVotingMode = trpc.liveVoting.setVotingMode.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Voting mode updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Presentation settings updated')
|
||||
|
|
@ -362,6 +399,15 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="session">
|
||||
<TabsList>
|
||||
<TabsTrigger value="session">Session</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="results">Results</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* SESSION TAB */}
|
||||
<TabsContent value="session">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main control panel */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
|
@ -506,8 +552,8 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Current Votes
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Live Stats
|
||||
{isConnected && (
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
|
|
@ -521,82 +567,192 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length
|
||||
: null
|
||||
)
|
||||
if (voteCount === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No votes yet
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total votes</span>
|
||||
<span className="text-muted-foreground">Jury Votes</span>
|
||||
<span className="font-medium">{voteCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Average score</span>
|
||||
<span className="text-muted-foreground">Jury Average</span>
|
||||
<span className="font-medium">
|
||||
{avgScore !== null ? avgScore.toFixed(1) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
{sessionData.allowAudienceVotes && (
|
||||
<>
|
||||
<div className="border-t pt-3" />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Audience Votes</span>
|
||||
<span className="font-medium">{liveAudienceVotes ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Audience Average</span>
|
||||
<span className="font-medium">
|
||||
{liveAudienceAvg !== null ? liveAudienceAvg.toFixed(1) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Registered Voters</span>
|
||||
<span className="font-medium">{audienceStats?.voterCount ?? sessionData.audienceVoterCount ?? 0}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Configuration */}
|
||||
{/* QR Codes & Links */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<QrCode className="h-5 w-5" />
|
||||
Voting Links
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Share these links with participants
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QRCodeDisplay
|
||||
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/jury/live/${sessionData.id}`}
|
||||
title="Jury Voting"
|
||||
size={160}
|
||||
/>
|
||||
{sessionData.allowAudienceVotes && (
|
||||
<QRCodeDisplay
|
||||
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/vote/${sessionData.id}`}
|
||||
title="Audience Voting"
|
||||
size={160}
|
||||
/>
|
||||
)}
|
||||
<QRCodeDisplay
|
||||
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/live-scores/${sessionData.id}`}
|
||||
title="Public Scoreboard"
|
||||
size={160}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pt-2 border-t">
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Jury Page
|
||||
</Link>
|
||||
</Button>
|
||||
{sessionData.allowAudienceVotes && (
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/vote/${sessionData.id}` as any} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Audience Page
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={`/live-scores/${sessionData.id}`} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Scoreboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* CONFIGURATION TAB */}
|
||||
<TabsContent value="config">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Voting Mode */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListOrdered className="h-5 w-5" />
|
||||
Voting Mode
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how jurors submit their scores
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode</Label>
|
||||
<Select
|
||||
value={sessionData.votingMode || 'simple'}
|
||||
onValueChange={(v) => {
|
||||
setVotingMode.mutate({
|
||||
sessionId: sessionData.id,
|
||||
votingMode: v as 'simple' | 'criteria',
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="simple">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
Simple (1-10)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="criteria">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
Criteria-Based
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{sessionData.votingMode === 'criteria' && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<p className="text-sm font-medium">Current Criteria</p>
|
||||
{(() => {
|
||||
const criteria = (sessionData.criteriaJson as LiveVotingCriterion[] | null) || []
|
||||
if (criteria.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No criteria configured. Import from an evaluation form or add manually.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between text-sm border rounded p-2">
|
||||
<div>
|
||||
<span className="font-medium">{c.label}</span>
|
||||
{c.description && (
|
||||
<span className="text-muted-foreground ml-2">({c.description})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
1-{c.scale}, {(c.weight * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Config */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
Session Config
|
||||
Session Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="audience-votes" className="text-sm">
|
||||
Audience Voting
|
||||
</Label>
|
||||
<Switch
|
||||
id="audience-votes"
|
||||
checked={!!sessionData.allowAudienceVotes}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
allowAudienceVotes: checked,
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sessionData.allowAudienceVotes && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Audience Weight</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={(sessionData.audienceVoteWeight || 0) * 100}
|
||||
onChange={(e) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
audienceVoteWeight: parseInt(e.target.value) / 100,
|
||||
})
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">
|
||||
{Math.round((sessionData.audienceVoteWeight || 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Tie-Breaker Method</Label>
|
||||
<Select
|
||||
|
|
@ -651,46 +807,339 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* QR Codes & Links */}
|
||||
<Card>
|
||||
{/* Audience Voting Config */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<QrCode className="h-5 w-5" />
|
||||
Voting Links
|
||||
<Users className="h-5 w-5" />
|
||||
Audience Voting
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Share these links with participants
|
||||
Configure how the audience participates in voting
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QRCodeDisplay
|
||||
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/jury/live/${sessionData.id}`}
|
||||
title="Jury Voting"
|
||||
size={160}
|
||||
/>
|
||||
<QRCodeDisplay
|
||||
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/live-scores/${sessionData.id}`}
|
||||
title="Public Scoreboard"
|
||||
size={160}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pt-2 border-t">
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Jury Page
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={`/live-scores/${sessionData.id}`} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Scoreboard
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="audience-votes">Enable Audience Voting</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow non-authenticated audience members to vote
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="audience-votes"
|
||||
checked={!!sessionData.allowAudienceVotes}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
allowAudienceVotes: checked,
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sessionData.allowAudienceVotes && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 border-t pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Audience Voting Mode</Label>
|
||||
<Select
|
||||
value={sessionData.audienceVotingMode || 'per_project'}
|
||||
onValueChange={(v) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="per_project">Per Project (1-10)</SelectItem>
|
||||
<SelectItem value="per_category">Per Category</SelectItem>
|
||||
<SelectItem value="favorites">Favorites</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Audience Weight</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={(sessionData.audienceVoteWeight || 0) * 100}
|
||||
onChange={(e) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
audienceVoteWeight: parseInt(e.target.value) / 100,
|
||||
})
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">
|
||||
{Math.round((sessionData.audienceVoteWeight || 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Require Identification</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Audience must provide email/name
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!sessionData.audienceRequireId}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
audienceRequireId: checked,
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sessionData.audienceVotingMode === 'favorites' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Max Favorites</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={sessionData.audienceMaxFavorites || 3}
|
||||
onChange={(e) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
audienceMaxFavorites: parseInt(e.target.value) || 3,
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* RESULTS TAB */}
|
||||
<TabsContent value="results">
|
||||
<ResultsPanel sessionId={sessionData.id} isCompleted={isCompleted} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultsPanel({ sessionId, isCompleted }: { sessionId: string; isCompleted: boolean }) {
|
||||
const [juryWeight, setJuryWeight] = useState(80)
|
||||
const [audienceWeight, setAudienceWeight] = useState(20)
|
||||
|
||||
const { data: results, isLoading } = trpc.liveVoting.getResults.useQuery(
|
||||
{
|
||||
sessionId,
|
||||
juryWeight: juryWeight / 100,
|
||||
audienceWeight: audienceWeight / 100,
|
||||
},
|
||||
{ enabled: !!sessionId }
|
||||
)
|
||||
|
||||
const handleWeightChange = (jury: number) => {
|
||||
setJuryWeight(jury)
|
||||
setAudienceWeight(100 - jury)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!results || results.results.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No results yet. Start voting to see results.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Weight sliders */}
|
||||
{results.weights.audience > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5" />
|
||||
Score Weighting
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Adjust the relative weight of jury and audience scores
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium w-24">Jury</span>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={juryWeight}
|
||||
onChange={(e) => handleWeightChange(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">{juryWeight}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium w-24">Audience</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={audienceWeight}
|
||||
onChange={(e) => handleWeightChange(100 - parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">{audienceWeight}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="hidden sm:grid grid-cols-[40px_1fr_80px_80px_80px_80px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase">
|
||||
<div>#</div>
|
||||
<div>Project</div>
|
||||
<div className="text-right">Jury</div>
|
||||
<div className="text-right">Audience</div>
|
||||
<div className="text-right">Votes</div>
|
||||
<div className="text-right">Total</div>
|
||||
</div>
|
||||
|
||||
{results.results.map((result, index) => (
|
||||
<div
|
||||
key={result.project?.id}
|
||||
className={`rounded-lg border p-3 ${
|
||||
index === 0 ? 'border-yellow-500 bg-yellow-500/5' : ''
|
||||
} ${
|
||||
index === 1 ? 'border-gray-400 bg-gray-400/5' : ''
|
||||
} ${
|
||||
index === 2 ? 'border-orange-600 bg-orange-600/5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<div className="hidden sm:grid grid-cols-[40px_1fr_80px_80px_80px_80px] gap-3 items-center">
|
||||
<div className="text-lg font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{result.project?.title}</p>
|
||||
{result.project?.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{result.project.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right font-medium">
|
||||
{result.juryAverage.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{result.audienceVoteCount > 0 ? result.audienceAverage.toFixed(1) : '--'}
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{result.juryVoteCount + result.audienceVoteCount}
|
||||
</div>
|
||||
<div className="text-right text-lg font-bold text-primary">
|
||||
{result.weightedTotal.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
|
||||
<div>
|
||||
<p className="font-medium">{result.project?.title}</p>
|
||||
{result.project?.teamName && (
|
||||
<p className="text-xs text-muted-foreground">{result.project.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{result.weightedTotal.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span>Jury: {result.juryAverage.toFixed(1)}</span>
|
||||
{result.audienceVoteCount > 0 && (
|
||||
<span>Audience: {result.audienceAverage.toFixed(1)}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{result.juryVoteCount + result.audienceVoteCount} votes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria breakdown */}
|
||||
{result.criteriaAverages && results.criteria && (
|
||||
<div className="mt-3 pt-3 border-t grid gap-2 sm:grid-cols-2">
|
||||
{results.criteria.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{c.label}</span>
|
||||
<span className="font-medium">
|
||||
{result.criteriaAverages?.[c.id]?.toFixed(1) || '--'}/{c.scale}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ties */}
|
||||
{results.ties.length > 0 && (
|
||||
<Alert className="mt-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Ties Detected</AlertTitle>
|
||||
<AlertDescription>
|
||||
{results.ties.length} tie(s) detected. Tie-breaker method: {results.tieBreakerMethod?.replace('_', ' ')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ROUND_FIELD_VISIBILITY, roundTypeLabels } from '@/types/round-settings'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
|
|
@ -148,8 +149,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
// 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
|
||||
// Check round type
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
const isLiveEventRound = round?.roundType === 'LIVE_EVENT'
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
||||
|
|
@ -165,6 +167,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
|
||||
// Live voting session (only fetch for LIVE_EVENT rounds)
|
||||
const { data: liveSession } = trpc.liveVoting.getSession.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isLiveEventRound, staleTime: 30_000 }
|
||||
)
|
||||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
|
|
@ -398,6 +406,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
const visibility = ROUND_FIELD_VISIBILITY[round.roundType] || ROUND_FIELD_VISIBILITY.EVALUATION
|
||||
const isLiveEvent = isLiveEventRound
|
||||
|
||||
const now = new Date()
|
||||
const isVotingOpen =
|
||||
round.status === 'ACTIVE' &&
|
||||
|
|
@ -462,6 +473,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
|
||||
{getStatusBadge()}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roundTypeLabels[round.roundType] || round.roundType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -577,6 +591,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{visibility.showAssignmentLimits && (
|
||||
<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>
|
||||
|
|
@ -593,7 +608,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{visibility.showRequiredReviews && (
|
||||
<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>
|
||||
|
|
@ -606,28 +623,48 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
<p className="text-xs text-muted-foreground">per project</p>
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{isLiveEvent ? 'Session' : 'Completion'}
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
{isLiveEvent ? (
|
||||
<Zap className="h-4 w-4 text-brand-teal" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLiveEvent && liveSession ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold capitalize">
|
||||
{liveSession.status === 'IN_PROGRESS' ? 'Live' : liveSession.status.toLowerCase().replace('_', ' ')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{liveSession.audienceVoterCount} audience voters
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{progress?.completionPercentage || 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && progress.totalAssignments > 0 && (
|
||||
{/* Progress - only for evaluation rounds */}
|
||||
{visibility.showRequiredReviews && progress && progress.totalAssignments > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -662,7 +699,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Voting Window */}
|
||||
{/* Voting Window - only for evaluation rounds */}
|
||||
{visibility.showVotingWindow && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -759,6 +797,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Filtering Section (for FILTERING rounds) */}
|
||||
{isFilteringRound && (
|
||||
|
|
@ -1268,8 +1307,133 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Live Event Section (for LIVE_EVENT rounds) */}
|
||||
{isLiveEventRound && liveSession && (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Zap className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Live Voting Session
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time voting during project presentations
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Session Status */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
{liveSession.status === 'IN_PROGRESS' ? (
|
||||
<Play className="h-5 w-5 text-green-600" />
|
||||
) : liveSession.status === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{liveSession.status === 'IN_PROGRESS' ? 'Live Now' : liveSession.status.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<FileText className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.round.projects.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<BarChart3 className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.currentVotes.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Votes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.audienceVoterCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Audience</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Indicator */}
|
||||
{liveSession.status === 'IN_PROGRESS' && liveSession.currentProjectId && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">
|
||||
Voting in progress
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Project {(liveSession.currentProjectIndex ?? 0) + 1} of {liveSession.round.projects.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveSession.status === 'COMPLETED' && (
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
Voting session completed - view results in the dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Session Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
{liveSession.allowAudienceVotes && (
|
||||
<Button variant="outline" asChild>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/vote/${liveSession.id}` as any}>
|
||||
<QrCode className="mr-2 h-4 w-4" />
|
||||
Audience Voting Page
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<AnimatedCard index={4}>
|
||||
<AnimatedCard index={isFilteringRound || isLiveEventRound ? 5 : 4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
|
|
@ -1311,22 +1475,46 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round Management */}
|
||||
{/* Type-Specific Management */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{isFilteringRound ? 'Filtering' : isLiveEvent ? 'Live Event' : 'Evaluation'} Management
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Filtering-specific actions */}
|
||||
{isFilteringRound && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
|
||||
<ListChecks className="mr-2 h-4 w-4" />
|
||||
View Rules
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Evaluation-specific actions */}
|
||||
{visibility.showAssignmentLimits && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Live Event-specific actions */}
|
||||
{isLiveEvent && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Live Voting
|
||||
Open Live Session
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Evaluation-round-only: AI Summaries */}
|
||||
{!isFilteringRound && !isLiveEvent && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1349,6 +1537,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
|
@ -124,14 +125,15 @@ function CreateRoundContent() {
|
|||
})
|
||||
|
||||
const onSubmit = async (data: CreateRoundForm) => {
|
||||
const visibility = ROUND_FIELD_VISIBILITY[roundType]
|
||||
await createRound.mutateAsync({
|
||||
programId: data.programId,
|
||||
name: data.name,
|
||||
roundType,
|
||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? undefined) : undefined,
|
||||
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? undefined) : undefined,
|
||||
entryNotificationType: entryNotificationType || undefined,
|
||||
})
|
||||
}
|
||||
|
|
@ -291,7 +293,7 @@ function CreateRoundContent() {
|
|||
)}
|
||||
/>
|
||||
|
||||
{roundType !== 'FILTERING' && (
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiredReviews"
|
||||
|
|
@ -326,6 +328,7 @@ function CreateRoundContent() {
|
|||
onSettingsChange={setRoundSettings}
|
||||
/>
|
||||
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showVotingWindow && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
|
|
@ -377,6 +380,7 @@ function CreateRoundContent() {
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Notification */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -63,10 +63,13 @@ import {
|
|||
Loader2,
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
List,
|
||||
GitBranchPlus,
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundPipeline } from '@/components/admin/round-pipeline'
|
||||
|
||||
type RoundData = {
|
||||
id: string
|
||||
|
|
@ -81,7 +84,7 @@ type RoundData = {
|
|||
}
|
||||
}
|
||||
|
||||
function RoundsContent() {
|
||||
function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
||||
includeRounds: true,
|
||||
})
|
||||
|
|
@ -107,6 +110,45 @@ function RoundsContent() {
|
|||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'pipeline') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
|
||||
<CardDescription>
|
||||
{program.name} - {program.status}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/new?program=${program.id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(program.rounds && program.rounds.length > 0) ? (
|
||||
<RoundPipeline rounds={program.rounds} programName={program.name} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No rounds created yet</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
|
|
@ -669,6 +711,8 @@ function RoundsListSkeleton() {
|
|||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -679,11 +723,31 @@ export default function RoundsPage() {
|
|||
Manage selection rounds and voting periods
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'pipeline' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('pipeline')}
|
||||
>
|
||||
<GitBranchPlus className="mr-1.5 h-4 w-4" />
|
||||
Pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<RoundsListSkeleton />}>
|
||||
<RoundsContent />
|
||||
<RoundsContent viewMode={viewMode} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { toast } from 'sonner'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
|
|
@ -26,6 +28,7 @@ const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|||
|
||||
function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
|
|
@ -34,6 +37,9 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
const votingMode = data?.session.votingMode || 'simple'
|
||||
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
|
|
@ -41,6 +47,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
|
@ -88,12 +95,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
useEffect(() => {
|
||||
if (data?.userVote) {
|
||||
setSelectedScore(data.userVote.score)
|
||||
// Restore criterion scores if available
|
||||
if (data.userVote.criterionScoresJson) {
|
||||
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
|
||||
}
|
||||
} else {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
}
|
||||
}, [data?.userVote, data?.currentProject?.id])
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
// Initialize criterion scores with mid-values when criteria change
|
||||
useEffect(() => {
|
||||
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
|
||||
const initial: Record<string, number> = {}
|
||||
for (const c of criteria) {
|
||||
initial[c.id] = Math.ceil(c.scale / 2)
|
||||
}
|
||||
setCriterionScores(initial)
|
||||
}
|
||||
}, [votingMode, criteria, criterionScores])
|
||||
|
||||
const handleSimpleVote = (score: number) => {
|
||||
if (!data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
vote.mutate({
|
||||
|
|
@ -103,6 +126,37 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
})
|
||||
}
|
||||
|
||||
const handleCriteriaVote = () => {
|
||||
if (!data?.currentProject) return
|
||||
|
||||
// Compute a rough overall score for the `score` field
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
|
||||
vote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score: computedScore,
|
||||
criterionScores,
|
||||
})
|
||||
}
|
||||
|
||||
const computeWeightedScore = (): number => {
|
||||
if (criteria.length === 0) return 0
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <JuryVotingSkeleton />
|
||||
}
|
||||
|
|
@ -169,7 +223,8 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
{/* Voting UI - Simple mode */}
|
||||
{votingMode === 'simple' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
|
|
@ -179,7 +234,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
onClick={() => handleSimpleVote(score)}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
|
|
@ -190,6 +245,61 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
|||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voting UI - Criteria mode */}
|
||||
{votingMode === 'criteria' && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-center">Score Each Criterion</p>
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.label}</p>
|
||||
{c.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{c.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
|
||||
{criterionScores[c.id] || 1}/{c.scale}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={c.scale}
|
||||
step={1}
|
||||
value={[criterionScores[c.id] || 1]}
|
||||
onValueChange={([val]) => {
|
||||
setCriterionScores((prev) => ({
|
||||
...prev,
|
||||
[c.id]: val,
|
||||
}))
|
||||
}}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Computed weighted score */}
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<p className="text-sm font-medium">Weighted Score</p>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{computeWeightedScore().toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCriteriaVote}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVoted && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Vote,
|
||||
} from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
const TOKEN_KEY = 'mopc_audience_token_'
|
||||
|
||||
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [hasVotedForProject, setHasVotedForProject] = useState(false)
|
||||
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
|
||||
if (saved) {
|
||||
setToken(saved)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Fetch session data
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
// Register mutation
|
||||
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setToken(result.token)
|
||||
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
|
||||
toast.success('Registered! You can now vote.')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Vote mutation
|
||||
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote recorded!')
|
||||
setHasVotedForProject(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Update countdown
|
||||
useEffect(() => {
|
||||
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
|
||||
setCountdown(data.timeRemaining)
|
||||
} else {
|
||||
setCountdown(null)
|
||||
}
|
||||
}, [data?.timeRemaining])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev === null || prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
// Reset vote state when project changes
|
||||
useEffect(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
}, [data?.currentProject?.id])
|
||||
|
||||
const handleRegister = () => {
|
||||
register.mutate({
|
||||
sessionId,
|
||||
identifier: identifier.trim() || undefined,
|
||||
identifierType: identifier.includes('@')
|
||||
? 'email'
|
||||
: identifier.trim()
|
||||
? 'name'
|
||||
: 'anonymous',
|
||||
})
|
||||
}
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
if (!token || !data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
castVote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AudienceVotingSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist or has ended.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.session.allowAudienceVotes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Audience voting is not enabled for this session.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Registration step
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Register to participate in audience voting
|
||||
</p>
|
||||
|
||||
{data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Email or Name</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="email@example.com or your name"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for audience voting verification
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Name (optional)</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="Enter your name (optional)"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={
|
||||
register.isPending ||
|
||||
(data.session.audienceRequireId && !identifier.trim())
|
||||
}
|
||||
>
|
||||
{register.isPending ? 'Registering...' : 'Join Voting'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Voting UI
|
||||
const isVoting = data.session.status === 'IN_PROGRESS'
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isVoting && data.currentProject ? (
|
||||
<>
|
||||
{/* Current project */}
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="default" className="mb-2">
|
||||
Now Presenting
|
||||
</Badge>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{data.currentProject.title}
|
||||
</h2>
|
||||
{data.currentProject.teamName && (
|
||||
<p className="text-muted-foreground">
|
||||
{data.currentProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-primary mb-2">
|
||||
{countdown !== null ? `${countdown}s` : '--'}
|
||||
</div>
|
||||
<Progress
|
||||
value={countdown !== null ? (countdown / 30) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Time remaining to vote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
disabled={castVote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVotedForProject && (
|
||||
<Alert className="bg-green-500/10 border-green-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Your vote has been recorded! You can change it before time runs out.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Waiting state */
|
||||
<div className="text-center py-12">
|
||||
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Waiting for Next Project
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{data.session.status === 'COMPLETED'
|
||||
? 'The voting session has ended. Thank you for participating!'
|
||||
: 'Voting will begin when the next project is presented.'}
|
||||
</p>
|
||||
{data.session.status !== 'COMPLETED' && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
This page will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isConnected ? 'Connected' : 'Reconnecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceVotingSkeleton() {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<Skeleton className="h-6 w-40 mx-auto" />
|
||||
<Skeleton className="h-4 w-56 mx-auto mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AudienceVotingPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <AudienceVotingContent sessionId={sessionId} />
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -86,19 +88,21 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
const voteCount = await prisma.liveVote.count({
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && voteCount !== lastVoteCount) {
|
||||
// Get the latest vote info
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
|
|
@ -113,6 +117,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
|
|
@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise<Response> {
|
|||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: voteCount,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = voteCount
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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 (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-4 w-4" />
|
||||
{roundTypeLabels[type]}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{roundTypeDescriptions[roundType]}
|
||||
</p>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ export interface VoteUpdate {
|
|||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AudienceVoteUpdate {
|
||||
projectId: string
|
||||
audienceVotes: number
|
||||
audienceAverage: number | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SessionStatusUpdate {
|
||||
status: string
|
||||
timestamp: string
|
||||
|
|
@ -23,6 +30,7 @@ export interface ProjectChangeUpdate {
|
|||
|
||||
interface SSECallbacks {
|
||||
onVoteUpdate?: (data: VoteUpdate) => void
|
||||
onAudienceVote?: (data: AudienceVoteUpdate) => void
|
||||
onSessionStatus?: (data: SessionStatusUpdate) => void
|
||||
onProjectChange?: (data: ProjectChangeUpdate) => void
|
||||
onConnected?: () => void
|
||||
|
|
@ -65,6 +73,15 @@ export function useLiveVotingSSE(
|
|||
}
|
||||
})
|
||||
|
||||
es.addEventListener('audience_vote', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as AudienceVoteUpdate
|
||||
callbacksRef.current.onAudienceVote?.(data)
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('session_status', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SessionStatusUpdate
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
export const liveVotingRouter = router({
|
||||
/**
|
||||
|
|
@ -46,7 +48,7 @@ export const liveVotingRouter = router({
|
|||
}
|
||||
|
||||
// Get current votes if voting is in progress
|
||||
let currentVotes: { userId: string; score: number }[] = []
|
||||
let currentVotes: { userId: string | null; score: number }[] = []
|
||||
if (session.currentProjectId) {
|
||||
const votes = await ctx.prisma.liveVote.findMany({
|
||||
where: {
|
||||
|
|
@ -58,9 +60,15 @@ export const liveVotingRouter = router({
|
|||
currentVotes = votes
|
||||
}
|
||||
|
||||
// Get audience voter count
|
||||
const audienceVoterCount = await ctx.prisma.audienceVoter.count({
|
||||
where: { sessionId: session.id },
|
||||
})
|
||||
|
||||
return {
|
||||
...session,
|
||||
currentVotes,
|
||||
audienceVoterCount,
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -115,6 +123,8 @@ export const liveVotingRouter = router({
|
|||
status: session.status,
|
||||
votingStartedAt: session.votingStartedAt,
|
||||
votingEndsAt: session.votingEndsAt,
|
||||
votingMode: session.votingMode,
|
||||
criteriaJson: session.criteriaJson,
|
||||
},
|
||||
round: session.round,
|
||||
currentProject,
|
||||
|
|
@ -202,6 +212,132 @@ export const liveVotingRouter = router({
|
|||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set voting mode (simple vs criteria)
|
||||
*/
|
||||
setVotingMode: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
votingMode: z.enum(['simple', 'criteria']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: { votingMode: input.votingMode },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'SET_VOTING_MODE',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: { votingMode: input.votingMode },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set criteria for criteria-based voting
|
||||
*/
|
||||
setCriteria: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
criteria: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
scale: z.number().int().min(1).max(100),
|
||||
weight: z.number().min(0).max(1),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate weights sum approximately to 1
|
||||
const weightSum = input.criteria.reduce((sum, c) => sum + c.weight, 0)
|
||||
if (Math.abs(weightSum - 1) > 0.01) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Criteria weights must sum to 1.0 (currently ${weightSum.toFixed(2)})`,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
criteriaJson: input.criteria,
|
||||
votingMode: 'criteria',
|
||||
},
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import criteria from an existing evaluation form
|
||||
*/
|
||||
importCriteriaFromForm: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
formId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const form = await ctx.prisma.evaluationForm.findUniqueOrThrow({
|
||||
where: { id: input.formId },
|
||||
})
|
||||
|
||||
const formCriteria = form.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
weight: number
|
||||
type?: string
|
||||
}>
|
||||
|
||||
// Filter out section headers and convert
|
||||
const scoringCriteria = formCriteria.filter(
|
||||
(c) => !c.type || c.type === 'numeric'
|
||||
)
|
||||
|
||||
if (scoringCriteria.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No numeric criteria found in this evaluation form',
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize weights to sum to 1
|
||||
const totalWeight = scoringCriteria.reduce((sum, c) => sum + (c.weight || 1), 0)
|
||||
const criteria: LiveVotingCriterion[] = scoringCriteria.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
scale: c.scale || 10,
|
||||
weight: (c.weight || 1) / totalWeight,
|
||||
}))
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
criteriaJson: criteria as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||
votingMode: 'criteria',
|
||||
},
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Start voting for a project
|
||||
*/
|
||||
|
|
@ -288,7 +424,7 @@ export const liveVotingRouter = router({
|
|||
}),
|
||||
|
||||
/**
|
||||
* Submit a vote
|
||||
* Submit a vote (supports both simple and criteria modes)
|
||||
*/
|
||||
vote: protectedProcedure
|
||||
.input(
|
||||
|
|
@ -296,6 +432,9 @@ export const liveVotingRouter = router({
|
|||
sessionId: z.string(),
|
||||
projectId: z.string(),
|
||||
score: z.number().int().min(1).max(10),
|
||||
criterionScores: z
|
||||
.record(z.string(), z.number())
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
@ -326,6 +465,46 @@ export const liveVotingRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// For criteria mode, validate and compute weighted score
|
||||
let finalScore = input.score
|
||||
let criterionScoresJson = null
|
||||
|
||||
if (session.votingMode === 'criteria' && input.criterionScores) {
|
||||
const criteria = session.criteriaJson as LiveVotingCriterion[] | null
|
||||
if (!criteria || criteria.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No criteria configured for this session',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate all required criteria have scores
|
||||
for (const c of criteria) {
|
||||
if (input.criterionScores[c.id] === undefined) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Missing score for criterion: ${c.label}`,
|
||||
})
|
||||
}
|
||||
const cScore = input.criterionScores[c.id]
|
||||
if (cScore < 1 || cScore > c.scale) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Score for ${c.label} must be between 1 and ${c.scale}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute weighted score normalized to 1-10
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const normalizedScore = (input.criterionScores[c.id] / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
finalScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
criterionScoresJson = input.criterionScores
|
||||
}
|
||||
|
||||
// Upsert vote (allow vote change during window)
|
||||
const vote = await ctx.prisma.liveVote.upsert({
|
||||
where: {
|
||||
|
|
@ -339,10 +518,12 @@ export const liveVotingRouter = router({
|
|||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
score: input.score,
|
||||
score: finalScore,
|
||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||
},
|
||||
update: {
|
||||
score: input.score,
|
||||
score: finalScore,
|
||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||
votedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
|
@ -354,7 +535,13 @@ export const liveVotingRouter = router({
|
|||
* Get results for a session (with weighted jury + audience scoring)
|
||||
*/
|
||||
getResults: protectedProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
juryWeight: z.number().min(0).max(1).optional(),
|
||||
audienceWeight: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
|
|
@ -367,8 +554,9 @@ export const liveVotingRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
const audienceWeight = session.audienceVoteWeight || 0
|
||||
const juryWeight = 1 - audienceWeight
|
||||
// Use custom weights if provided, else session defaults
|
||||
const audienceWeightVal = input.audienceWeight ?? session.audienceVoteWeight ?? 0
|
||||
const juryWeightVal = input.juryWeight ?? (1 - audienceWeightVal)
|
||||
|
||||
// Get jury votes grouped by project
|
||||
const juryScores = await ctx.prisma.liveVote.groupBy({
|
||||
|
|
@ -400,6 +588,39 @@ export const liveVotingRouter = router({
|
|||
|
||||
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
|
||||
|
||||
// For criteria mode, get per-criterion breakdowns
|
||||
let criteriaBreakdown: Record<string, Record<string, number>> | null = null
|
||||
if (session.votingMode === 'criteria') {
|
||||
const allJuryVotes = await ctx.prisma.liveVote.findMany({
|
||||
where: { sessionId: input.sessionId, isAudienceVote: false },
|
||||
select: { projectId: true, criterionScoresJson: true },
|
||||
})
|
||||
|
||||
criteriaBreakdown = {}
|
||||
for (const vote of allJuryVotes) {
|
||||
if (!vote.criterionScoresJson) continue
|
||||
const scores = vote.criterionScoresJson as Record<string, number>
|
||||
if (!criteriaBreakdown[vote.projectId]) {
|
||||
criteriaBreakdown[vote.projectId] = {}
|
||||
}
|
||||
for (const [criterionId, score] of Object.entries(scores)) {
|
||||
if (!criteriaBreakdown[vote.projectId][criterionId]) {
|
||||
criteriaBreakdown[vote.projectId][criterionId] = 0
|
||||
}
|
||||
criteriaBreakdown[vote.projectId][criterionId] += score
|
||||
}
|
||||
}
|
||||
// Average the scores
|
||||
for (const projectId of Object.keys(criteriaBreakdown)) {
|
||||
const projectVoteCount = allJuryVotes.filter((v) => v.projectId === projectId).length
|
||||
if (projectVoteCount > 0) {
|
||||
for (const criterionId of Object.keys(criteriaBreakdown[projectId])) {
|
||||
criteriaBreakdown[projectId][criterionId] /= projectVoteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and calculate weighted scores
|
||||
const results = juryScores
|
||||
.map((jurySc) => {
|
||||
|
|
@ -407,8 +628,8 @@ export const liveVotingRouter = router({
|
|||
const audienceSc = audienceMap.get(jurySc.projectId)
|
||||
const juryAvg = jurySc._avg?.score || 0
|
||||
const audienceAvg = audienceSc?._avg?.score || 0
|
||||
const weightedTotal = audienceWeight > 0 && audienceSc
|
||||
? juryAvg * juryWeight + audienceAvg * audienceWeight
|
||||
const weightedTotal = audienceWeightVal > 0 && audienceSc
|
||||
? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal
|
||||
: juryAvg
|
||||
|
||||
return {
|
||||
|
|
@ -418,6 +639,7 @@ export const liveVotingRouter = router({
|
|||
audienceAverage: audienceAvg,
|
||||
audienceVoteCount: audienceSc?._count || 0,
|
||||
weightedTotal,
|
||||
criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.weightedTotal - a.weightedTotal)
|
||||
|
|
@ -436,6 +658,9 @@ export const liveVotingRouter = router({
|
|||
results,
|
||||
ties,
|
||||
tieBreakerMethod: session.tieBreakerMethod,
|
||||
votingMode: session.votingMode,
|
||||
criteria: session.criteriaJson as LiveVotingCriterion[] | null,
|
||||
weights: { jury: juryWeightVal, audience: audienceWeightVal },
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -477,6 +702,10 @@ export const liveVotingRouter = router({
|
|||
allowAudienceVotes: z.boolean().optional(),
|
||||
audienceVoteWeight: z.number().min(0).max(1).optional(),
|
||||
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
|
||||
audienceVotingMode: z.enum(['disabled', 'per_project', 'per_category', 'favorites']).optional(),
|
||||
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
|
||||
audienceRequireId: z.boolean().optional(),
|
||||
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
@ -507,17 +736,76 @@ export const liveVotingRouter = router({
|
|||
}),
|
||||
|
||||
/**
|
||||
* Cast an audience vote
|
||||
* Register an audience voter (public, no auth required)
|
||||
*/
|
||||
castAudienceVote: protectedProcedure
|
||||
registerAudienceVoter: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
identifierType: z.enum(['email', 'phone', 'name', 'anonymous']).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
})
|
||||
|
||||
if (!session.allowAudienceVotes) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Audience voting is not enabled for this session',
|
||||
})
|
||||
}
|
||||
|
||||
if (session.audienceRequireId && !input.identifier) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Identification is required for audience voting',
|
||||
})
|
||||
}
|
||||
|
||||
const token = randomUUID()
|
||||
|
||||
const voter = await ctx.prisma.audienceVoter.create({
|
||||
data: {
|
||||
sessionId: input.sessionId,
|
||||
token,
|
||||
identifier: input.identifier || null,
|
||||
identifierType: input.identifierType || 'anonymous',
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { token: voter.token, voterId: voter.id }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cast an audience vote (token-based, no auth required)
|
||||
*/
|
||||
castAudienceVote: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
projectId: z.string(),
|
||||
score: z.number().int().min(1).max(10),
|
||||
token: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify voter token
|
||||
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||
where: { token: input.token },
|
||||
})
|
||||
|
||||
if (!voter || voter.sessionId !== input.sessionId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid voting token',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify session is in progress and allows audience votes
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
|
|
@ -551,19 +839,19 @@ export const liveVotingRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Upsert audience vote
|
||||
// Upsert audience vote (dedup by audienceVoterId)
|
||||
const vote = await ctx.prisma.liveVote.upsert({
|
||||
where: {
|
||||
sessionId_projectId_userId: {
|
||||
sessionId_projectId_audienceVoterId: {
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
audienceVoterId: voter.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
audienceVoterId: voter.id,
|
||||
score: input.score,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
|
|
@ -576,6 +864,70 @@ export const liveVotingRouter = router({
|
|||
return vote
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audience voter stats (admin)
|
||||
*/
|
||||
getAudienceVoterStats: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const voterCount = await ctx.prisma.audienceVoter.count({
|
||||
where: { sessionId: input.sessionId },
|
||||
})
|
||||
|
||||
const voteCount = await ctx.prisma.liveVote.count({
|
||||
where: { sessionId: input.sessionId, isAudienceVote: true },
|
||||
})
|
||||
|
||||
return { voterCount, voteCount }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public session info for audience voting page
|
||||
*/
|
||||
getAudienceSession: publicProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
audienceVotingMode: true,
|
||||
audienceRequireId: true,
|
||||
audienceMaxFavorites: true,
|
||||
round: {
|
||||
select: {
|
||||
name: true,
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let currentProject = null
|
||||
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
|
||||
currentProject = await ctx.prisma.project.findUnique({
|
||||
where: { id: session.currentProjectId },
|
||||
select: { id: true, title: true, teamName: true },
|
||||
})
|
||||
}
|
||||
|
||||
let timeRemaining = null
|
||||
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
|
||||
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
|
||||
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
currentProject,
|
||||
timeRemaining,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public results for a live voting session (no auth required)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ export interface LiveEventRoundSettings {
|
|||
votingWindowSeconds: number
|
||||
showLiveScores: boolean
|
||||
allowVoteChange: boolean
|
||||
votingMode: 'simple' | 'criteria'
|
||||
|
||||
// Audience voting
|
||||
audienceVotingMode: 'disabled' | 'per_project' | 'per_category' | 'favorites'
|
||||
audienceMaxFavorites: number
|
||||
audienceRequireId: boolean
|
||||
audienceVotingDuration: number | null
|
||||
|
||||
// Display
|
||||
displayMode: 'SCORES' | 'RANKING' | 'NONE'
|
||||
|
|
@ -74,6 +81,11 @@ export const defaultLiveEventSettings: LiveEventRoundSettings = {
|
|||
votingWindowSeconds: 30,
|
||||
showLiveScores: true,
|
||||
allowVoteChange: false,
|
||||
votingMode: 'simple',
|
||||
audienceVotingMode: 'disabled',
|
||||
audienceMaxFavorites: 3,
|
||||
audienceRequireId: false,
|
||||
audienceVotingDuration: null,
|
||||
displayMode: 'RANKING',
|
||||
}
|
||||
|
||||
|
|
@ -90,3 +102,43 @@ export const roundTypeDescriptions: Record<string, string> = {
|
|||
EVALUATION: 'In-depth evaluation with detailed criteria and feedback',
|
||||
LIVE_EVENT: 'Real-time voting during presentations',
|
||||
}
|
||||
|
||||
// Field visibility per round type
|
||||
export const ROUND_FIELD_VISIBILITY: Record<string, {
|
||||
showRequiredReviews: boolean
|
||||
showAssignmentLimits: boolean
|
||||
showVotingWindow: boolean
|
||||
showSubmissionDates: boolean
|
||||
showEvaluationForm: boolean
|
||||
}> = {
|
||||
FILTERING: {
|
||||
showRequiredReviews: false,
|
||||
showAssignmentLimits: false,
|
||||
showVotingWindow: false,
|
||||
showSubmissionDates: true,
|
||||
showEvaluationForm: false,
|
||||
},
|
||||
EVALUATION: {
|
||||
showRequiredReviews: true,
|
||||
showAssignmentLimits: true,
|
||||
showVotingWindow: true,
|
||||
showSubmissionDates: true,
|
||||
showEvaluationForm: true,
|
||||
},
|
||||
LIVE_EVENT: {
|
||||
showRequiredReviews: false,
|
||||
showAssignmentLimits: false,
|
||||
showVotingWindow: false,
|
||||
showSubmissionDates: false,
|
||||
showEvaluationForm: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Live voting criterion type
|
||||
export interface LiveVotingCriterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // max score (e.g. 10)
|
||||
weight: number // 0-1, weights must sum to 1
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue