diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cdfedce..237968b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1059,39 +1059,77 @@ 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])
}
model LiveVote {
- id String @id @default(cuid())
- sessionId String
- projectId String
- userId String
- score Int // 1-10
- isAudienceVote Boolean @default(false)
- votedAt DateTime @default(now())
+ id String @id @default(cuid())
+ sessionId String
+ projectId String
+ 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)
+ session LiveVotingSession @relation(fields: [sessionId], 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])
}
// =============================================================================
diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
index 9b2cd0b..9c7169b 100644
--- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
@@ -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 && (
- Currently Voting -
-- {projects.find((p) => p.id === sessionData.currentProjectId)?.title} -
-+ Currently Voting +
++ {projects.find((p) => p.id === sessionData.currentProjectId)?.title} +
+remaining
++ No finalist projects found for this round +
+ ) : ( +remaining
Current Criteria
+ {(() => { + const criteria = (sessionData.criteriaJson as LiveVotingCriterion[] | null) || [] + if (criteria.length === 0) { + return ( ++ No criteria configured. Import from an evaluation form or add manually. +
+ ) + } + return ( +- No finalist projects found for this round -
- ) : ( -- No votes yet -
- ) - } - return ( -+ Allow non-authenticated audience members to vote +
++ Audience must provide email/name +
+No results yet. Start voting to see results.
+{result.project?.title}
+ {result.project?.teamName && ( +{result.project.teamName}
+ )} +{result.project?.title}
+ {result.project?.teamName && ( +{result.project.teamName}
+ )} +per project
- {progress?.completedAssignments || 0} of {progress?.totalAssignments || 0} -
+ {isLiveEvent && liveSession ? ( + <> ++ {liveSession.audienceVoterCount} audience voters +
+ > + ) : ( + <> ++ {progress?.completedAssignments || 0} of {progress?.totalAssignments || 0} +
+ > + )}+ {liveSession.status === 'IN_PROGRESS' ? 'Live Now' : liveSession.status.toLowerCase().replace('_', ' ')} +
+Status
+{liveSession.round.projects.length}
+Projects
+{liveSession.currentVotes.length}
+Jury Votes
+{liveSession.audienceVoterCount}
+Audience
++ Voting in progress +
++ Project {(liveSession.currentProjectIndex ?? 0) + 1} of {liveSession.round.projects.length} +
++ Voting session completed - view results in the dashboard +
+Round Management
++ {isFilteringRound ? 'Filtering' : isLiveEvent ? 'Live Event' : 'Evaluation'} Management +
Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.
-Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.
+Your Score
-Your Score
++ 1 = Low, 10 = Excellent +
- 1 = Low, 10 = Excellent -
-Score Each Criterion
+ {criteria.map((c) => ( +{c.label}
+ {c.description && ( ++ {c.description} +
+ )} +Weighted Score
+ + {computeWeightedScore().toFixed(1)} + ++ Audience voting is not enabled for this session. +
++ Register to participate in audience voting +
+ + {data.session.audienceRequireId && ( ++ Required for audience voting verification +
++ {data.currentProject.teamName} +
+ )} ++ Time remaining to vote +
+Your Score
++ 1 = Low, 10 = Excellent +
++ {data.session.status === 'COMPLETED' + ? 'The voting session has ended. Thank you for participating!' + : 'Voting will begin when the next project is presented.'} +
+ {data.session.status !== 'COMPLETED' && ( ++ This page will update automatically. +
+ )} ++ {isConnected ? 'Connected' : 'Reconnecting...'} +
++ {round.name} +
+ + {/* Stats Row */} +{round.name}
++ {round.roundType.toLowerCase().replace('_', ' ')} · {round.status.toLowerCase()} +
++ {projectCount} projects + {round._count?.assignments ? `, ${round._count.assignments} assignments` : ''} +
+ {isBottleneck && ( ++ {dropRate}% drop from previous round +
+ )} ++ {settings.votingMode === 'simple' + ? 'Jurors give a single 1-10 score per project' + : 'Jurors score each criterion separately, weighted into a final score'} +
++ How audience members can participate in voting +
++ Audience must provide email or name to vote +
++ Number of favorites each audience member can select +
++ Leave empty to use the same window as jury voting +
+