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:
105
src/components/shared/countdown-timer.tsx
Normal file
105
src/components/shared/countdown-timer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/components/shared/mentor-chat.tsx
Normal file
177
src/components/shared/mentor-chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user