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

@@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import {
ArrowLeft,
Edit,
@@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
)}
{/* AI Evaluation Summary */}
{project.roundId && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
roundId={project.roundId}
/>
)}
</div>
)
}

View File

@@ -67,6 +67,8 @@ import {
AlertCircle,
Layers,
FolderOpen,
X,
AlertTriangle,
} from 'lucide-react'
import {
Select,
@@ -75,6 +77,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
@@ -346,6 +349,77 @@ export default function ProjectsPage() {
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
setSelectedIds(new Set())
setBulkStatus('')
setBulkConfirmOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to update projects')
},
})
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleSelectAll = () => {
if (!data) return
const allVisible = data.projects.map((p) => p.id)
const allSelected = allVisible.every((id) => selectedIds.has(id))
if (allSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.delete(id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.add(id))
return next
})
}
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkConfirmOpen(true)
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
roundId: filters.roundId,
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
})
}
// Determine if all visible projects are selected
const allVisibleSelected = data
? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id))
: false
const someVisibleSelected = data
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@@ -468,6 +542,15 @@ export default function ProjectsPage() {
<Table>
<TableHeader>
<TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
@@ -484,6 +567,16 @@ export default function ProjectsPage() {
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
@@ -577,14 +670,23 @@ export default function ProjectsPage() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
@@ -627,6 +729,7 @@ export default function ProjectsPage() {
</CardContent>
</Card>
</Link>
</div>
))}
</div>
@@ -641,6 +744,93 @@ export default function ProjectsPage() {
</>
) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
You are about to change the status of{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
</p>
{bulkStatus === 'REJECTED' && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>

View File

@@ -0,0 +1,404 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
ArrowLeft,
ShieldAlert,
CheckCircle2,
AlertCircle,
MoreHorizontal,
ShieldCheck,
UserX,
StickyNote,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
function COIManagementContent({ roundId }: { roundId: string }) {
const [conflictsOnly, setConflictsOnly] = useState(false)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
roundId,
hasConflictOnly: conflictsOnly || undefined,
})
const utils = trpc.useUtils()
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
onSuccess: (data) => {
utils.evaluation.listCOIByRound.invalidate({ roundId })
toast.success(`COI marked as "${data.reviewAction}"`)
},
onError: (error) => {
toast.error(error.message || 'Failed to review COI')
},
})
if (loadingRound || loadingCOI) {
return <COISkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
const totalCount = coiList?.length ?? 0
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
const getReviewBadge = (reviewAction: string | null) => {
switch (reviewAction) {
case 'cleared':
return (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Cleared
</Badge>
)
case 'reassigned':
return (
<Badge variant="default" className="bg-blue-600">
<UserX className="mr-1 h-3 w-3" />
Reassigned
</Badge>
)
case 'noted':
return (
<Badge variant="secondary">
<StickyNote className="mr-1 h-3 w-3" />
Noted
</Badge>
)
default:
return (
<Badge variant="outline" className="text-amber-600 border-amber-300">
Pending Review
</Badge>
)
}
}
const getConflictTypeBadge = (type: string | null) => {
switch (type) {
case 'financial':
return <Badge variant="destructive">Financial</Badge>
case 'personal':
return <Badge variant="secondary">Personal</Badge>
case 'organizational':
return <Badge variant="outline">Organizational</Badge>
case 'other':
return <Badge variant="outline">Other</Badge>
default:
return null
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<ShieldAlert className="h-6 w-6" />
Conflict of Interest Declarations
</h1>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{reviewedCount}</div>
</CardContent>
</Card>
</div>
{/* COI Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Declarations</CardTitle>
<CardDescription>
Review and manage conflict of interest declarations from jury members
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Switch
id="conflicts-only"
checked={conflictsOnly}
onCheckedChange={setConflictsOnly}
/>
<Label htmlFor="conflicts-only" className="text-sm">
Conflicts only
</Label>
</div>
</div>
</CardHeader>
<CardContent>
{coiList && coiList.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Juror</TableHead>
<TableHead>Conflict</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coiList.map((coi) => (
<TableRow key={coi.id}>
<TableCell className="font-medium max-w-[200px] truncate">
{coi.assignment.project.title}
</TableCell>
<TableCell>
{coi.user.name || coi.user.email}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<Badge variant="destructive">Yes</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-300">
No
</Badge>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
</TableCell>
<TableCell className="max-w-[200px]">
{coi.description ? (
<span className="text-sm text-muted-foreground truncate block">
{coi.description}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<div className="space-y-1">
{getReviewBadge(coi.reviewAction)}
{coi.reviewedBy && (
<p className="text-xs text-muted-foreground">
by {coi.reviewedBy.name || coi.reviewedBy.email}
{coi.reviewedAt && (
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
)}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={reviewCOI.isPending}
>
{reviewCOI.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'cleared',
})
}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Clear
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'reassigned',
})
}
>
<UserX className="mr-2 h-4 w-4" />
Reassign
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'noted',
})
}
>
<StickyNote className="mr-2 h-4 w-4" />
Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Declarations Yet</p>
<p className="text-sm text-muted-foreground">
{conflictsOnly
? 'No conflicts of interest have been declared for this round'
: 'No jury members have submitted COI declarations for this round yet'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function COISkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-80" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function COIManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<COISkeleton />}>
<COIManagementContent roundId={id} />
</Suspense>
)
}

View File

@@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Team Notification - removed from schema, feature not implemented */}
{/* Upload Deadline Policy */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
<CardDescription>
Control how file uploads are handled after the round starts
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Select
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
onValueChange={(value) =>
setRoundSettings((prev) => ({
...prev,
uploadDeadlinePolicy: value || undefined,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Default (no restriction)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
Default - No restriction
</SelectItem>
<SelectItem value="BLOCK">
Block uploads after round starts
</SelectItem>
<SelectItem value="ALLOW_LATE">
Allow late uploads (marked as late)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
When set to &ldquo;Block&rdquo;, applicants cannot upload files after the voting start date.
When set to &ldquo;Allow late&rdquo;, uploads are accepted but flagged as late submissions.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */}
<Card>

View File

@@ -53,6 +53,7 @@ import {
RotateCcw,
Loader2,
ShieldCheck,
Download,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -109,6 +110,41 @@ export default function FilteringResultsPage({
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
const handleExport = async () => {
const result = await exportResults.refetch()
if (result.data) {
const { data: rows, columns } = result.data
const csvContent = [
columns.join(','),
...rows.map((row) =>
columns
.map((col) => {
const value = row[col as keyof typeof row]
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value ?? ''
})
.join(',')
),
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
}
const toggleRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
@@ -166,13 +202,27 @@ export default function FilteringResultsPage({
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
</div>
<Button
variant="outline"
onClick={handleExport}
disabled={exportResults.isFetching}
>
{exportResults.isFetching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
</div>
{/* Outcome Filter Tabs */}

View File

@@ -50,6 +50,7 @@ import {
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
// AI summary bulk generation
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
onSuccess: (data) => {
if (data.errors.length > 0) {
toast.warning(
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
)
} else {
toast.success(`Generated ${data.generated} AI summaries successfully`)
}
},
onError: (error) => {
toast.error(error.message || 'Failed to generate AI summaries')
},
})
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
@@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
disabled={bulkSummaries.isPending}
>
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</div>
</div>
</CardContent>

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

View File

@@ -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>
)

View File

@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import {
ArrowLeft,
@@ -30,6 +31,7 @@ import {
Calendar,
FileText,
ExternalLink,
MessageSquare,
} from 'lucide-react'
import { formatDateOnly, getInitials } from '@/lib/utils'
@@ -52,6 +54,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
projectId,
})
const { data: mentorMessages, isLoading: messagesLoading } = trpc.mentor.getMessages.useQuery({
projectId,
})
const utils = trpc.useUtils()
const sendMessage = trpc.mentor.sendMessage.useMutation({
onSuccess: () => {
utils.mentor.getMessages.invalidate({ projectId })
},
})
if (isLoading) {
return <ProjectDetailSkeleton />
}
@@ -363,6 +376,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</Card>
{/* Messaging Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Messages
</CardTitle>
<CardDescription>
Communicate with the project team
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Suspense } from 'react'
import { prisma } from '@/lib/prisma'
'use client'
export const dynamic = 'force-dynamic'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
@@ -20,80 +20,72 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileSpreadsheet, BarChart3, Users, ClipboardList } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
TrendingUp,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
} from '@/components/charts'
async function ReportsContent() {
// Get rounds with evaluation stats
const rounds = await prisma.round.findMany({
include: {
program: {
select: {
name: true,
},
},
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
id: true,
evaluation: {
select: { id: true, status: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
})
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Calculate completion stats for each round
const roundStats = rounds.map((round) => {
const totalAssignments = round._count.assignments
const completedEvaluations = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const completionRate =
totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
programName: `${p.year} Edition`,
}))
) || []
return {
...round,
totalAssignments,
completedEvaluations,
completionRate,
}
})
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
// Calculate totals
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments,
0
)
const totalEvaluations = roundStats.reduce(
(acc, r) => acc + r.completedEvaluations,
0
)
if (rounds.length === 0) {
if (isLoading) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Reports will appear here once rounds are created
</p>
</CardContent>
</Card>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
const totalPrograms = programs?.length || 0
return (
<div className="space-y-6">
{/* Quick Stats */}
@@ -106,7 +98,7 @@ async function ReportsContent() {
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{rounds.filter((r) => r.status === 'ACTIVE').length} active
{activeRounds} active
</p>
</CardContent>
</Card>
@@ -124,27 +116,99 @@ async function ReportsContent() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssignments}</div>
<p className="text-xs text-muted-foreground">Total assignments</p>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalEvaluations}</div>
<p className="text-xs text-muted-foreground">Completed</p>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
</div>
{/* Round-specific overview stats */}
{selectedRoundId && (
<>
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
) : overviewStats ? (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Selected Round Details</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
<p className="text-xs text-muted-foreground">In this round</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
<p className="text-xs text-muted-foreground">
{overviewStats.jurorCount} jurors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
<p className="text-xs text-muted-foreground">Submitted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
</CardContent>
</Card>
</div>
</div>
) : null}
</>
)}
{/* Rounds Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
@@ -158,12 +222,11 @@ async function ReportsContent() {
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roundStats.map((round) => (
{rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
@@ -175,21 +238,8 @@ async function ReportsContent() {
)}
</div>
</TableCell>
<TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>
<div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm">
<span>
{round.completedEvaluations}/{round.totalAssignments}
</span>
<span className="text-muted-foreground">
{round.completionRate}%
</span>
</div>
<Progress value={round.completionRate} className="h-2" />
</div>
</TableCell>
<TableCell>{round.programName}</TableCell>
<TableCell>{round._count?.projects || '-'}</TableCell>
<TableCell>
<Badge
variant={
@@ -213,7 +263,7 @@ async function ReportsContent() {
{/* Rounds Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Round Reports</h2>
{roundStats.map((round) => (
{rounds.map((round) => (
<Card key={round.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
@@ -230,24 +280,14 @@ async function ReportsContent() {
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{round.program.name}</p>
<p className="text-sm text-muted-foreground">{round.programName}</p>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
<div className="flex items-center justify-between text-sm">
<span>{round._count.projects} projects</span>
<span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span className="text-muted-foreground">{round.completionRate}%</span>
</div>
<Progress value={round.completionRate} className="h-2" />
<div className="text-sm">
<span>{round._count?.projects || 0} projects</span>
</div>
</CardContent>
</Card>
@@ -257,40 +297,136 @@ async function ReportsContent() {
)
}
function ReportsSkeleton() {
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId, limit: 15 },
{ enabled: !!selectedRoundId }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
</div>
)
}
export default function ObserverReportsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
programName: `${p.year} Edition`,
}))
) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
return (
<div className="space-y-6">
{/* Header */}
@@ -301,10 +437,62 @@ export default function ObserverReportsPage() {
</p>
</div>
{/* Content */}
<Suspense fallback={<ReportsSkeleton />}>
<ReportsContent />
</Suspense>
{/* Round Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Round:</label>
{roundsLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : rounds.length > 0 ? (
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewTab selectedRoundId={selectedRoundId} />
</TabsContent>
<TabsContent value="analytics">
{selectedRoundId ? (
<AnalyticsTab selectedRoundId={selectedRoundId} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round from the dropdown above to view analytics
</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
@@ -16,17 +17,21 @@ import {
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
AlertTriangle,
Download,
Video,
File,
Users,
Crown,
MessageSquare,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
@@ -64,12 +69,25 @@ export function SubmissionDetailClient() {
const params = useParams()
const { data: session } = useSession()
const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details')
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ enabled: !!session?.user }
)
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId },
{ enabled: !!session?.user && activeTab === 'mentor' }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId })
},
})
if (isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
@@ -148,43 +166,161 @@ export function SubmissionDetailClient() {
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="mentor" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Mentor
</TabsTrigger>
</TabsList>
{/* Files */}
{/* Details Tab */}
<TabsContent value="details">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
@@ -201,6 +337,7 @@ export function SubmissionDetailClient() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div
@@ -210,7 +347,15 @@ export function SubmissionDetailClient() {
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">{file.fileName}</p>
<div className="flex items-center gap-2">
<p className="font-medium">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Submitted late
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
@@ -226,110 +371,34 @@ export function SubmissionDetailClient() {
)}
</CardContent>
</Card>
</TabsContent>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
{/* Mentor Tab */}
<TabsContent value="mentor">
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Mentor Communication
</CardTitle>
<CardDescription>
Chat with your assigned mentor
</CardDescription>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { processEvaluationReminders } from '@/server/services/evaluation-reminders'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await processEvaluationReminders()
return NextResponse.json({
ok: true,
sent: result.sent,
errors: result.errors,
timestamp: new Date().toISOString(),
})
} catch (error) {
console.error('Cron reminder processing failed:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}