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:
parent
7f334ed095
commit
845554fdb8
|
|
@ -40,13 +40,14 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
FileBox,
|
FolderKanban,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Settings,
|
Settings,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Archive,
|
Archive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
CalendarDays,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||||
|
|
||||||
|
|
@ -298,10 +299,12 @@ export default function CompetitionDetailPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileBox className="h-4 w-4 text-emerald-500" />
|
<FolderKanban className="h-4 w-4 text-emerald-500" />
|
||||||
<span className="text-sm font-medium">Windows</span>
|
<span className="text-sm font-medium">Projects</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -349,39 +352,93 @@ export default function CompetitionDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{competition.rounds.map((round, index) => (
|
{competition.rounds.map((round: any, index: number) => {
|
||||||
<Link
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
key={round.id}
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
>
|
const statusColors: Record<string, string> = {
|
||||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
<CardContent className="flex items-center gap-3 py-3">
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
{index + 1}
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
</div>
|
}
|
||||||
<div className="min-w-0 flex-1">
|
return (
|
||||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
<Link
|
||||||
</div>
|
key={round.id}
|
||||||
<Badge
|
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||||
variant="secondary"
|
>
|
||||||
className={cn(
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
'text-[10px] shrink-0',
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
{/* 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('_', ' ')}
|
{/* Jury group */}
|
||||||
</Badge>
|
{round.juryGroup && (
|
||||||
<Badge
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
variant="outline"
|
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||||
className="text-[10px] shrink-0 hidden sm:inline-flex"
|
<span className="truncate">{round.juryGroup.name}</span>
|
||||||
>
|
</div>
|
||||||
{round.status.replace('ROUND_', '')}
|
)}
|
||||||
</Badge>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</Link>
|
||||||
</Link>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,21 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Zap,
|
Zap,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
|
||||||
Shield,
|
Shield,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
CircleDot,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} 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 { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
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 --
|
// -- Status config --
|
||||||
const roundStatusConfig = {
|
const roundStatusConfig = {
|
||||||
|
|
@ -109,6 +116,7 @@ export default function RoundDetailPage() {
|
||||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState('overview')
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -118,6 +126,7 @@ export default function RoundDetailPage() {
|
||||||
{ competitionId },
|
{ competitionId },
|
||||||
{ enabled: !!competitionId },
|
{ enabled: !!competitionId },
|
||||||
)
|
)
|
||||||
|
const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId })
|
||||||
|
|
||||||
// Sync config from server when not dirty
|
// Sync config from server when not dirty
|
||||||
if (round && !hasChanges) {
|
if (round && !hasChanges) {
|
||||||
|
|
@ -189,10 +198,12 @@ export default function RoundDetailPage() {
|
||||||
const juryGroup = round?.juryGroup
|
const juryGroup = round?.juryGroup
|
||||||
const juryMemberCount = juryGroup?.members?.length ?? 0
|
const juryMemberCount = juryGroup?.members?.length ?? 0
|
||||||
|
|
||||||
// Determine available tabs based on round type
|
// Round type flags
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
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
|
// Loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -236,6 +247,49 @@ export default function RoundDetailPage() {
|
||||||
const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
|
const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
|
||||||
const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ===== HEADER ===== */}
|
{/* ===== HEADER ===== */}
|
||||||
|
|
@ -331,15 +385,7 @@ export default function RoundDetailPage() {
|
||||||
Save Config
|
Save Config
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(isEvaluation || isFiltering) && (
|
<Link href={poolLink}>
|
||||||
<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}>
|
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Layers className="h-4 w-4 mr-1.5" />
|
<Layers className="h-4 w-4 mr-1.5" />
|
||||||
Project Pool
|
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">—</p>
|
||||||
<p className="text-xs text-muted-foreground">No jury groups yet</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">—</p>
|
||||||
<p className="text-xs text-muted-foreground">No dates set</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">—</p>
|
||||||
<p className="text-xs text-muted-foreground">Admin selection</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" />
|
<Settings className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Config
|
Config
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="windows">
|
|
||||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Documents
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ===== OVERVIEW TAB ===== */}
|
{/* ===== OVERVIEW TAB ===== */}
|
||||||
<TabsContent value="overview" className="space-y-6">
|
<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 */}
|
{/* Quick Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -573,7 +666,7 @@ export default function RoundDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assign projects */}
|
{/* 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">
|
<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" />
|
<Layers className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -620,19 +713,20 @@ export default function RoundDetailPage() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Evaluation specific */}
|
{/* Evaluation: generate assignments */}
|
||||||
{isEvaluation && (
|
{isEvaluation && (
|
||||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
<button
|
||||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
onClick={() => setActiveTab('assignments')}
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
|
||||||
<div>
|
>
|
||||||
<p className="text-sm font-medium">Manage Assignments</p>
|
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<div>
|
||||||
Generate and review jury-project assignments
|
<p className="text-sm font-medium">Manage Assignments</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</div>
|
Generate and review jury-project assignments
|
||||||
</button>
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View projects */}
|
{/* View projects */}
|
||||||
|
|
@ -680,19 +774,19 @@ export default function RoundDetailPage() {
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Jury Group</span>
|
<span className="text-muted-foreground">Jury Group</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{juryGroup ? juryGroup.name : '—'}
|
{juryGroup ? juryGroup.name : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Opens</span>
|
<span className="text-muted-foreground">Opens</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'}
|
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Closes</span>
|
<span className="text-muted-foreground">Closes</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'}
|
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -757,143 +851,182 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
{/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
|
{/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
|
||||||
{isEvaluation && (
|
{isEvaluation && (
|
||||||
<TabsContent value="assignments" className="space-y-4">
|
<TabsContent value="assignments" className="space-y-6">
|
||||||
<RoundAssignmentsOverview competitionId={competitionId} roundId={roundId} />
|
{/* 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 "Generate Assignments" 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>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== CONFIG TAB ===== */}
|
{/* ===== 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
|
<RoundConfigForm
|
||||||
roundType={round.roundType}
|
roundType={round.roundType}
|
||||||
config={config}
|
config={config}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ===== DOCUMENTS TAB ===== */}
|
{/* Document Requirements (merged from old Documents tab) */}
|
||||||
<TabsContent value="windows" className="space-y-4">
|
<Card>
|
||||||
<FileRequirementsEditor
|
<CardHeader>
|
||||||
roundId={roundId}
|
<CardTitle className="text-base">Document Requirements</CardTitle>
|
||||||
windowOpenAt={round.windowOpenAt}
|
<CardDescription>
|
||||||
windowCloseAt={round.windowCloseAt}
|
Files applicants must submit for this round
|
||||||
/>
|
{round.windowCloseAt && (
|
||||||
|
<> — due by {new Date(round.windowCloseAt).toLocaleDateString()}</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FileRequirementsEditor
|
||||||
|
roundId={roundId}
|
||||||
|
windowOpenAt={round.windowOpenAt}
|
||||||
|
windowCloseAt={round.windowCloseAt}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Inline sub-component for evaluation round assignments =====
|
// ===== Sub-component: Unassigned projects queue for evaluation rounds =====
|
||||||
|
|
||||||
function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) {
|
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
|
||||||
const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({
|
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||||
roundId,
|
|
||||||
requiredReviews: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
|
||||||
{ roundId, requiredReviews: 3 },
|
{ roundId, requiredReviews: 3 },
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
{/* Coverage stats */}
|
<CardHeader>
|
||||||
{coverageLoading ? (
|
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
|
||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-28" />)}
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
) : coverage ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
<div className="space-y-2">
|
||||||
<Card>
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
) : unassigned && unassigned.length > 0 ? (
|
||||||
<CardContent>
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
{unassignedLoading ? (
|
{unassigned.map((project: any) => (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
key={project.id}
|
||||||
</div>
|
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
|
||||||
) : unassigned && unassigned.length > 0 ? (
|
>
|
||||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
<div className="min-w-0">
|
||||||
{unassigned.map((project: any) => (
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
<div
|
<p className="text-xs text-muted-foreground">
|
||||||
key={project.id}
|
{project.competitionCategory || 'No category'}
|
||||||
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
|
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||||
>
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Badge variant="outline" className={cn(
|
||||||
</div>
|
'text-xs shrink-0 ml-3',
|
||||||
) : (
|
(project.assignmentCount || 0) === 0
|
||||||
<p className="text-sm text-muted-foreground text-center py-6">
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
All projects have sufficient assignments
|
: 'bg-amber-50 text-amber-700 border-amber-200'
|
||||||
</p>
|
)}>
|
||||||
)}
|
{project.assignmentCount || 0} / 3
|
||||||
</CardContent>
|
</Badge>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
All projects have sufficient assignments
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -23,41 +26,86 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from 'sonner'
|
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() {
|
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 [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||||
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
||||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||||
|
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const perPage = 50
|
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(
|
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||||
{
|
{
|
||||||
programId: selectedProgramId,
|
programId,
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
|
unassignedOnly: showUnassignedOnly,
|
||||||
|
excludeRoundId: urlRoundId || undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
perPage,
|
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(
|
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
||||||
{ id: selectedProgramId },
|
{ id: programId },
|
||||||
{ enabled: !!selectedProgramId }
|
{ 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()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -68,7 +116,7 @@ export default function ProjectPoolPage() {
|
||||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
setAssignDialogOpen(false)
|
setAssignDialogOpen(false)
|
||||||
setTargetRoundId('')
|
setTargetRoundId(urlRoundId)
|
||||||
refetch()
|
refetch()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
|
|
@ -83,7 +131,7 @@ export default function ProjectPoolPage() {
|
||||||
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
setAssignAllDialogOpen(false)
|
setAssignAllDialogOpen(false)
|
||||||
setTargetRoundId('')
|
setTargetRoundId(urlRoundId)
|
||||||
refetch()
|
refetch()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
|
|
@ -102,11 +150,12 @@ export default function ProjectPoolPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAssignAll = () => {
|
const handleAssignAll = () => {
|
||||||
if (!targetRoundId || !selectedProgramId) return
|
if (!targetRoundId || !programId) return
|
||||||
assignAllMutation.mutate({
|
assignAllMutation.mutate({
|
||||||
programId: selectedProgramId,
|
programId,
|
||||||
roundId: targetRoundId,
|
roundId: targetRoundId,
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -143,37 +202,47 @@ export default function ProjectPoolPage() {
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Assign unassigned projects to competition rounds
|
{currentEdition
|
||||||
|
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
|
||||||
|
: 'No edition selected'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Filters */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
<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">
|
<div className="flex-1 space-y-2">
|
||||||
<label className="text-sm font-medium">Category</label>
|
<label className="text-sm font-medium">Category</label>
|
||||||
<Select value={categoryFilter} onValueChange={(value: string) => {
|
<Select value={categoryFilter} onValueChange={(value: string) => {
|
||||||
|
|
@ -202,14 +271,29 @@ export default function ProjectPoolPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Action bar */}
|
||||||
{selectedProgramId && poolData && poolData.total > 0 && (
|
{programId && poolData && poolData.total > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedProjects.length > 0 && (
|
{selectedProjects.length > 0 && (
|
||||||
|
|
@ -229,7 +313,7 @@ export default function ProjectPoolPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Projects Table */}
|
{/* Projects Table */}
|
||||||
{selectedProgramId && (
|
{programId ? (
|
||||||
<>
|
<>
|
||||||
{isLoadingPool ? (
|
{isLoadingPool ? (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|
@ -246,7 +330,7 @@ export default function ProjectPoolPage() {
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="border-b">
|
<thead className="border-b">
|
||||||
<tr className="text-sm">
|
<tr className="text-sm">
|
||||||
<th className="p-3 text-left">
|
<th className="p-3 text-left w-[40px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
|
|
@ -254,6 +338,7 @@ export default function ProjectPoolPage() {
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Project</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">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">Country</th>
|
||||||
<th className="p-3 text-left font-medium">Submitted</th>
|
<th className="p-3 text-left font-medium">Submitted</th>
|
||||||
<th className="p-3 text-left font-medium">Quick Assign</th>
|
<th className="p-3 text-left font-medium">Quick Assign</th>
|
||||||
|
|
@ -279,11 +364,28 @@ export default function ProjectPoolPage() {
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="p-3 text-sm text-muted-foreground">
|
||||||
{project.country || '-'}
|
{project.country || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -304,7 +406,7 @@ export default function ProjectPoolPage() {
|
||||||
<SelectValue placeholder="Assign to round..." />
|
<SelectValue placeholder="Assign to round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -351,15 +453,22 @@ export default function ProjectPoolPage() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!selectedProgramId && (
|
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -378,7 +487,7 @@ export default function ProjectPoolPage() {
|
||||||
<SelectValue placeholder="Select round..." />
|
<SelectValue placeholder="Select round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -405,9 +514,9 @@ export default function ProjectPoolPage() {
|
||||||
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Assign All Unassigned Projects</DialogTitle>
|
<DialogTitle>Assign All Projects</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
@ -416,7 +525,7 @@ export default function ProjectPoolPage() {
|
||||||
<SelectValue placeholder="Select round..." />
|
<SelectValue placeholder="Select round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -43,15 +44,20 @@ import {
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Eye,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Ban,
|
Ban,
|
||||||
Flag,
|
Flag,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
|
||||||
type FilteringDashboardProps = {
|
type FilteringDashboardProps = {
|
||||||
competitionId: string
|
competitionId: string
|
||||||
|
|
@ -80,7 +86,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
const [bulkOverrideDialogOpen, setBulkOverrideDialogOpen] = useState(false)
|
const [bulkOverrideDialogOpen, setBulkOverrideDialogOpen] = useState(false)
|
||||||
const [bulkOutcome, setBulkOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
|
const [bulkOutcome, setBulkOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
|
||||||
const [bulkReason, setBulkReason] = useState('')
|
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()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -208,6 +215,14 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
finalizeMutation.mutate({ roundId })
|
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) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
|
|
@ -247,6 +262,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
const hasResults = stats && stats.total > 0
|
const hasResults = stats && stats.total > 0
|
||||||
const hasRules = rules && rules.length > 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Job Control */}
|
{/* Job Control */}
|
||||||
|
|
@ -379,7 +404,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
<p className="text-xs text-muted-foreground">Projects screened</p>
|
<p className="text-xs text-muted-foreground">Projects screened</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Passed</CardTitle>
|
<CardTitle className="text-sm font-medium">Passed</CardTitle>
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
|
@ -391,7 +416,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Filtered Out</CardTitle>
|
<CardTitle className="text-sm font-medium">Filtered Out</CardTitle>
|
||||||
<Ban className="h-4 w-4 text-red-600" />
|
<Ban className="h-4 w-4 text-red-600" />
|
||||||
|
|
@ -403,7 +428,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Flagged</CardTitle>
|
<CardTitle className="text-sm font-medium">Flagged</CardTitle>
|
||||||
<Flag className="h-4 w-4 text-amber-600" />
|
<Flag className="h-4 w-4 text-amber-600" />
|
||||||
|
|
@ -434,10 +459,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Filtering Results</CardTitle>
|
<CardTitle className="text-base">Filtering Results</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Review AI screening outcomes and override decisions
|
Review AI screening outcomes — click a row to see reasoning, use quick buttons to override
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Select
|
||||||
value={outcomeFilter}
|
value={outcomeFilter}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
|
|
@ -446,7 +480,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -462,12 +496,13 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setBulkOverrideDialogOpen(true)}
|
onClick={() => setBulkOverrideDialogOpen(true)}
|
||||||
>
|
>
|
||||||
Override {selectedIds.size} Selected
|
Override {selectedIds.size}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
utils.filtering.getResults.invalidate()
|
utils.filtering.getResults.invalidate()
|
||||||
utils.filtering.getResultStats.invalidate({ roundId })
|
utils.filtering.getResultStats.invalidate({ roundId })
|
||||||
|
|
@ -485,15 +520,15 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : resultsPage && resultsPage.results.length > 0 ? (
|
) : displayResults.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-0">
|
||||||
{/* Table Header */}
|
{/* 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>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
resultsPage.results.length > 0 &&
|
displayResults.length > 0 &&
|
||||||
resultsPage.results.every((r: any) => selectedIds.has(r.id))
|
displayResults.every((r: any) => selectedIds.has(r.id))
|
||||||
}
|
}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
|
|
@ -501,92 +536,232 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
<div>Project</div>
|
<div>Project</div>
|
||||||
<div>Category</div>
|
<div>Category</div>
|
||||||
<div>Outcome</div>
|
<div>Outcome</div>
|
||||||
<div>Confidence</div>
|
<div>Conf.</div>
|
||||||
<div>Quality</div>
|
<div>Quality</div>
|
||||||
<div>Actions</div>
|
<div>Quick Actions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
{resultsPage.results.map((result: any) => {
|
{displayResults.map((result: any) => {
|
||||||
const ai = parseAIData(result.aiScreeningJson)
|
const ai = parseAIData(result.aiScreeningJson)
|
||||||
const effectiveOutcome = result.finalOutcome || result.outcome
|
const effectiveOutcome = result.finalOutcome || result.outcome
|
||||||
|
const isExpanded = expandedId === result.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={result.id} className="border-b last:border-b-0">
|
||||||
key={result.id}
|
{/* Main Row */}
|
||||||
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
|
||||||
>
|
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"
|
||||||
<div>
|
onClick={() => setExpandedId(isExpanded ? null : result.id)}
|
||||||
<Checkbox
|
>
|
||||||
checked={selectedIds.has(result.id)}
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
onCheckedChange={() => toggleSelect(result.id)}
|
<Checkbox
|
||||||
/>
|
checked={selectedIds.has(result.id)}
|
||||||
</div>
|
onCheckedChange={() => toggleSelect(result.id)}
|
||||||
<div className="min-w-0">
|
/>
|
||||||
<p className="font-medium truncate">{result.project?.title || 'Unknown'}</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
{result.project?.teamName}
|
{isExpanded ? (
|
||||||
{result.project?.country && ` · ${result.project.country}`}
|
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
<div>
|
)}
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="min-w-0">
|
||||||
{result.project?.competitionCategory || '—'}
|
<Link
|
||||||
</Badge>
|
href={`/admin/projects/${result.projectId}` as Route}
|
||||||
</div>
|
className="font-medium truncate block hover:underline text-foreground"
|
||||||
<div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
|
>
|
||||||
</div>
|
{result.project?.title || 'Unknown'}
|
||||||
<div>
|
</Link>
|
||||||
{ai?.confidence != null ? (
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
<ConfidenceIndicator value={ai.confidence} />
|
{result.project?.teamName}
|
||||||
) : (
|
{result.project?.country && ` \u00b7 ${result.project.country}`}
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{ai?.qualityScore != null ? (
|
<Badge variant="outline" className="text-xs">
|
||||||
<span className={`text-sm font-mono font-medium ${
|
{result.project?.competitionCategory || '\u2014'}
|
||||||
ai.qualityScore >= 7 ? 'text-green-700' :
|
</Badge>
|
||||||
ai.qualityScore >= 4 ? 'text-amber-700' :
|
</div>
|
||||||
'text-red-700'
|
<div>
|
||||||
}`}>
|
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
|
||||||
{ai.qualityScore}/10
|
</div>
|
||||||
</span>
|
<div>
|
||||||
) : (
|
{ai?.confidence != null ? (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<ConfidenceIndicator value={ai.confidence} />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
<div className="flex items-center gap-1">
|
)}
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
<div>
|
||||||
size="icon"
|
{ai?.qualityScore != null ? (
|
||||||
className="h-7 w-7"
|
<span className={`text-sm font-mono font-medium ${
|
||||||
onClick={() => setDetailResult(result)}
|
ai.qualityScore >= 7 ? 'text-green-700' :
|
||||||
title="View AI feedback"
|
ai.qualityScore >= 4 ? 'text-amber-700' :
|
||||||
>
|
'text-red-700'
|
||||||
<Eye className="h-3.5 w-3.5" />
|
}`}>
|
||||||
</Button>
|
{ai.qualityScore}/10
|
||||||
<Button
|
</span>
|
||||||
variant="ghost"
|
) : (
|
||||||
size="icon"
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
className="h-7 w-7"
|
)}
|
||||||
onClick={() => {
|
</div>
|
||||||
setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
|
{effectiveOutcome !== 'PASSED' && (
|
||||||
setOverrideDialogOpen(true)
|
<Button
|
||||||
}}
|
variant="ghost"
|
||||||
title="Override decision"
|
size="sm"
|
||||||
>
|
className="h-7 px-2 text-green-700 hover:text-green-800 hover:bg-green-50"
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
disabled={overrideMutation.isPending}
|
||||||
</Button>
|
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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{resultsPage.totalPages > 1 && (
|
{resultsPage && resultsPage.totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {resultsPage.page} of {resultsPage.totalPages} ({resultsPage.total} total)
|
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">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -625,101 +802,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Detail Dialog */}
|
{/* Single Override Dialog (with reason) */}
|
||||||
<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 */}
|
|
||||||
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
|
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -52,6 +53,8 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
|
@ -76,13 +79,17 @@ type ProjectStatesTableProps = {
|
||||||
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
||||||
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
|
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
|
||||||
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
||||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
||||||
|
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||||
|
|
||||||
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
)
|
)
|
||||||
|
|
@ -145,9 +152,21 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = projectStates?.filter((ps: any) =>
|
// Apply state filter first, then search filter
|
||||||
stateFilter === 'ALL' ? true : ps.state === stateFilter
|
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 toggleSelectAll = useCallback(() => {
|
||||||
const ids = filtered.map((ps: any) => ps.projectId)
|
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">
|
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||||
Assign projects from the Project Pool to this round to get started.
|
Assign projects from the Project Pool to this round to get started.
|
||||||
</p>
|
</p>
|
||||||
<Link href={'/admin/projects/pool' as Route}>
|
<Link href={poolLink}>
|
||||||
<Button size="sm" className="mt-4">
|
<Button size="sm" className="mt-4">
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Go to Project Pool
|
Go to Project Pool
|
||||||
|
|
@ -210,46 +229,70 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<button
|
<div className="relative w-64">
|
||||||
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
<Input
|
||||||
stateFilter === 'ALL'
|
placeholder="Search projects..."
|
||||||
? 'bg-foreground text-background border-foreground'
|
value={searchQuery}
|
||||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
}`}
|
className="pl-8 h-8 text-sm"
|
||||||
>
|
/>
|
||||||
All ({projectStates.length})
|
</div>
|
||||||
</button>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{PROJECT_STATES.map((state) => {
|
<button
|
||||||
const count = counts[state] || 0
|
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
|
||||||
if (count === 0) return null
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
const cfg = stateConfig[state]
|
stateFilter === 'ALL'
|
||||||
return (
|
? 'bg-foreground text-background border-foreground'
|
||||||
<button
|
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||||
key={state}
|
}`}
|
||||||
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
|
>
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
All ({projectStates.length})
|
||||||
stateFilter === state
|
</button>
|
||||||
? cfg.color + ' border-current'
|
{PROJECT_STATES.map((state) => {
|
||||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
const count = counts[state] || 0
|
||||||
}`}
|
if (count === 0) return null
|
||||||
>
|
const cfg = stateConfig[state]
|
||||||
{cfg.label} ({count})
|
return (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<Link href={'/admin/projects/pool' as Route}>
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Add from Pool
|
Quick Add
|
||||||
</Button>
|
</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>
|
</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 */}
|
{/* Bulk actions bar */}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
|
<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>
|
||||||
<div className="min-w-0">
|
<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>
|
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -341,6 +389,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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) => {
|
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
|
||||||
const sCfg = stateConfig[state]
|
const sCfg = stateConfig[state]
|
||||||
return (
|
return (
|
||||||
|
|
@ -368,8 +423,25 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Dialog */}
|
||||||
|
<QuickAddDialog
|
||||||
|
open={quickAddOpen}
|
||||||
|
onOpenChange={setQuickAddOpen}
|
||||||
|
roundId={roundId}
|
||||||
|
competitionId={competitionId}
|
||||||
|
onAssigned={() => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Single Remove Confirmation */}
|
{/* Single Remove Confirmation */}
|
||||||
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
@ -466,3 +538,133 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,16 @@ export const programRouter = router({
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return programs with rounds flattened
|
// Return programs with rounds flattened, preserving competitionId
|
||||||
return programs.map((p) => {
|
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 {
|
return {
|
||||||
...p,
|
...p,
|
||||||
// Provide `stages` as alias for backward compatibility
|
// Provide `stages` as alias for backward compatibility
|
||||||
stages: allRounds.map((round: any) => ({
|
stages: allRounds.map((round: any) => ({
|
||||||
...round,
|
...round,
|
||||||
// Backward-compatible _count shape
|
|
||||||
_count: {
|
_count: {
|
||||||
projects: round._count?.projectRoundStates || 0,
|
projects: round._count?.projectRoundStates || 0,
|
||||||
assignments: round._count?.assignments || 0,
|
assignments: round._count?.assignments || 0,
|
||||||
|
|
@ -60,6 +61,7 @@ export const programRouter = router({
|
||||||
rounds: allRounds.map((round: any) => ({
|
rounds: allRounds.map((round: any) => ({
|
||||||
id: round.id,
|
id: round.id,
|
||||||
name: round.name,
|
name: round.name,
|
||||||
|
competitionId: round.competitionId,
|
||||||
status: round.status,
|
status: round.status,
|
||||||
votingEndAt: round.windowCloseAt,
|
votingEndAt: round.windowCloseAt,
|
||||||
_count: {
|
_count: {
|
||||||
|
|
@ -95,8 +97,10 @@ export const programRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Flatten rounds from all competitions
|
// Flatten rounds from all competitions, preserving competitionId
|
||||||
const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || []
|
const allRounds = (program as any).competitions?.flatMap((c: any) =>
|
||||||
|
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
|
||||||
|
) || []
|
||||||
const rounds = allRounds.map((round: any) => ({
|
const rounds = allRounds.map((round: any) => ({
|
||||||
...round,
|
...round,
|
||||||
_count: {
|
_count: {
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,120 @@ import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
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
|
* Project Pool Router
|
||||||
*
|
*
|
||||||
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
|
* Manages the project pool for assigning projects to competition rounds.
|
||||||
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
|
* Shows all projects by default, with optional filtering for unassigned-only
|
||||||
|
* or projects not yet in a specific round.
|
||||||
*/
|
*/
|
||||||
export const projectPoolRouter = router({
|
export const projectPoolRouter = router({
|
||||||
/**
|
/**
|
||||||
* List unassigned projects with filtering and pagination
|
* List projects in the pool with filtering and pagination.
|
||||||
* Projects not assigned to any round
|
* 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
|
listUnassigned: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string(), // Required - must specify which program
|
programId: z.string(),
|
||||||
competitionCategory: z
|
competitionCategory: z
|
||||||
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
||||||
.optional(),
|
.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),
|
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 }) => {
|
.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
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
programId,
|
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
|
// Filter by competition category
|
||||||
|
|
@ -77,6 +159,22 @@ export const projectPoolRouter = router({
|
||||||
teamMembers: true,
|
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 }),
|
ctx.prisma.project.count({ where }),
|
||||||
|
|
@ -93,21 +191,11 @@ export const projectPoolRouter = router({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk assign projects to a round
|
* 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
|
assignToRound: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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(),
|
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({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { id: true },
|
select: { id: true, name: true, configJson: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Perform bulk assignment in a transaction
|
// Step 2: Perform bulk assignment in a transaction
|
||||||
|
|
@ -192,6 +280,12 @@ export const projectPoolRouter = router({
|
||||||
userAgent: ctx.userAgent,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
assignedCount: result.count,
|
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
|
assignAllToRound: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
@ -208,22 +303,33 @@ export const projectPoolRouter = router({
|
||||||
programId: z.string(),
|
programId: z.string(),
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
unassignedOnly: z.boolean().optional().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { programId, roundId, competitionCategory } = input
|
const { programId, roundId, competitionCategory, unassignedOnly } = input
|
||||||
|
|
||||||
// Verify round exists
|
// Verify round exists and get config
|
||||||
await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
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> = {
|
const where: Record<string, unknown> = {
|
||||||
programId,
|
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) {
|
if (competitionCategory) {
|
||||||
where.competitionCategory = competitionCategory
|
where.competitionCategory = competitionCategory
|
||||||
}
|
}
|
||||||
|
|
@ -271,12 +377,19 @@ export const projectPoolRouter = router({
|
||||||
roundId,
|
roundId,
|
||||||
programId,
|
programId,
|
||||||
competitionCategory: competitionCategory || 'ALL',
|
competitionCategory: competitionCategory || 'ALL',
|
||||||
|
unassignedOnly,
|
||||||
projectCount: projectIds.length,
|
projectCount: projectIds.length,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
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 }
|
return { success: true, assignedCount: result.count, roundId }
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue