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:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -39,6 +39,10 @@ import {
CheckCircle2,
PieChart,
TrendingUp,
GitCompare,
UserCheck,
Globe,
Printer,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -49,6 +53,9 @@ import {
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
CrossRoundComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
function ReportsOverview() {
@@ -414,6 +421,215 @@ function RoundAnalytics() {
)
}
function CrossRoundTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
{/* Round selector */}
<Card>
<CardHeader>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardDescription>
Choose at least 2 rounds to compare metrics side by side
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{rounds.map((round) => {
const isSelected = selectedRoundIds.includes(round.id)
return (
<Badge
key={round.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(round.id)}
>
{round.programName} - {round.name}
</Badge>
)
})}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 rounds to enable comparison
</p>
)}
</CardContent>
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedRoundIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-[300px]" />
<Skeleton className="h-[300px]" />
</div>
</div>
)}
{comparison && (
<CrossRoundComparisonChart data={comparison as Array<{
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
)}
</div>
)
}
function JurorConsistencyTab() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{consistencyLoading && <Skeleton className="h-[400px]" />}
{consistency && (
<JurorConsistencyChart
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string
name: string
email: string
evaluationCount: number
averageScore: number
stddev: number
deviationFromOverall: number
isOutlier: boolean
}>
}}
/>
)}
</div>
)
}
function DiversityTab() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{diversityLoading && <Skeleton className="h-[400px]" />}
{diversity && (
<DiversityMetricsChart
data={diversity as {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}}
/>
)}
</div>
)
}
export default function ReportsPage() {
return (
<div className="space-y-6">
@@ -427,16 +643,40 @@ export default function ReportsPage() {
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2">
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between flex-wrap gap-4">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2">
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-round" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2">
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2">
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => {
window.print()
}}
>
<Printer className="mr-2 h-4 w-4" />
Export PDF
</Button>
</div>
<TabsContent value="overview">
<ReportsOverview />
@@ -445,6 +685,18 @@ export default function ReportsPage() {
<TabsContent value="analytics">
<RoundAnalytics />
</TabsContent>
<TabsContent value="cross-round">
<CrossRoundTab />
</TabsContent>
<TabsContent value="consistency">
<JurorConsistencyTab />
</TabsContent>
<TabsContent value="diversity">
<DiversityTab />
</TabsContent>
</Tabs>
</div>
)