Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal

Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)

Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)

Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)

Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)

Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View File

@@ -25,6 +25,7 @@ import {
ArrowRight,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
async function JuryDashboardContent() {
const session = await auth()
@@ -105,6 +106,27 @@ async function JuryDashboardContent() {
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
)
// Get grace periods for this user
const gracePeriods = await prisma.gracePeriod.findMany({
where: {
userId,
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
extendedUntil: true,
},
})
// Build a map of roundId -> latest extendedUntil
const graceByRound = new Map<string, Date>()
for (const gp of gracePeriods) {
const existing = graceByRound.get(gp.roundId)
if (!existing || gp.extendedUntil > existing) {
graceByRound.set(gp.roundId, gp.extendedUntil)
}
}
// Get active rounds (voting window is open)
const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter(
@@ -221,9 +243,15 @@ async function JuryDashboardContent() {
</div>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Deadline: {formatDateOnly(round.votingEndAt)}
</p>
<div className="flex items-center gap-2 flex-wrap">
<CountdownTimer
deadline={graceByRound.get(round.id) ?? new Date(round.votingEndAt)}
label="Deadline:"
/>
<span className="text-xs text-muted-foreground">
({formatDateOnly(round.votingEndAt)})
</span>
</div>
)}
<Button asChild size="sm" className="w-full sm:w-auto">

View File

@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationForm } from '@/components/forms/evaluation-form'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
@@ -21,9 +21,20 @@ interface Criterion {
id: string
label: string
description?: string
scale: number
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
scale?: number
weight?: number
required?: boolean
maxLength?: number
placeholder?: string
trueLabel?: string
falseLabel?: string
condition?: {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
sectionId?: string
}
async function EvaluateContent({ projectId }: { projectId: string }) {
@@ -133,6 +144,14 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
redirect(`/jury/projects/${projectId}/evaluation`)
}
// Check COI status
const coiRecord = await prisma.conflictOfInterest.findUnique({
where: { assignmentId: assignment.id },
})
const coiStatus = coiRecord
? { hasConflict: coiRecord.hasConflict, declared: true }
: { hasConflict: false, declared: false }
// Get evaluation form criteria
const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) {
@@ -247,8 +266,8 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
</Card>
)}
{/* Evaluation Form */}
<EvaluationForm
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
@@ -258,7 +277,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number
number | string | boolean
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
@@ -269,6 +288,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
coiStatus={coiStatus}
/>
</div>
)