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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user