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,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>
)
}