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">