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

@@ -0,0 +1,105 @@
'use client'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Clock, AlertTriangle } from 'lucide-react'
interface CountdownTimerProps {
deadline: Date
label?: string
className?: string
}
interface TimeRemaining {
days: number
hours: number
minutes: number
seconds: number
totalMs: number
}
function getTimeRemaining(deadline: Date): TimeRemaining {
const totalMs = deadline.getTime() - Date.now()
if (totalMs <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }
}
const seconds = Math.floor((totalMs / 1000) % 60)
const minutes = Math.floor((totalMs / 1000 / 60) % 60)
const hours = Math.floor((totalMs / (1000 * 60 * 60)) % 24)
const days = Math.floor(totalMs / (1000 * 60 * 60 * 24))
return { days, hours, minutes, seconds, totalMs }
}
function formatCountdown(time: TimeRemaining): string {
if (time.totalMs <= 0) return 'Deadline passed'
const { days, hours, minutes, seconds } = time
// Less than 1 hour: show minutes and seconds
if (days === 0 && hours === 0) {
return `${minutes}m ${seconds}s`
}
// Less than 24 hours: show hours and minutes
if (days === 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
// More than 24 hours: show days, hours, minutes
return `${days}d ${hours}h ${minutes}m`
}
type Urgency = 'expired' | 'critical' | 'warning' | 'normal'
function getUrgency(totalMs: number): Urgency {
if (totalMs <= 0) return 'expired'
if (totalMs < 60 * 60 * 1000) return 'critical' // < 1 hour
if (totalMs < 24 * 60 * 60 * 1000) return 'warning' // < 24 hours
return 'normal'
}
const urgencyStyles: Record<Urgency, string> = {
expired: 'text-muted-foreground bg-muted',
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900',
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900',
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
}
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {
const [time, setTime] = useState<TimeRemaining>(() => getTimeRemaining(deadline))
useEffect(() => {
const timer = setInterval(() => {
const remaining = getTimeRemaining(deadline)
setTime(remaining)
if (remaining.totalMs <= 0) {
clearInterval(timer)
}
}, 1000)
return () => clearInterval(timer)
}, [deadline])
const urgency = getUrgency(time.totalMs)
const displayText = formatCountdown(time)
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
urgencyStyles[urgency],
className
)}
>
{urgency === 'critical' ? (
<AlertTriangle className="h-3 w-3 shrink-0" />
) : (
<Clock className="h-3 w-3 shrink-0" />
)}
{label && <span className="hidden sm:inline">{label}</span>}
<span className="tabular-nums">{displayText}</span>
</div>
)
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { Send, MessageSquare } from 'lucide-react'
interface Message {
id: string
message: string
createdAt: Date | string
isRead: boolean
sender: {
id: string
name: string | null
email: string
role?: string
}
}
interface MentorChatProps {
messages: Message[]
currentUserId: string
onSendMessage: (message: string) => Promise<void>
isLoading?: boolean
isSending?: boolean
className?: string
}
export function MentorChat({
messages,
currentUserId,
onSendMessage,
isLoading,
isSending,
className,
}: MentorChatProps) {
const [newMessage, setNewMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSend = async () => {
const text = newMessage.trim()
if (!text || isSending) return
setNewMessage('')
await onSendMessage(text)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const formatTime = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
if (isLoading) {
return (
<div className={cn('flex flex-col gap-3', className)}>
<Skeleton className="h-16 w-3/4" />
<Skeleton className="h-16 w-3/4 ml-auto" />
<Skeleton className="h-16 w-3/4" />
</div>
)
}
return (
<div className={cn('flex flex-col', className)}>
{/* Messages */}
<div className="flex-1 overflow-y-auto max-h-[400px] space-y-3 p-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<MessageSquare className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm font-medium">No messages yet</p>
<p className="text-xs mt-1">Send a message to start the conversation</p>
</div>
) : (
messages.map((msg) => {
const isOwn = msg.sender.id === currentUserId
return (
<div
key={msg.id}
className={cn(
'flex',
isOwn ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2.5',
isOwn
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
{!isOwn && (
<p className={cn(
'text-xs font-medium mb-1',
isOwn ? 'text-primary-foreground/70' : 'text-foreground/70'
)}>
{msg.sender.name || msg.sender.email}
{msg.sender.role === 'MENTOR' && (
<span className="ml-1.5 text-[10px] font-normal opacity-70">
Mentor
</span>
)}
</p>
)}
<p className="text-sm whitespace-pre-wrap break-words">
{msg.message}
</p>
<p
className={cn(
'text-[10px] mt-1',
isOwn
? 'text-primary-foreground/60'
: 'text-muted-foreground'
)}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
ref={textareaRef}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="min-h-[40px] max-h-[120px] resize-none"
rows={1}
disabled={isSending}
/>
<Button
size="icon"
onClick={handleSend}
disabled={!newMessage.trim() || isSending}
className="shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
)
}

View File

@@ -1,13 +1,14 @@
'use client'
import { cn } from '@/lib/utils'
import { CheckCircle, Circle, Clock } from 'lucide-react'
import { CheckCircle, Circle, Clock, XCircle, Trophy } from 'lucide-react'
interface TimelineItem {
status: string
label: string
date: Date | string | null
completed: boolean
isTerminal?: boolean
}
interface StatusTrackerProps {
@@ -39,6 +40,8 @@ export function StatusTracker({
const isCurrent =
isCompleted && !timeline[index + 1]?.completed
const isPending = !isCompleted
const isRejected = item.status === 'REJECTED' && item.isTerminal
const isWinner = item.status === 'WINNER' && isCompleted
return (
<div key={item.status} className="relative flex gap-4">
@@ -47,14 +50,26 @@ export function StatusTracker({
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
isCompleted ? 'bg-primary' : 'bg-muted'
isRejected
? 'bg-destructive/30'
: isCompleted
? 'bg-primary'
: 'bg-muted'
)}
/>
)}
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
{isCompleted ? (
{isRejected ? (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground">
<XCircle className="h-4 w-4" />
</div>
) : isWinner ? (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-500 text-white">
<Trophy className="h-4 w-4" />
</div>
) : isCompleted ? (
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
@@ -82,23 +97,35 @@ export function StatusTracker({
<p
className={cn(
'font-medium',
isPending && 'text-muted-foreground'
isRejected && 'text-destructive',
isWinner && 'text-yellow-600',
isPending && !isRejected && 'text-muted-foreground'
)}
>
{item.label}
</p>
{isCurrent && (
{isCurrent && !isRejected && !isWinner && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
{isRejected && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded-full">
Final
</span>
)}
{isWinner && (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
Winner
</span>
)}
</div>
{item.date && (
<p className="text-sm text-muted-foreground">
{formatDate(item.date)}
</p>
)}
{isPending && !isCurrent && (
{isPending && !isCurrent && !isRejected && (
<p className="text-sm text-muted-foreground">Pending</p>
)}
</div>