Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle

- Pool page: auto-select program from edition context, URL params for roundId/competitionId deep-linking, unassigned toggle, round badges column
- Competition detail: rich round cards with project counts, dates, jury info, status badges replacing flat list
- Round detail: readiness checklist, embedded assignment dashboard, file requirements in config tab, notifyOnEntry toggle
- ProjectStatesTable: search input, project links, quick-add dialog, pool links with context params
- FilteringDashboard: expandable rows with AI reasoning inline, quick override buttons, search, clickable stats
- Backend: notifyOnEntry in round configJson triggers announcement emails on project assignment via existing email infra

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 08:23:40 +01:00
parent 7f334ed095
commit 845554fdb8
7 changed files with 1197 additions and 496 deletions

View File

@ -40,13 +40,14 @@ import {
ChevronDown,
Layers,
Users,
FileBox,
FolderKanban,
ClipboardList,
Settings,
MoreHorizontal,
Archive,
Loader2,
Plus,
CalendarDays,
} from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@ -298,10 +299,12 @@ export default function CompetitionDetailPage() {
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<FileBox className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Windows</span>
<FolderKanban className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Projects</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
<p className="text-2xl font-bold mt-1">
{competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)}
</p>
</CardContent>
</Card>
<Card>
@ -349,39 +352,93 @@ export default function CompetitionDetailPage() {
</CardContent>
</Card>
) : (
<div className="space-y-2">
{competition.rounds.map((round, index) => (
<Link
key={round.id}
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{round.name}</p>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0',
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{competition.rounds.map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
return (
<Link
key={round.id}
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="pt-4 pb-3 space-y-3">
{/* Top: number + name + badges */}
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{round.name}</p>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge
variant="outline"
className={cn('text-[10px]', statusColors[statusLabel])}
>
{statusLabel}
</Badge>
</div>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ClipboardList className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{/* Dates */}
{(round.windowOpenAt || round.windowCloseAt) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span>
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString()
: '?'}
{' \u2014 '}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString()
: '?'}
</span>
</div>
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge
variant="outline"
className="text-[10px] shrink-0 hidden sm:inline-flex"
>
{round.status.replace('ROUND_', '')}
</Badge>
</CardContent>
</Card>
</Link>
))}
{/* Jury group */}
{round.juryGroup && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</div>
)}
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</TabsContent>

View File

@ -53,14 +53,21 @@ import {
Settings,
Zap,
ExternalLink,
FileText,
Shield,
UserPlus,
CheckCircle2,
AlertTriangle,
CircleDot,
FileText,
} from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
// -- Status config --
const roundStatusConfig = {
@ -109,6 +116,7 @@ export default function RoundDetailPage() {
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const utils = trpc.useUtils()
@ -118,6 +126,7 @@ export default function RoundDetailPage() {
{ competitionId },
{ enabled: !!competitionId },
)
const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId })
// Sync config from server when not dirty
if (round && !hasChanges) {
@ -189,10 +198,12 @@ export default function RoundDetailPage() {
const juryGroup = round?.juryGroup
const juryMemberCount = juryGroup?.members?.length ?? 0
// Determine available tabs based on round type
// Round type flags
const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION'
const hasSubmissionWindows = round?.roundType === 'SUBMISSION' || round?.roundType === 'EVALUATION' || round?.roundType === 'INTAKE'
// Pool link with context params
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
// Loading
if (isLoading) {
@ -236,6 +247,49 @@ export default function RoundDetailPage() {
const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
// -- Readiness checklist items --
const readinessItems = [
{
label: 'Projects assigned',
ready: projectCount > 0,
detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet',
action: projectCount === 0 ? poolLink : undefined,
actionLabel: 'Assign Projects',
},
...(isEvaluation || isFiltering
? [
{
label: 'Jury group set',
ready: !!juryGroup,
detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
},
]
: []),
{
label: 'Dates configured',
ready: !!round.windowOpenAt && !!round.windowCloseAt,
detail:
round.windowOpenAt && round.windowCloseAt
? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}`
: 'No dates set \u2014 configure in Config tab',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
},
{
label: 'File requirements set',
ready: (fileRequirements?.length ?? 0) > 0,
detail:
(fileRequirements?.length ?? 0) > 0
? `${fileRequirements?.length} requirement(s)`
: 'No file requirements \u2014 configure in Config tab',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
},
]
const readyCount = readinessItems.filter((i) => i.ready).length
return (
<div className="space-y-6">
{/* ===== HEADER ===== */}
@ -331,15 +385,7 @@ export default function RoundDetailPage() {
Save Config
</Button>
)}
{(isEvaluation || isFiltering) && (
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<Button variant="outline" size="sm">
<ClipboardList className="h-4 w-4 mr-1.5" />
Assignments
</Button>
</Link>
)}
<Link href={'/admin/projects/pool' as Route}>
<Link href={poolLink}>
<Button variant="outline" size="sm">
<Layers className="h-4 w-4 mr-1.5" />
Project Pool
@ -405,7 +451,7 @@ export default function RoundDetailPage() {
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No jury groups yet</p>
</>
)}
@ -433,7 +479,7 @@ export default function RoundDetailPage() {
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No dates set</p>
</>
)}
@ -455,7 +501,7 @@ export default function RoundDetailPage() {
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">Admin selection</p>
</>
)}
@ -490,14 +536,61 @@ export default function RoundDetailPage() {
<Settings className="h-3.5 w-3.5 mr-1.5" />
Config
</TabsTrigger>
<TabsTrigger value="windows">
<FileText className="h-3.5 w-3.5 mr-1.5" />
Documents
</TabsTrigger>
</TabsList>
{/* ===== OVERVIEW TAB ===== */}
<TabsContent value="overview" className="space-y-6">
{/* Readiness Checklist */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Readiness Checklist</CardTitle>
<CardDescription>
{readyCount}/{readinessItems.length} items ready
</CardDescription>
</div>
<Badge
variant={readyCount === readinessItems.length ? 'default' : 'secondary'}
className={cn(
'text-xs',
readyCount === readinessItems.length
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700'
)}
>
{readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{readinessItems.map((item) => (
<div key={item.label} className="flex items-start gap-3">
{item.ready ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium', item.ready && 'text-muted-foreground')}>
{item.label}
</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
{item.action && (
<Link href={item.action}>
<Button variant="outline" size="sm" className="shrink-0 text-xs">
{item.actionLabel}
</Button>
</Link>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
@ -573,7 +666,7 @@ export default function RoundDetailPage() {
)}
{/* Assign projects */}
<Link href={'/admin/projects/pool' as Route}>
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
<Layers className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
@ -620,19 +713,20 @@ export default function RoundDetailPage() {
</button>
)}
{/* Evaluation specific */}
{/* Evaluation: generate assignments */}
{isEvaluation && (
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Manage Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate and review jury-project assignments
</p>
</div>
</button>
</Link>
<button
onClick={() => setActiveTab('assignments')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
>
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Manage Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate and review jury-project assignments
</p>
</div>
</button>
)}
{/* View projects */}
@ -680,19 +774,19 @@ export default function RoundDetailPage() {
<div className="flex justify-between">
<span className="text-muted-foreground">Jury Group</span>
<span className="font-medium">
{juryGroup ? juryGroup.name : ''}
{juryGroup ? juryGroup.name : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opens</span>
<span className="font-medium">
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : ''}
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Closes</span>
<span className="font-medium">
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : ''}
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
</span>
</div>
</CardContent>
@ -757,143 +851,182 @@ export default function RoundDetailPage() {
{/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-4">
<RoundAssignmentsOverview competitionId={competitionId} roundId={roundId} />
<TabsContent value="assignments" className="space-y-6">
{/* Coverage Report (embedded) */}
<CoverageReport roundId={roundId} />
{/* Generate Assignments */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Assignment Generation</CardTitle>
<CardDescription>
AI-suggested jury-to-project assignments based on expertise and workload
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setPreviewSheetOpen(true)}
disabled={projectCount === 0 || !juryGroup}
>
<Zap className="h-4 w-4 mr-1.5" />
Generate Assignments
</Button>
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<Button size="sm" variant="outline">
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
Full Dashboard
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent>
{!juryGroup && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 shrink-0" />
Assign a jury group first before generating assignments.
</div>
)}
{projectCount === 0 && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 shrink-0" />
Add projects to this round first.
</div>
)}
{juryGroup && projectCount > 0 && (
<p className="text-sm text-muted-foreground">
Click &quot;Generate Assignments&quot; to preview AI-suggested assignments.
You can review and execute them from the preview sheet.
</p>
)}
</CardContent>
</Card>
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
{/* Assignment Preview Sheet */}
<AssignmentPreviewSheet
roundId={roundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
/>
</TabsContent>
)}
{/* ===== CONFIG TAB ===== */}
<TabsContent value="config" className="space-y-4">
<TabsContent value="config" className="space-y-6">
{/* General Round Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
</Label>
<p className="text-xs text-muted-foreground">
Send an automated email to project applicants when their project enters this round
</p>
</div>
<Switch
id="notify-on-entry"
checked={!!config.notifyOnEntry}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnEntry: checked })
}}
/>
</div>
</CardContent>
</Card>
{/* Round-type-specific config */}
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
/>
</TabsContent>
{/* ===== DOCUMENTS TAB ===== */}
<TabsContent value="windows" className="space-y-4">
<FileRequirementsEditor
roundId={roundId}
windowOpenAt={round.windowOpenAt}
windowCloseAt={round.windowCloseAt}
/>
{/* Document Requirements (merged from old Documents tab) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Document Requirements</CardTitle>
<CardDescription>
Files applicants must submit for this round
{round.windowCloseAt && (
<> &mdash; due by {new Date(round.windowCloseAt).toLocaleDateString()}</>
)}
</CardDescription>
</CardHeader>
<CardContent>
<FileRequirementsEditor
roundId={roundId}
windowOpenAt={round.windowOpenAt}
windowCloseAt={round.windowCloseAt}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
// ===== Inline sub-component for evaluation round assignments =====
// ===== Sub-component: Unassigned projects queue for evaluation rounds =====
function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) {
const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({
roundId,
requiredReviews: 3,
})
const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId, requiredReviews: 3 },
)
return (
<div className="space-y-6">
{/* Coverage stats */}
{coverageLoading ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-28" />)}
</div>
) : coverage ? (
<div className="grid gap-4 grid-cols-1 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">Fully Assigned</CardTitle>
<Users className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{coverage.fullyAssigned || 0}</div>
<p className="text-xs text-muted-foreground">
of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Reviews/Project</CardTitle>
<BarChart3 className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{coverage.avgReviewsPerProject?.toFixed(1) || '0'}</div>
<p className="text-xs text-muted-foreground">Target: 3 per project</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
<Layers className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-700">{coverage.unassigned || 0}</div>
<p className="text-xs text-muted-foreground">Need more assignments</p>
</CardContent>
</Card>
</div>
) : null}
{/* Unassigned queue */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Unassigned Projects</CardTitle>
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
</div>
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<Button size="sm">
<ClipboardList className="h-4 w-4 mr-1.5" />
Full Assignment Dashboard
<ExternalLink className="h-3 w-3 ml-1.5" />
</Button>
</Link>
<Card>
<CardHeader>
<CardTitle className="text-base">Unassigned Projects</CardTitle>
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
</CardHeader>
<CardContent>
{unassignedLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : unassigned && unassigned.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{unassigned.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground">
{project.competitionCategory || 'No category'}
{project.teamName && ` · ${project.teamName}`}
</p>
</div>
<Badge variant="outline" className={cn(
'text-xs shrink-0 ml-3',
(project.assignmentCount || 0) === 0
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-amber-50 text-amber-700 border-amber-200'
)}>
{project.assignmentCount || 0} / 3
</Badge>
) : unassigned && unassigned.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{unassigned.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground">
{project.competitionCategory || 'No category'}
{project.teamName && ` \u00b7 ${project.teamName}`}
</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
</div>
<Badge variant="outline" className={cn(
'text-xs shrink-0 ml-3',
(project.assignmentCount || 0) === 0
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-amber-50 text-amber-700 border-amber-200'
)}>
{project.assignmentCount || 0} / 3
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
)
}

View File

@ -1,11 +1,14 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@ -23,41 +26,86 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const searchParams = useSearchParams()
const { currentEdition, isLoading: editionLoading } = useEdition()
// URL params for deep-linking context
const urlRoundId = searchParams.get('roundId') || ''
const urlCompetitionId = searchParams.get('competitionId') || ''
// Auto-select programId from edition
const programId = currentEdition?.id || ''
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
// Pre-select target round from URL param
useEffect(() => {
if (urlRoundId) setTargetRoundId(urlRoundId)
}, [urlRoundId])
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
programId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
unassignedOnly: showUnassignedOnly,
excludeRoundId: urlRoundId || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
{ enabled: !!programId }
)
// Load rounds from program (program.get returns rounds from all competitions)
// Load rounds from program (flattened from all competitions, now with competitionId)
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
{ id: programId },
{ enabled: !!programId }
)
const rounds = (programData?.rounds || []) as Array<{ id: string; name: string; roundType: string; sortOrder: number }>
// Get round name for context banner
const allRounds = useMemo(() => {
return (programData?.rounds || []) as Array<{
id: string
name: string
competitionId: string
status: string
_count: { projects: number; assignments: number }
}>
}, [programData])
// Filter rounds by competitionId if URL param is set
const filteredRounds = useMemo(() => {
if (urlCompetitionId) {
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
}
return allRounds
}, [allRounds, urlCompetitionId])
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
const utils = trpc.useUtils()
@ -68,7 +116,7 @@ export default function ProjectPoolPage() {
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
@ -83,7 +131,7 @@ export default function ProjectPoolPage() {
toast.success(`Assigned all ${result.assignedCount} projects to round`)
setSelectedProjects([])
setAssignAllDialogOpen(false)
setTargetRoundId('')
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
@ -102,11 +150,12 @@ export default function ProjectPoolPage() {
}
const handleAssignAll = () => {
if (!targetRoundId || !selectedProgramId) return
if (!targetRoundId || !programId) return
assignAllMutation.mutate({
programId: selectedProgramId,
programId,
roundId: targetRoundId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
unassignedOnly: showUnassignedOnly,
})
}
@ -134,6 +183,16 @@ export default function ProjectPoolPage() {
}
}
if (editionLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
@ -143,37 +202,47 @@ export default function ProjectPoolPage() {
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex-1">
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to competition rounds
{currentEdition
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
: 'No edition selected'}
</p>
</div>
</div>
{/* Context banner when coming from a round */}
{contextRound && (
<Card className="border-blue-200 bg-blue-50/50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-600 shrink-0" />
<p className="text-sm">
Assigning to <span className="font-semibold">{contextRound.name}</span>
{' \u2014 '}
<span className="text-muted-foreground">
projects already in this round are hidden
</span>
</p>
</div>
<Link
href={`/admin/competitions/${urlCompetitionId}/rounds/${urlRoundId}` as Route}
>
<Button variant="outline" size="sm" className="shrink-0">
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back to Round
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* Filters */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: string) => {
@ -202,14 +271,29 @@ export default function ProjectPoolPage() {
}}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Switch
id="unassigned-only"
checked={showUnassignedOnly}
onCheckedChange={(checked) => {
setShowUnassignedOnly(checked)
setCurrentPage(1)
}}
/>
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
Unassigned only
</label>
</div>
</div>
</Card>
{/* Action bar */}
{selectedProgramId && poolData && poolData.total > 0 && (
<div className="flex items-center justify-between">
{programId && poolData && poolData.total > 0 && (
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{poolData.total}</span> unassigned project{poolData.total !== 1 ? 's' : ''}
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
{showUnassignedOnly && ' (unassigned only)'}
</p>
<div className="flex items-center gap-2">
{selectedProjects.length > 0 && (
@ -229,7 +313,7 @@ export default function ProjectPoolPage() {
)}
{/* Projects Table */}
{selectedProgramId && (
{programId ? (
<>
{isLoadingPool ? (
<Card className="p-4">
@ -246,7 +330,7 @@ export default function ProjectPoolPage() {
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<th className="p-3 text-left w-[40px]">
<Checkbox
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
onCheckedChange={toggleSelectAll}
@ -254,6 +338,7 @@ export default function ProjectPoolPage() {
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Rounds</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Quick Assign</th>
@ -279,11 +364,28 @@ export default function ProjectPoolPage() {
</td>
<td className="p-3">
{project.competitionCategory && (
<Badge variant="outline">
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</td>
<td className="p-3">
{(project as any).projectRoundStates?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{(project as any).projectRoundStates.map((prs: any) => (
<Badge
key={prs.roundId}
variant="secondary"
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
>
{prs.round?.name || 'Round'}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
@ -304,7 +406,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
@ -351,15 +453,22 @@ export default function ProjectPoolPage() {
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
<div className="flex flex-col items-center gap-3">
<Layers className="h-8 w-8 text-muted-foreground/50" />
<p>
{showUnassignedOnly
? 'No unassigned projects found'
: urlRoundId
? 'All projects are already assigned to this round'
: 'No projects found for this program'}
</p>
</div>
</Card>
)}
</>
)}
{!selectedProgramId && (
) : (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
No edition selected. Please select an edition from the sidebar.
</Card>
)}
@ -378,7 +487,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
@ -405,9 +514,9 @@ export default function ProjectPoolPage() {
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign All Unassigned Projects</DialogTitle>
<DialogTitle>Assign All Projects</DialogTitle>
<DialogDescription>
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''} unassigned projects to a round in one operation.
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@ -416,7 +525,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>

View File

@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@ -43,15 +44,20 @@ import {
XCircle,
AlertTriangle,
RefreshCw,
Eye,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Shield,
Sparkles,
Ban,
Flag,
RotateCcw,
Search,
ExternalLink,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
type FilteringDashboardProps = {
competitionId: string
@ -80,7 +86,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const [bulkOverrideDialogOpen, setBulkOverrideDialogOpen] = useState(false)
const [bulkOutcome, setBulkOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
const [bulkReason, setBulkReason] = useState('')
const [detailResult, setDetailResult] = useState<any>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const utils = trpc.useUtils()
@ -208,6 +215,14 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
finalizeMutation.mutate({ roundId })
}
const handleQuickOverride = (id: string, newOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED') => {
overrideMutation.mutate({
id,
finalOutcome: newOutcome,
reason: 'Quick override by admin',
})
}
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
@ -247,6 +262,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const hasResults = stats && stats.total > 0
const hasRules = rules && rules.length > 0
// Filter results by search query (client-side)
const displayResults = resultsPage?.results.filter((r: any) => {
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase()
return (
(r.project?.title || '').toLowerCase().includes(q) ||
(r.project?.teamName || '').toLowerCase().includes(q)
)
}) ?? []
return (
<div className="space-y-6">
{/* Job Control */}
@ -379,7 +404,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<p className="text-xs text-muted-foreground">Projects screened</p>
</CardContent>
</Card>
<Card>
<Card className="cursor-pointer hover:border-green-300 transition-colors" onClick={() => { setOutcomeFilter('PASSED'); setPage(1) }}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Passed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
@ -391,7 +416,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</p>
</CardContent>
</Card>
<Card>
<Card className="cursor-pointer hover:border-red-300 transition-colors" onClick={() => { setOutcomeFilter('FILTERED_OUT'); setPage(1) }}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Filtered Out</CardTitle>
<Ban className="h-4 w-4 text-red-600" />
@ -403,7 +428,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</p>
</CardContent>
</Card>
<Card>
<Card className="cursor-pointer hover:border-amber-300 transition-colors" onClick={() => { setOutcomeFilter('FLAGGED'); setPage(1) }}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Flagged</CardTitle>
<Flag className="h-4 w-4 text-amber-600" />
@ -434,10 +459,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div>
<CardTitle className="text-base">Filtering Results</CardTitle>
<CardDescription>
Review AI screening outcomes and override decisions
Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
<Select
value={outcomeFilter}
onValueChange={(v) => {
@ -446,7 +480,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
setSelectedIds(new Set())
}}
>
<SelectTrigger className="w-[160px]">
<SelectTrigger className="w-[140px] h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -462,12 +496,13 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
size="sm"
onClick={() => setBulkOverrideDialogOpen(true)}
>
Override {selectedIds.size} Selected
Override {selectedIds.size}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
utils.filtering.getResults.invalidate()
utils.filtering.getResultStats.invalidate({ roundId })
@ -485,15 +520,15 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : resultsPage && resultsPage.results.length > 0 ? (
<div className="space-y-1">
) : displayResults.length > 0 ? (
<div className="space-y-0">
{/* Table Header */}
<div className="grid grid-cols-[40px_1fr_120px_80px_80px_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
<div className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
<div>
<Checkbox
checked={
resultsPage.results.length > 0 &&
resultsPage.results.every((r: any) => selectedIds.has(r.id))
displayResults.length > 0 &&
displayResults.every((r: any) => selectedIds.has(r.id))
}
onCheckedChange={toggleSelectAll}
/>
@ -501,92 +536,232 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div>Project</div>
<div>Category</div>
<div>Outcome</div>
<div>Confidence</div>
<div>Conf.</div>
<div>Quality</div>
<div>Actions</div>
<div>Quick Actions</div>
</div>
{/* Rows */}
{resultsPage.results.map((result: any) => {
{displayResults.map((result: any) => {
const ai = parseAIData(result.aiScreeningJson)
const effectiveOutcome = result.finalOutcome || result.outcome
const isExpanded = expandedId === result.id
return (
<div
key={result.id}
className="grid grid-cols-[40px_1fr_120px_80px_80px_80px_100px] gap-2 px-3 py-2.5 items-center border-b last:border-b-0 hover:bg-muted/50 text-sm"
>
<div>
<Checkbox
checked={selectedIds.has(result.id)}
onCheckedChange={() => toggleSelect(result.id)}
/>
</div>
<div className="min-w-0">
<p className="font-medium truncate">{result.project?.title || 'Unknown'}</p>
<p className="text-xs text-muted-foreground truncate">
{result.project?.teamName}
{result.project?.country && ` · ${result.project.country}`}
</p>
</div>
<div>
<Badge variant="outline" className="text-xs">
{result.project?.competitionCategory || '—'}
</Badge>
</div>
<div>
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
</div>
<div>
{ai?.confidence != null ? (
<ConfidenceIndicator value={ai.confidence} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<div>
{ai?.qualityScore != null ? (
<span className={`text-sm font-mono font-medium ${
ai.qualityScore >= 7 ? 'text-green-700' :
ai.qualityScore >= 4 ? 'text-amber-700' :
'text-red-700'
}`}>
{ai.qualityScore}/10
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setDetailResult(result)}
title="View AI feedback"
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
setOverrideDialogOpen(true)
}}
title="Override decision"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<div key={result.id} className="border-b last:border-b-0">
{/* Main Row */}
<div
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : result.id)}
>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(result.id)}
onCheckedChange={() => toggleSelect(result.id)}
/>
</div>
<div className="min-w-0 flex items-center gap-2">
{isExpanded ? (
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<div className="min-w-0">
<Link
href={`/admin/projects/${result.projectId}` as Route}
className="font-medium truncate block hover:underline text-foreground"
onClick={(e) => e.stopPropagation()}
>
{result.project?.title || 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">
{result.project?.teamName}
{result.project?.country && ` \u00b7 ${result.project.country}`}
</p>
</div>
</div>
<div>
<Badge variant="outline" className="text-xs">
{result.project?.competitionCategory || '\u2014'}
</Badge>
</div>
<div>
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
</div>
<div>
{ai?.confidence != null ? (
<ConfidenceIndicator value={ai.confidence} />
) : (
<span className="text-xs text-muted-foreground">&mdash;</span>
)}
</div>
<div>
{ai?.qualityScore != null ? (
<span className={`text-sm font-mono font-medium ${
ai.qualityScore >= 7 ? 'text-green-700' :
ai.qualityScore >= 4 ? 'text-amber-700' :
'text-red-700'
}`}>
{ai.qualityScore}/10
</span>
) : (
<span className="text-xs text-muted-foreground">&mdash;</span>
)}
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{effectiveOutcome !== 'PASSED' && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-green-700 hover:text-green-800 hover:bg-green-50"
disabled={overrideMutation.isPending}
onClick={() => handleQuickOverride(result.id, 'PASSED')}
title="Mark as Passed"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
{effectiveOutcome !== 'FLAGGED' && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-amber-700 hover:text-amber-800 hover:bg-amber-50"
disabled={overrideMutation.isPending}
onClick={() => handleQuickOverride(result.id, 'FLAGGED')}
title="Mark as Flagged"
>
<Flag className="h-3.5 w-3.5" />
</Button>
)}
{effectiveOutcome !== 'FILTERED_OUT' && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-700 hover:text-red-800 hover:bg-red-50"
disabled={overrideMutation.isPending}
onClick={() => handleQuickOverride(result.id, 'FILTERED_OUT')}
title="Mark as Filtered Out"
>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => {
setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
setOverrideDialogOpen(true)
}}
title="Override with reason"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Expanded Detail Row */}
{isExpanded && (
<div className="px-12 pb-4 bg-muted/20 border-t border-dashed">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-3">
{/* Left: AI Reasoning */}
<div className="space-y-3">
{ai?.reasoning ? (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">AI Reasoning</p>
<div className="rounded-lg bg-background border p-3 text-sm whitespace-pre-wrap leading-relaxed">
{ai.reasoning}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
)}
{result.overrideReason && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Override Reason</p>
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm">
{result.overrideReason}
{result.overriddenByUser && (
<p className="text-xs text-muted-foreground mt-1">
By {result.overriddenByUser.name || result.overriddenByUser.email}
{result.overriddenAt && ` on ${new Date(result.overriddenAt).toLocaleDateString()}`}
</p>
)}
</div>
</div>
)}
</div>
{/* Right: Metrics + Rule Results */}
<div className="space-y-3">
{ai && (
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border p-2.5 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Confidence</p>
<p className="text-base font-bold">
{ai.confidence != null ? `${(ai.confidence * 100).toFixed(0)}%` : '\u2014'}
</p>
</div>
<div className="rounded-lg border p-2.5 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Quality</p>
<p className="text-base font-bold">
{ai.qualityScore != null ? `${ai.qualityScore}/10` : '\u2014'}
</p>
</div>
<div className="rounded-lg border p-2.5 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Spam Risk</p>
<p className={`text-base font-bold ${ai.spamRisk ? 'text-red-600' : 'text-green-600'}`}>
{ai.spamRisk ? 'Yes' : 'No'}
</p>
</div>
</div>
)}
{result.finalOutcome && result.finalOutcome !== result.outcome && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Original AI decision:</span>
<OutcomeBadge outcome={result.outcome} overridden={false} />
</div>
)}
{/* Rule-by-rule results */}
{result.ruleResultsJson && Array.isArray(result.ruleResultsJson) && result.ruleResultsJson.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Rule Results</p>
<div className="space-y-1">
{(result.ruleResultsJson as any[]).map((rule: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm px-2 py-1 rounded border bg-background">
<span className="truncate">{rule.ruleName || `Rule ${i + 1}`}</span>
<Badge variant={rule.passed ? 'default' : 'destructive'} className="text-xs shrink-0 ml-2">
{rule.passed ? 'Pass' : 'Fail'}
</Badge>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end pt-1">
<Link
href={`/admin/projects/${result.projectId}` as Route}
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
View Full Project
</Link>
</div>
</div>
</div>
</div>
)}
</div>
)
})}
{/* Pagination */}
{resultsPage.totalPages > 1 && (
{resultsPage && resultsPage.totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
Page {resultsPage.page} of {resultsPage.totalPages} ({resultsPage.total} total)
@ -615,9 +790,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">No results yet</p>
<p className="text-sm font-medium">
{searchQuery.trim() ? 'No results match your search' : 'No results yet'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Run the filtering job to screen projects
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'}
</p>
</div>
)}
@ -625,101 +802,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</Card>
)}
{/* AI Detail Dialog */}
<Dialog open={!!detailResult} onOpenChange={(open) => !open && setDetailResult(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{detailResult?.project?.title || 'Project'}</DialogTitle>
<DialogDescription>
AI screening feedback and reasoning
</DialogDescription>
</DialogHeader>
{detailResult && (() => {
const ai = parseAIData(detailResult.aiScreeningJson)
const effectiveOutcome = detailResult.finalOutcome || detailResult.outcome
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<OutcomeBadge outcome={effectiveOutcome} overridden={!!detailResult.finalOutcome && detailResult.finalOutcome !== detailResult.outcome} />
{detailResult.finalOutcome && detailResult.finalOutcome !== detailResult.outcome && (
<span className="text-xs text-muted-foreground">
Original: <OutcomeBadge outcome={detailResult.outcome} overridden={false} />
</span>
)}
</div>
{ai && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground mb-1">Confidence</p>
<p className="text-lg font-bold">
{ai.confidence != null ? `${(ai.confidence * 100).toFixed(0)}%` : '—'}
</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground mb-1">Quality</p>
<p className="text-lg font-bold">
{ai.qualityScore != null ? `${ai.qualityScore}/10` : '—'}
</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground mb-1">Spam Risk</p>
<p className={`text-lg font-bold ${ai.spamRisk ? 'text-red-600' : 'text-green-600'}`}>
{ai.spamRisk ? 'Yes' : 'No'}
</p>
</div>
</div>
{ai.reasoning && (
<div>
<p className="text-sm font-medium mb-1">AI Reasoning</p>
<div className="rounded-lg bg-muted p-3 text-sm whitespace-pre-wrap">
{ai.reasoning}
</div>
</div>
)}
</>
)}
{detailResult.overrideReason && (
<div>
<p className="text-sm font-medium mb-1">Override Reason</p>
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm">
{detailResult.overrideReason}
{detailResult.overriddenByUser && (
<p className="text-xs text-muted-foreground mt-1">
By {detailResult.overriddenByUser.name || detailResult.overriddenByUser.email}
{detailResult.overriddenAt && ` on ${new Date(detailResult.overriddenAt).toLocaleDateString()}`}
</p>
)}
</div>
</div>
)}
{/* Rule-by-rule results */}
{detailResult.ruleResultsJson && Array.isArray(detailResult.ruleResultsJson) && (
<div>
<p className="text-sm font-medium mb-1">Rule Results</p>
<div className="space-y-1">
{(detailResult.ruleResultsJson as any[]).map((rule: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm px-2 py-1.5 rounded border">
<span>{rule.ruleName || `Rule ${i + 1}`}</span>
<Badge variant={rule.passed ? 'default' : 'destructive'} className="text-xs">
{rule.passed ? 'Pass' : 'Fail'}
</Badge>
</div>
))}
</div>
</div>
)}
</div>
)
})()}
</DialogContent>
</Dialog>
{/* Single Override Dialog */}
{/* Single Override Dialog (with reason) */}
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
<DialogContent>
<DialogHeader>

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@ -52,6 +53,8 @@ import {
Layers,
Trash2,
Plus,
Search,
ExternalLink,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
@ -76,13 +79,17 @@ type ProjectStatesTableProps = {
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('')
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
const utils = trpc.useUtils()
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
)
@ -145,9 +152,21 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
})
}
const filtered = projectStates?.filter((ps: any) =>
stateFilter === 'ALL' ? true : ps.state === stateFilter
) ?? []
// Apply state filter first, then search filter
const filtered = useMemo(() => {
let result = projectStates ?? []
if (stateFilter !== 'ALL') {
result = result.filter((ps: any) => ps.state === stateFilter)
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter((ps: any) =>
(ps.project?.title || '').toLowerCase().includes(q) ||
(ps.project?.teamName || '').toLowerCase().includes(q)
)
}
return result
}, [projectStates, stateFilter, searchQuery])
const toggleSelectAll = useCallback(() => {
const ids = filtered.map((ps: any) => ps.projectId)
@ -196,7 +215,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool to this round to get started.
</p>
<Link href={'/admin/projects/pool' as Route}>
<Link href={poolLink}>
<Button size="sm" className="mt-4">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
@ -210,46 +229,70 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
return (
<div className="space-y-4">
{/* Top bar: filters + add button */}
{/* Top bar: search + filters + add buttons */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === 'ALL'
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
All ({projectStates.length})
</button>
{PROJECT_STATES.map((state) => {
const count = counts[state] || 0
if (count === 0) return null
const cfg = stateConfig[state]
return (
<button
key={state}
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === state
? cfg.color + ' border-current'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{cfg.label} ({count})
</button>
)
})}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === 'ALL'
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
All ({projectStates.length})
</button>
{PROJECT_STATES.map((state) => {
const count = counts[state] || 0
if (count === 0) return null
const cfg = stateConfig[state]
return (
<button
key={state}
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === state
? cfg.color + ' border-current'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{cfg.label} ({count})
</button>
)
})}
</div>
</div>
<Link href={'/admin/projects/pool' as Route}>
<Button size="sm" variant="outline">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
Quick Add
</Button>
</Link>
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
</Button>
</Link>
</div>
</div>
{/* Search results count */}
{searchQuery.trim() && (
<p className="text-xs text-muted-foreground">
Showing {filtered.length} of {projectStates.length} projects matching &quot;{searchQuery}&quot;
</p>
)}
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
@ -316,7 +359,12 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
/>
</div>
<div className="min-w-0">
<p className="font-medium truncate">{ps.project?.title || 'Unknown'}</p>
<Link
href={`/admin/projects/${ps.projectId}` as Route}
className="font-medium truncate block hover:underline text-foreground"
>
{ps.project?.title || 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
</div>
<div>
@ -341,6 +389,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${ps.projectId}` as Route}>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
View Project
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
const sCfg = stateConfig[state]
return (
@ -368,8 +423,25 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
)
})}
{filtered.length === 0 && searchQuery.trim() && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No projects match &quot;{searchQuery}&quot;
</div>
)}
</div>
{/* Quick Add Dialog */}
<QuickAddDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent>
@ -466,3 +538,133 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
)
}
/**
* Quick Add Dialog inline search + assign projects to this round without leaving the page.
*/
function QuickAddDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [search, setSearch] = useState('')
const [addingIds, setAddingIds] = useState<Set<string>>(new Set())
// Get the competition to find programId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: search.trim() || undefined,
perPage: 10,
},
{ enabled: open && !!programId },
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`Added to round`)
onAssigned()
// Remove from addingIds
setAddingIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const handleQuickAssign = (projectId: string) => {
setAddingIds((prev) => new Set(prev).add(projectId))
assignMutation.mutate({ projectIds: [projectId], roundId })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Quick Add Projects</DialogTitle>
<DialogDescription>
Search and assign projects to this round without leaving the page.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
autoFocus
/>
</div>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}
</p>
)}
{poolResults?.projects.map((project: any) => (
<div
key={project.id}
className="flex items-center justify-between gap-3 p-2.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.competitionCategory && (
<> &middot; {project.competitionCategory}</>
)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={assignMutation.isPending && addingIds.has(project.id)}
onClick={() => handleQuickAssign(project.id)}
>
{assignMutation.isPending && addingIds.has(project.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</>
)}
</Button>
</div>
))}
</div>
{poolResults && poolResults.total > 10 && (
<p className="text-xs text-muted-foreground text-center">
Showing 10 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -42,15 +42,16 @@ export const programRouter = router({
: undefined,
})
// Return programs with rounds flattened
// Return programs with rounds flattened, preserving competitionId
return programs.map((p) => {
const allRounds = (p as any).competitions?.flatMap((c: any) => c.rounds || []) || []
const allRounds = (p as any).competitions?.flatMap((c: any) =>
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
) || []
return {
...p,
// Provide `stages` as alias for backward compatibility
stages: allRounds.map((round: any) => ({
...round,
// Backward-compatible _count shape
_count: {
projects: round._count?.projectRoundStates || 0,
assignments: round._count?.assignments || 0,
@ -60,6 +61,7 @@ export const programRouter = router({
rounds: allRounds.map((round: any) => ({
id: round.id,
name: round.name,
competitionId: round.competitionId,
status: round.status,
votingEndAt: round.windowCloseAt,
_count: {
@ -95,8 +97,10 @@ export const programRouter = router({
},
})
// Flatten rounds from all competitions
const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || []
// Flatten rounds from all competitions, preserving competitionId
const allRounds = (program as any).competitions?.flatMap((c: any) =>
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
) || []
const rounds = allRounds.map((round: any) => ({
...round,
_count: {

View File

@ -2,38 +2,120 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { sendAnnouncementEmail } from '@/lib/email'
import type { PrismaClient } from '@prisma/client'
/**
* Send round-entry notification emails to project team members.
* Fire-and-forget: errors are logged but never block the assignment.
*/
async function sendRoundEntryEmails(
prisma: PrismaClient,
projectIds: string[],
roundName: string,
) {
try {
// Fetch projects with team members' user emails + fallback submittedByEmail
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: {
user: { select: { email: true, name: true } },
},
},
},
})
const emailPromises: Promise<void>[] = []
for (const project of projects) {
// Collect unique emails for this project
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
}
}
// Fallback: if no team members have emails, use submittedByEmail
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
emailPromises.push(
sendAnnouncementEmail(
email,
name,
`Your project has entered: ${roundName}`,
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
}),
)
}
}
await Promise.allSettled(emailPromises)
} catch (err) {
console.error('[round-entry-email] Failed to send round entry emails:', err)
}
}
/**
* Project Pool Router
*
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
* Manages the project pool for assigning projects to competition rounds.
* Shows all projects by default, with optional filtering for unassigned-only
* or projects not yet in a specific round.
*/
export const projectPoolRouter = router({
/**
* List unassigned projects with filtering and pagination
* Projects not assigned to any round
* List projects in the pool with filtering and pagination.
* By default shows ALL projects. Use filters to narrow:
* - unassignedOnly: true only projects not in any round
* - excludeRoundId: "..." only projects not already in that round
*/
listUnassigned: adminProcedure
.input(
z.object({
programId: z.string(), // Required - must specify which program
programId: z.string(),
competitionCategory: z
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
.optional(),
search: z.string().optional(), // Search in title, teamName, description
search: z.string().optional(),
unassignedOnly: z.boolean().optional().default(false),
excludeRoundId: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(20),
perPage: z.number().int().min(1).max(200).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, competitionCategory, search, page, perPage } = input
const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {
programId,
projectRoundStates: { none: {} }, // Only unassigned projects (not in any round)
}
// Optional: only show projects not in any round
if (unassignedOnly) {
where.projectRoundStates = { none: {} }
}
// Optional: exclude projects already in a specific round
if (excludeRoundId && !unassignedOnly) {
where.projectRoundStates = {
none: { roundId: excludeRoundId },
}
}
// Filter by competition category
@ -77,6 +159,22 @@ export const projectPoolRouter = router({
teamMembers: true,
},
},
projectRoundStates: {
select: {
roundId: true,
state: true,
round: {
select: {
name: true,
roundType: true,
sortOrder: true,
},
},
},
orderBy: {
round: { sortOrder: 'asc' },
},
},
},
}),
ctx.prisma.project.count({ where }),
@ -93,21 +191,11 @@ export const projectPoolRouter = router({
/**
* Bulk assign projects to a round
*
* Validates that:
* - All projects exist
* - Round exists
*
* Creates:
* - RoundAssignment entries for each project
* - Project.status updated to 'ASSIGNED'
* - ProjectStatusHistory records for each project
* - Audit log
*/
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
projectIds: z.array(z.string()).min(1).max(200),
roundId: z.string(),
})
)
@ -136,10 +224,10 @@ export const projectPoolRouter = router({
})
}
// Verify round exists
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true },
select: { id: true, name: true, configJson: true },
})
// Step 2: Perform bulk assignment in a transaction
@ -192,6 +280,12 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return {
success: true,
assignedCount: result.count,
@ -200,7 +294,8 @@ export const projectPoolRouter = router({
}),
/**
* Assign ALL unassigned projects in a program to a round (server-side, no ID limit)
* Assign ALL matching projects in a program to a round (server-side, no ID limit).
* Skips projects already in the target round.
*/
assignAllToRound: adminProcedure
.input(
@ -208,22 +303,33 @@ export const projectPoolRouter = router({
programId: z.string(),
roundId: z.string(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
unassignedOnly: z.boolean().optional().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { programId, roundId, competitionCategory } = input
const { programId, roundId, competitionCategory, unassignedOnly } = input
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true },
select: { id: true, name: true, configJson: true },
})
// Find all unassigned projects
// Find projects to assign
const where: Record<string, unknown> = {
programId,
projectRoundStates: { none: {} },
}
if (unassignedOnly) {
// Only projects not in any round
where.projectRoundStates = { none: {} }
} else {
// All projects not already in the target round
where.projectRoundStates = {
none: { roundId },
}
}
if (competitionCategory) {
where.competitionCategory = competitionCategory
}
@ -271,12 +377,19 @@ export const projectPoolRouter = router({
roundId,
programId,
competitionCategory: competitionCategory || 'ALL',
unassignedOnly,
projectCount: projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return { success: true, assignedCount: result.count, roundId }
}),
})