Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,10 @@ import {
|
||||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
import {
|
||||
Select,
|
||||
@@ -458,6 +461,321 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jury Features */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<GitCompare className="h-5 w-5" />
|
||||
Jury Features
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure project comparison and peer review for jury members
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Comparison settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Enable Project Comparison</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jury members to compare projects side by side
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(roundSettings.enable_comparison)}
|
||||
onCheckedChange={(checked) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
enable_comparison: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!!roundSettings.enable_comparison && (
|
||||
<div className="space-y-2 pl-4 border-l-2 border-muted">
|
||||
<Label className="text-sm">Max Projects to Compare</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={5}
|
||||
value={Number(roundSettings.comparison_max_projects || 3)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
comparison_max_projects: parseInt(e.target.value) || 3,
|
||||
}))
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peer review settings */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Enable Peer Review / Discussion
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jury members to discuss and see aggregated scores
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(roundSettings.peer_review_enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
peer_review_enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!!roundSettings.peer_review_enabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Divergence Threshold</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Score divergence level that triggers a warning (0.0 - 1.0)
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(roundSettings.divergence_threshold || 0.3)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
divergence_threshold: parseFloat(e.target.value) || 0.3,
|
||||
}))
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Anonymization Level</Label>
|
||||
<Select
|
||||
value={String(roundSettings.anonymization_level || 'partial')}
|
||||
onValueChange={(v) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
anonymization_level: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No anonymization</SelectItem>
|
||||
<SelectItem value="partial">Partial (Juror 1, 2...)</SelectItem>
|
||||
<SelectItem value="full">Full anonymization</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Discussion Window (hours)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={Number(roundSettings.discussion_window_hours || 48)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
discussion_window_hours: parseInt(e.target.value) || 48,
|
||||
}))
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Max Comment Length</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={100}
|
||||
max={5000}
|
||||
value={Number(roundSettings.max_comment_length || 2000)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
max_comment_length: parseInt(e.target.value) || 2000,
|
||||
}))
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
File Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure allowed file types and versioning for this round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Allowed File Types</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated MIME types or extensions
|
||||
</p>
|
||||
<Input
|
||||
placeholder="application/pdf, video/mp4, image/jpeg"
|
||||
value={String(roundSettings.allowed_file_types || '')}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
allowed_file_types: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Max File Size (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={2048}
|
||||
value={Number(roundSettings.max_file_size_mb || 500)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
max_file_size_mb: parseInt(e.target.value) || 500,
|
||||
}))
|
||||
}
|
||||
className="max-w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Enable File Versioning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep previous versions when files are replaced
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(roundSettings.file_versioning)}
|
||||
onCheckedChange={(checked) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
file_versioning: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!!roundSettings.file_versioning && (
|
||||
<div className="space-y-2 pl-4 border-l-2 border-muted">
|
||||
<Label className="text-sm">Max Versions per File</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={20}
|
||||
value={Number(roundSettings.max_file_versions || 5)}
|
||||
onChange={(e) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
max_file_versions: parseInt(e.target.value) || 5,
|
||||
}))
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Availability Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Jury Availability Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how jury member availability affects assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Require Availability</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Jury members must set availability before receiving assignments
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(roundSettings.require_availability)}
|
||||
onCheckedChange={(checked) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
require_availability: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Availability Mode</Label>
|
||||
<Select
|
||||
value={String(roundSettings.availability_mode || 'soft_penalty')}
|
||||
onValueChange={(v) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
availability_mode: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="max-w-[250px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hard_block">
|
||||
Hard Block (unavailable jurors excluded)
|
||||
</SelectItem>
|
||||
<SelectItem value="soft_penalty">
|
||||
Soft Penalty (reduce assignment priority)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
Availability Weight ({Number(roundSettings.availability_weight || 50)}%)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How much weight to give availability when using soft penalty mode
|
||||
</p>
|
||||
<Slider
|
||||
value={[Number(roundSettings.availability_weight || 50)]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={([value]) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
availability_weight: value,
|
||||
}))
|
||||
}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Evaluation Criteria */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -15,6 +15,15 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -28,6 +37,10 @@ import {
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
QrCode,
|
||||
Settings2,
|
||||
Scale,
|
||||
UserCheck,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
@@ -46,6 +59,8 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
|
||||
import { QRCodeDisplay } from '@/components/shared/qr-code-display'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -119,11 +134,38 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
||||
const [projectOrder, setProjectOrder] = useState<string[]>([])
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [votingDuration, setVotingDuration] = useState(30)
|
||||
const [liveVoteCount, setLiveVoteCount] = useState<number | null>(null)
|
||||
const [liveAvgScore, setLiveAvgScore] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 2000 } // Poll every 2 seconds
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// SSE for real-time vote updates
|
||||
const onVoteUpdate = useCallback((data: VoteUpdate) => {
|
||||
setLiveVoteCount(data.totalVotes)
|
||||
setLiveAvgScore(data.averageScore)
|
||||
}, [])
|
||||
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setLiveVoteCount(null)
|
||||
setLiveAvgScore(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(
|
||||
sessionData?.id || null,
|
||||
{
|
||||
onVoteUpdate,
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
}
|
||||
)
|
||||
|
||||
// Mutations
|
||||
@@ -166,6 +208,26 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
const updateSessionConfig = trpc.liveVoting.updateSessionConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Session config updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Presentation settings updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -446,61 +508,185 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Current Votes
|
||||
{isConnected && (
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionData.currentVotes.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No votes yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total votes</span>
|
||||
<span className="font-medium">
|
||||
{sessionData.currentVotes.length}
|
||||
</span>
|
||||
{(() => {
|
||||
const voteCount = liveVoteCount ?? sessionData.currentVotes.length
|
||||
const avgScore = liveAvgScore ?? (
|
||||
sessionData.currentVotes.length > 0
|
||||
? 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="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total 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="font-medium">
|
||||
{avgScore !== null ? avgScore.toFixed(1) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Average score</span>
|
||||
<span className="font-medium">
|
||||
{(
|
||||
sessionData.currentVotes.reduce(
|
||||
(sum, v) => sum + v.score,
|
||||
0
|
||||
) / sessionData.currentVotes.length
|
||||
).toFixed(1)}
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
Session Config
|
||||
</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
|
||||
value={sessionData.tieBreakerMethod || 'admin_decides'}
|
||||
onValueChange={(v) => {
|
||||
updateSessionConfig.mutate({
|
||||
sessionId: sessionData.id,
|
||||
tieBreakerMethod: v as 'admin_decides' | 'highest_individual' | 'revote',
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin_decides">Admin Decides</SelectItem>
|
||||
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
|
||||
<SelectItem value="revote">Revote</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Score Display Format</Label>
|
||||
<Select
|
||||
value={
|
||||
(sessionData.presentationSettingsJson as Record<string, unknown>)?.scoreDisplayFormat as string || 'bar'
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
const existing = (sessionData.presentationSettingsJson as Record<string, unknown>) || {}
|
||||
updatePresentationSettings.mutate({
|
||||
sessionId: sessionData.id,
|
||||
presentationSettingsJson: {
|
||||
...existing,
|
||||
scoreDisplayFormat: v as 'bar' | 'number' | 'radial',
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={isCompleted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bar">Bar Chart</SelectItem>
|
||||
<SelectItem value="number">Number Only</SelectItem>
|
||||
<SelectItem value="radial">Radial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Links */}
|
||||
{/* QR Codes & Links */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Links</CardTitle>
|
||||
<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-2">
|
||||
<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" />
|
||||
Jury Voting 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" />
|
||||
Public Score Display
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Sparkles,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
@@ -126,6 +127,21 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
// Save as template
|
||||
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success('Saved as template', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => router.push(`/admin/round-templates/${data.id}`),
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
// AI summary bulk generation
|
||||
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -794,6 +810,24 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
saveAsTemplate.mutate({
|
||||
roundId: round.id,
|
||||
name: `${round.name} Template`,
|
||||
})
|
||||
}
|
||||
disabled={saveAsTemplate.isPending}
|
||||
>
|
||||
{saveAsTemplate.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save as Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, Bell } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
// Available notification types for teams entering a round
|
||||
@@ -71,9 +72,38 @@ function CreateRoundContent() {
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||
const { data: templates } = trpc.roundTemplate.list.useQuery()
|
||||
|
||||
const loadTemplate = (templateId: string) => {
|
||||
if (!templateId || !templates) return
|
||||
const template = templates.find((t) => t.id === templateId)
|
||||
if (!template) return
|
||||
|
||||
// Apply template settings
|
||||
const typeMap: Record<string, 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'> = {
|
||||
EVALUATION: 'EVALUATION',
|
||||
SELECTION: 'EVALUATION',
|
||||
FINAL: 'EVALUATION',
|
||||
LIVE_VOTING: 'LIVE_EVENT',
|
||||
FILTERING: 'FILTERING',
|
||||
}
|
||||
setRoundType(typeMap[template.roundType] || 'EVALUATION')
|
||||
|
||||
if (template.settingsJson && typeof template.settingsJson === 'object') {
|
||||
setRoundSettings(template.settingsJson as Record<string, unknown>)
|
||||
}
|
||||
|
||||
if (template.name) {
|
||||
form.setValue('name', template.name)
|
||||
}
|
||||
|
||||
setSelectedTemplateId(templateId)
|
||||
toast.success(`Loaded template: ${template.name}`)
|
||||
}
|
||||
|
||||
const createRound = trpc.round.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -155,6 +185,56 @@ function CreateRoundContent() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Template Selector */}
|
||||
{templates && templates.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<LayoutTemplate className="h-5 w-5" />
|
||||
Start from Template
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Load settings from a saved template to get started quickly
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={selectedTemplateId}
|
||||
onValueChange={loadTemplate}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-sm">
|
||||
<SelectValue placeholder="Select a template..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{t.description ? ` - ${t.description}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedTemplateId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTemplateId('')
|
||||
setRoundType('EVALUATION')
|
||||
setRoundSettings({})
|
||||
form.reset()
|
||||
toast.info('Template cleared')
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user