Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
- 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:
@@ -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 — 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">—</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" 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>
|
||||
|
||||
@@ -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 "{searchQuery}"
|
||||
</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 "{searchQuery}"
|
||||
</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 && (
|
||||
<> · {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} — refine your search for more specific results
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user