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,
|
||||
Layers,
|
||||
Users,
|
||||
FileBox,
|
||||
FolderKanban,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
MoreHorizontal,
|
||||
Archive,
|
||||
Loader2,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
} from 'lucide-react'
|
||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||
|
||||
|
|
@ -298,10 +299,12 @@ export default function CompetitionDetailPage() {
|
|||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileBox className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Windows</span>
|
||||
<FolderKanban className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Projects</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
|
@ -349,39 +352,93 @@ export default function CompetitionDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{competition.rounds.map((round, index) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0',
|
||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{competition.rounds.map((round: any, index: number) => {
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-600',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
{/* Top: number + name + badges */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{round.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px]', statusColors[statusLabel])}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{round.windowOpenAt
|
||||
? new Date(round.windowOpenAt).toLocaleDateString()
|
||||
: '?'}
|
||||
{' \u2014 '}
|
||||
{round.windowCloseAt
|
||||
? new Date(round.windowCloseAt).toLocaleDateString()
|
||||
: '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 hidden sm:inline-flex"
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Jury group */}
|
||||
{round.juryGroup && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{round.juryGroup.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -53,14 +53,21 @@ import {
|
|||
Settings,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Shield,
|
||||
UserPlus,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
CircleDot,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
|
||||
// -- Status config --
|
||||
const roundStatusConfig = {
|
||||
|
|
@ -109,6 +116,7 @@ export default function RoundDetailPage() {
|
|||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -118,6 +126,7 @@ export default function RoundDetailPage() {
|
|||
{ competitionId },
|
||||
{ enabled: !!competitionId },
|
||||
)
|
||||
const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId })
|
||||
|
||||
// Sync config from server when not dirty
|
||||
if (round && !hasChanges) {
|
||||
|
|
@ -189,10 +198,12 @@ export default function RoundDetailPage() {
|
|||
const juryGroup = round?.juryGroup
|
||||
const juryMemberCount = juryGroup?.members?.length ?? 0
|
||||
|
||||
// Determine available tabs based on round type
|
||||
// Round type flags
|
||||
const isFiltering = round?.roundType === 'FILTERING'
|
||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||
const hasSubmissionWindows = round?.roundType === 'SUBMISSION' || round?.roundType === 'EVALUATION' || round?.roundType === 'INTAKE'
|
||||
|
||||
// Pool link with context params
|
||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
|
|
@ -236,6 +247,49 @@ export default function RoundDetailPage() {
|
|||
const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
|
||||
const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
|
||||
|
||||
// -- Readiness checklist items --
|
||||
const readinessItems = [
|
||||
{
|
||||
label: 'Projects assigned',
|
||||
ready: projectCount > 0,
|
||||
detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet',
|
||||
action: projectCount === 0 ? poolLink : undefined,
|
||||
actionLabel: 'Assign Projects',
|
||||
},
|
||||
...(isEvaluation || isFiltering
|
||||
? [
|
||||
{
|
||||
label: 'Jury group set',
|
||||
ready: !!juryGroup,
|
||||
detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned',
|
||||
action: undefined as Route | undefined,
|
||||
actionLabel: undefined as string | undefined,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Dates configured',
|
||||
ready: !!round.windowOpenAt && !!round.windowCloseAt,
|
||||
detail:
|
||||
round.windowOpenAt && round.windowCloseAt
|
||||
? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}`
|
||||
: 'No dates set \u2014 configure in Config tab',
|
||||
action: undefined as Route | undefined,
|
||||
actionLabel: undefined as string | undefined,
|
||||
},
|
||||
{
|
||||
label: 'File requirements set',
|
||||
ready: (fileRequirements?.length ?? 0) > 0,
|
||||
detail:
|
||||
(fileRequirements?.length ?? 0) > 0
|
||||
? `${fileRequirements?.length} requirement(s)`
|
||||
: 'No file requirements \u2014 configure in Config tab',
|
||||
action: undefined as Route | undefined,
|
||||
actionLabel: undefined as string | undefined,
|
||||
},
|
||||
]
|
||||
const readyCount = readinessItems.filter((i) => i.ready).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ===== HEADER ===== */}
|
||||
|
|
@ -331,15 +385,7 @@ export default function RoundDetailPage() {
|
|||
Save Config
|
||||
</Button>
|
||||
)}
|
||||
{(isEvaluation || isFiltering) && (
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ClipboardList className="h-4 w-4 mr-1.5" />
|
||||
Assignments
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<Link href={poolLink}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Layers className="h-4 w-4 mr-1.5" />
|
||||
Project Pool
|
||||
|
|
@ -405,7 +451,7 @@ export default function RoundDetailPage() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1 text-muted-foreground">—</p>
|
||||
<p className="text-2xl font-bold mt-1 text-muted-foreground">—</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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -490,14 +536,61 @@ export default function RoundDetailPage() {
|
|||
<Settings className="h-3.5 w-3.5 mr-1.5" />
|
||||
Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="windows">
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ===== OVERVIEW TAB ===== */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* Readiness Checklist */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Readiness Checklist</CardTitle>
|
||||
<CardDescription>
|
||||
{readyCount}/{readinessItems.length} items ready
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={readyCount === readinessItems.length ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
readyCount === readinessItems.length
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
)}
|
||||
>
|
||||
{readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{readinessItems.map((item) => (
|
||||
<div key={item.label} className="flex items-start gap-3">
|
||||
{item.ready ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium', item.ready && 'text-muted-foreground')}>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{item.detail}</p>
|
||||
</div>
|
||||
{item.action && (
|
||||
<Link href={item.action}>
|
||||
<Button variant="outline" size="sm" className="shrink-0 text-xs">
|
||||
{item.actionLabel}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -573,7 +666,7 @@ export default function RoundDetailPage() {
|
|||
)}
|
||||
|
||||
{/* Assign projects */}
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<Link href={poolLink}>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
||||
<Layers className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
|
|
@ -620,19 +713,20 @@ export default function RoundDetailPage() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Evaluation specific */}
|
||||
{/* Evaluation: generate assignments */}
|
||||
{isEvaluation && (
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Manage Assignments</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Generate and review jury-project assignments
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setActiveTab('assignments')}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Manage Assignments</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Generate and review jury-project assignments
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* View projects */}
|
||||
|
|
@ -680,19 +774,19 @@ export default function RoundDetailPage() {
|
|||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Jury Group</span>
|
||||
<span className="font-medium">
|
||||
{juryGroup ? juryGroup.name : '—'}
|
||||
{juryGroup ? juryGroup.name : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Opens</span>
|
||||
<span className="font-medium">
|
||||
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'}
|
||||
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Closes</span>
|
||||
<span className="font-medium">
|
||||
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'}
|
||||
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -757,143 +851,182 @@ export default function RoundDetailPage() {
|
|||
|
||||
{/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
|
||||
{isEvaluation && (
|
||||
<TabsContent value="assignments" className="space-y-4">
|
||||
<RoundAssignmentsOverview competitionId={competitionId} roundId={roundId} />
|
||||
<TabsContent value="assignments" className="space-y-6">
|
||||
{/* Coverage Report (embedded) */}
|
||||
<CoverageReport roundId={roundId} />
|
||||
|
||||
{/* Generate Assignments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Assignment Generation</CardTitle>
|
||||
<CardDescription>
|
||||
AI-suggested jury-to-project assignments based on expertise and workload
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPreviewSheetOpen(true)}
|
||||
disabled={projectCount === 0 || !juryGroup}
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-1.5" />
|
||||
Generate Assignments
|
||||
</Button>
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<Button size="sm" variant="outline">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
|
||||
Full Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!juryGroup && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Assign a jury group first before generating assignments.
|
||||
</div>
|
||||
)}
|
||||
{projectCount === 0 && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Add projects to this round first.
|
||||
</div>
|
||||
)}
|
||||
{juryGroup && projectCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "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>
|
||||
)}
|
||||
|
||||
{/* ===== CONFIG TAB ===== */}
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
{/* General Round Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">General Settings</CardTitle>
|
||||
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
|
||||
Notify on round entry
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send an automated email to project applicants when their project enters this round
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-on-entry"
|
||||
checked={!!config.notifyOnEntry}
|
||||
onCheckedChange={(checked) => {
|
||||
handleConfigChange({ ...config, notifyOnEntry: checked })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Round-type-specific config */}
|
||||
<RoundConfigForm
|
||||
roundType={round.roundType}
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== DOCUMENTS TAB ===== */}
|
||||
<TabsContent value="windows" className="space-y-4">
|
||||
<FileRequirementsEditor
|
||||
roundId={roundId}
|
||||
windowOpenAt={round.windowOpenAt}
|
||||
windowCloseAt={round.windowCloseAt}
|
||||
/>
|
||||
{/* Document Requirements (merged from old Documents tab) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Document Requirements</CardTitle>
|
||||
<CardDescription>
|
||||
Files applicants must submit for this round
|
||||
{round.windowCloseAt && (
|
||||
<> — due by {new Date(round.windowCloseAt).toLocaleDateString()}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileRequirementsEditor
|
||||
roundId={roundId}
|
||||
windowOpenAt={round.windowOpenAt}
|
||||
windowCloseAt={round.windowCloseAt}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Inline sub-component for evaluation round assignments =====
|
||||
// ===== Sub-component: Unassigned projects queue for evaluation rounds =====
|
||||
|
||||
function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) {
|
||||
const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({
|
||||
roundId,
|
||||
requiredReviews: 3,
|
||||
})
|
||||
|
||||
const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
|
||||
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId, requiredReviews: 3 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Coverage stats */}
|
||||
{coverageLoading ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-28" />)}
|
||||
</div>
|
||||
) : coverage ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Fully Assigned</CardTitle>
|
||||
<Users className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{coverage.fullyAssigned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Reviews/Project</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{coverage.avgReviewsPerProject?.toFixed(1) || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">Target: 3 per project</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
|
||||
<Layers className="h-4 w-4 text-amber-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-700">{coverage.unassigned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Need more assignments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Unassigned queue */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
||||
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
|
||||
</div>
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<Button size="sm">
|
||||
<ClipboardList className="h-4 w-4 mr-1.5" />
|
||||
Full Assignment Dashboard
|
||||
<ExternalLink className="h-3 w-3 ml-1.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
||||
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{unassignedLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : unassigned && unassigned.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{unassigned.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.teamName && ` · ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs shrink-0 ml-3',
|
||||
(project.assignmentCount || 0) === 0
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
)}>
|
||||
{project.assignmentCount || 0} / 3
|
||||
</Badge>
|
||||
) : unassigned && unassigned.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{unassigned.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs shrink-0 ml-3',
|
||||
(project.assignmentCount || 0) === 0
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
)}>
|
||||
{project.assignmentCount || 0} / 3
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -23,41 +26,86 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
export default function ProjectPoolPage() {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition, isLoading: editionLoading } = useEdition()
|
||||
|
||||
// URL params for deep-linking context
|
||||
const urlRoundId = searchParams.get('roundId') || ''
|
||||
const urlCompetitionId = searchParams.get('competitionId') || ''
|
||||
|
||||
// Auto-select programId from edition
|
||||
const programId = currentEdition?.id || ''
|
||||
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
// Pre-select target round from URL param
|
||||
useEffect(() => {
|
||||
if (urlRoundId) setTargetRoundId(urlRoundId)
|
||||
}, [urlRoundId])
|
||||
|
||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId: selectedProgramId,
|
||||
programId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
search: searchQuery || undefined,
|
||||
unassignedOnly: showUnassignedOnly,
|
||||
excludeRoundId: urlRoundId || undefined,
|
||||
page: currentPage,
|
||||
perPage,
|
||||
},
|
||||
{ enabled: !!selectedProgramId }
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Load rounds from program (program.get returns rounds from all competitions)
|
||||
// Load rounds from program (flattened from all competitions, now with competitionId)
|
||||
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
||||
{ id: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
{ id: programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
const rounds = (programData?.rounds || []) as Array<{ id: string; name: string; roundType: string; sortOrder: number }>
|
||||
|
||||
// Get round name for context banner
|
||||
const allRounds = useMemo(() => {
|
||||
return (programData?.rounds || []) as Array<{
|
||||
id: string
|
||||
name: string
|
||||
competitionId: string
|
||||
status: string
|
||||
_count: { projects: number; assignments: number }
|
||||
}>
|
||||
}, [programData])
|
||||
|
||||
// Filter rounds by competitionId if URL param is set
|
||||
const filteredRounds = useMemo(() => {
|
||||
if (urlCompetitionId) {
|
||||
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
|
||||
}
|
||||
return allRounds
|
||||
}, [allRounds, urlCompetitionId])
|
||||
|
||||
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -68,7 +116,7 @@ export default function ProjectPoolPage() {
|
|||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetRoundId('')
|
||||
setTargetRoundId(urlRoundId)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
|
|
@ -83,7 +131,7 @@ export default function ProjectPoolPage() {
|
|||
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignAllDialogOpen(false)
|
||||
setTargetRoundId('')
|
||||
setTargetRoundId(urlRoundId)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
|
|
@ -102,11 +150,12 @@ export default function ProjectPoolPage() {
|
|||
}
|
||||
|
||||
const handleAssignAll = () => {
|
||||
if (!targetRoundId || !selectedProgramId) return
|
||||
if (!targetRoundId || !programId) return
|
||||
assignAllMutation.mutate({
|
||||
programId: selectedProgramId,
|
||||
programId,
|
||||
roundId: targetRoundId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
unassignedOnly: showUnassignedOnly,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +183,16 @@ export default function ProjectPoolPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (editionLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -143,37 +202,47 @@ export default function ProjectPoolPage() {
|
|||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Assign unassigned projects to competition rounds
|
||||
{currentEdition
|
||||
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
|
||||
: 'No edition selected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context banner when coming from a round */}
|
||||
{contextRound && (
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Assigning to <span className="font-semibold">{contextRound.name}</span>
|
||||
{' \u2014 '}
|
||||
<span className="text-muted-foreground">
|
||||
projects already in this round are hidden
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/competitions/${urlCompetitionId}/rounds/${urlRoundId}` as Route}
|
||||
>
|
||||
<Button variant="outline" size="sm" className="shrink-0">
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||
Back to Round
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Program</label>
|
||||
<Select value={selectedProgramId} onValueChange={(value) => {
|
||||
setSelectedProgramId(value)
|
||||
setSelectedProjects([])
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.name} {program.year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Select value={categoryFilter} onValueChange={(value: string) => {
|
||||
|
|
@ -202,14 +271,29 @@ export default function ProjectPoolPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<Switch
|
||||
id="unassigned-only"
|
||||
checked={showUnassignedOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowUnassignedOnly(checked)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
|
||||
Unassigned only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action bar */}
|
||||
{selectedProgramId && poolData && poolData.total > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
{programId && poolData && poolData.total > 0 && (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{poolData.total}</span> unassigned project{poolData.total !== 1 ? 's' : ''}
|
||||
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
|
||||
{showUnassignedOnly && ' (unassigned only)'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedProjects.length > 0 && (
|
||||
|
|
@ -229,7 +313,7 @@ export default function ProjectPoolPage() {
|
|||
)}
|
||||
|
||||
{/* Projects Table */}
|
||||
{selectedProgramId && (
|
||||
{programId ? (
|
||||
<>
|
||||
{isLoadingPool ? (
|
||||
<Card className="p-4">
|
||||
|
|
@ -246,7 +330,7 @@ export default function ProjectPoolPage() {
|
|||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-sm">
|
||||
<th className="p-3 text-left">
|
||||
<th className="p-3 text-left w-[40px]">
|
||||
<Checkbox
|
||||
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
|
|
@ -254,6 +338,7 @@ export default function ProjectPoolPage() {
|
|||
</th>
|
||||
<th className="p-3 text-left font-medium">Project</th>
|
||||
<th className="p-3 text-left font-medium">Category</th>
|
||||
<th className="p-3 text-left font-medium">Rounds</th>
|
||||
<th className="p-3 text-left font-medium">Country</th>
|
||||
<th className="p-3 text-left font-medium">Submitted</th>
|
||||
<th className="p-3 text-left font-medium">Quick Assign</th>
|
||||
|
|
@ -279,11 +364,28 @@ export default function ProjectPoolPage() {
|
|||
</td>
|
||||
<td className="p-3">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{(project as any).projectRoundStates?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(project as any).projectRoundStates.map((prs: any) => (
|
||||
<Badge
|
||||
key={prs.roundId}
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{prs.round?.name || 'Round'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country || '-'}
|
||||
</td>
|
||||
|
|
@ -304,7 +406,7 @@ export default function ProjectPoolPage() {
|
|||
<SelectValue placeholder="Assign to round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
|
|
@ -351,15 +453,22 @@ export default function ProjectPoolPage() {
|
|||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No unassigned projects found for this program
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Layers className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p>
|
||||
{showUnassignedOnly
|
||||
? 'No unassigned projects found'
|
||||
: urlRoundId
|
||||
? 'All projects are already assigned to this round'
|
||||
: 'No projects found for this program'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedProgramId && (
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Select a program to view unassigned projects
|
||||
No edition selected. Please select an edition from the sidebar.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
|
@ -378,7 +487,7 @@ export default function ProjectPoolPage() {
|
|||
<SelectValue placeholder="Select round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
|
|
@ -405,9 +514,9 @@ export default function ProjectPoolPage() {
|
|||
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign All Unassigned Projects</DialogTitle>
|
||||
<DialogTitle>Assign All Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''} unassigned projects to a round in one operation.
|
||||
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
|
|
@ -416,7 +525,7 @@ export default function ProjectPoolPage() {
|
|||
<SelectValue placeholder="Select round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress'
|
|||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -43,15 +44,20 @@ import {
|
|||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Ban,
|
||||
Flag,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
|
||||
type FilteringDashboardProps = {
|
||||
competitionId: string
|
||||
|
|
@ -80,7 +86,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
const [bulkOverrideDialogOpen, setBulkOverrideDialogOpen] = useState(false)
|
||||
const [bulkOutcome, setBulkOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
|
||||
const [bulkReason, setBulkReason] = useState('')
|
||||
const [detailResult, setDetailResult] = useState<any>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -208,6 +215,14 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
finalizeMutation.mutate({ roundId })
|
||||
}
|
||||
|
||||
const handleQuickOverride = (id: string, newOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED') => {
|
||||
overrideMutation.mutate({
|
||||
id,
|
||||
finalOutcome: newOutcome,
|
||||
reason: 'Quick override by admin',
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -247,6 +262,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
const hasResults = stats && stats.total > 0
|
||||
const hasRules = rules && rules.length > 0
|
||||
|
||||
// Filter results by search query (client-side)
|
||||
const displayResults = resultsPage?.results.filter((r: any) => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
return (
|
||||
(r.project?.title || '').toLowerCase().includes(q) ||
|
||||
(r.project?.teamName || '').toLowerCase().includes(q)
|
||||
)
|
||||
}) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Job Control */}
|
||||
|
|
@ -379,7 +404,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
<p className="text-xs text-muted-foreground">Projects screened</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="cursor-pointer hover:border-green-300 transition-colors" onClick={() => { setOutcomeFilter('PASSED'); setPage(1) }}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Passed</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
|
|
@ -391,7 +416,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="cursor-pointer hover:border-red-300 transition-colors" onClick={() => { setOutcomeFilter('FILTERED_OUT'); setPage(1) }}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Filtered Out</CardTitle>
|
||||
<Ban className="h-4 w-4 text-red-600" />
|
||||
|
|
@ -403,7 +428,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="cursor-pointer hover:border-amber-300 transition-colors" onClick={() => { setOutcomeFilter('FLAGGED'); setPage(1) }}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Flagged</CardTitle>
|
||||
<Flag className="h-4 w-4 text-amber-600" />
|
||||
|
|
@ -434,10 +459,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
<div>
|
||||
<CardTitle className="text-base">Filtering Results</CardTitle>
|
||||
<CardDescription>
|
||||
Review AI screening outcomes and override decisions
|
||||
Review AI screening outcomes — click a row to see reasoning, use quick buttons to override
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={outcomeFilter}
|
||||
onValueChange={(v) => {
|
||||
|
|
@ -446,7 +480,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
setSelectedIds(new Set())
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -462,12 +496,13 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
size="sm"
|
||||
onClick={() => setBulkOverrideDialogOpen(true)}
|
||||
>
|
||||
Override {selectedIds.size} Selected
|
||||
Override {selectedIds.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
utils.filtering.getResults.invalidate()
|
||||
utils.filtering.getResultStats.invalidate({ roundId })
|
||||
|
|
@ -485,15 +520,15 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : resultsPage && resultsPage.results.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
) : displayResults.length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-[40px_1fr_120px_80px_80px_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
|
||||
<div className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={
|
||||
resultsPage.results.length > 0 &&
|
||||
resultsPage.results.every((r: any) => selectedIds.has(r.id))
|
||||
displayResults.length > 0 &&
|
||||
displayResults.every((r: any) => selectedIds.has(r.id))
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
|
|
@ -501,92 +536,232 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
<div>Project</div>
|
||||
<div>Category</div>
|
||||
<div>Outcome</div>
|
||||
<div>Confidence</div>
|
||||
<div>Conf.</div>
|
||||
<div>Quality</div>
|
||||
<div>Actions</div>
|
||||
<div>Quick Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{resultsPage.results.map((result: any) => {
|
||||
{displayResults.map((result: any) => {
|
||||
const ai = parseAIData(result.aiScreeningJson)
|
||||
const effectiveOutcome = result.finalOutcome || result.outcome
|
||||
const isExpanded = expandedId === result.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
className="grid grid-cols-[40px_1fr_120px_80px_80px_80px_100px] gap-2 px-3 py-2.5 items-center border-b last:border-b-0 hover:bg-muted/50 text-sm"
|
||||
>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(result.id)}
|
||||
onCheckedChange={() => toggleSelect(result.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{result.project?.title || 'Unknown'}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{result.project?.teamName}
|
||||
{result.project?.country && ` · ${result.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.project?.competitionCategory || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
|
||||
</div>
|
||||
<div>
|
||||
{ai?.confidence != null ? (
|
||||
<ConfidenceIndicator value={ai.confidence} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{ai?.qualityScore != null ? (
|
||||
<span className={`text-sm font-mono font-medium ${
|
||||
ai.qualityScore >= 7 ? 'text-green-700' :
|
||||
ai.qualityScore >= 4 ? 'text-amber-700' :
|
||||
'text-red-700'
|
||||
}`}>
|
||||
{ai.qualityScore}/10
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setDetailResult(result)}
|
||||
title="View AI feedback"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
|
||||
setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
|
||||
setOverrideDialogOpen(true)
|
||||
}}
|
||||
title="Override decision"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div key={result.id} className="border-b last:border-b-0">
|
||||
{/* Main Row */}
|
||||
<div
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
|
||||
onClick={() => setExpandedId(isExpanded ? null : result.id)}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(result.id)}
|
||||
onCheckedChange={() => toggleSelect(result.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/projects/${result.projectId}` as Route}
|
||||
className="font-medium truncate block hover:underline text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{result.project?.title || 'Unknown'}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{result.project?.teamName}
|
||||
{result.project?.country && ` \u00b7 ${result.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.project?.competitionCategory || '\u2014'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<OutcomeBadge outcome={effectiveOutcome} overridden={!!result.finalOutcome && result.finalOutcome !== result.outcome} />
|
||||
</div>
|
||||
<div>
|
||||
{ai?.confidence != null ? (
|
||||
<ConfidenceIndicator value={ai.confidence} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{ai?.qualityScore != null ? (
|
||||
<span className={`text-sm font-mono font-medium ${
|
||||
ai.qualityScore >= 7 ? 'text-green-700' :
|
||||
ai.qualityScore >= 4 ? 'text-amber-700' :
|
||||
'text-red-700'
|
||||
}`}>
|
||||
{ai.qualityScore}/10
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{effectiveOutcome !== 'PASSED' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-green-700 hover:text-green-800 hover:bg-green-50"
|
||||
disabled={overrideMutation.isPending}
|
||||
onClick={() => handleQuickOverride(result.id, 'PASSED')}
|
||||
title="Mark as Passed"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{effectiveOutcome !== 'FLAGGED' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-amber-700 hover:text-amber-800 hover:bg-amber-50"
|
||||
disabled={overrideMutation.isPending}
|
||||
onClick={() => handleQuickOverride(result.id, 'FLAGGED')}
|
||||
title="Mark as Flagged"
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{effectiveOutcome !== 'FILTERED_OUT' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-red-700 hover:text-red-800 hover:bg-red-50"
|
||||
disabled={overrideMutation.isPending}
|
||||
onClick={() => handleQuickOverride(result.id, 'FILTERED_OUT')}
|
||||
title="Mark as Filtered Out"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
|
||||
setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
|
||||
setOverrideDialogOpen(true)
|
||||
}}
|
||||
title="Override with reason"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Detail Row */}
|
||||
{isExpanded && (
|
||||
<div className="px-12 pb-4 bg-muted/20 border-t border-dashed">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-3">
|
||||
{/* Left: AI Reasoning */}
|
||||
<div className="space-y-3">
|
||||
{ai?.reasoning ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">AI Reasoning</p>
|
||||
<div className="rounded-lg bg-background border p-3 text-sm whitespace-pre-wrap leading-relaxed">
|
||||
{ai.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
|
||||
)}
|
||||
|
||||
{result.overrideReason && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">Override Reason</p>
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm">
|
||||
{result.overrideReason}
|
||||
{result.overriddenByUser && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
By {result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
{result.overriddenAt && ` on ${new Date(result.overriddenAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Metrics + Rule Results */}
|
||||
<div className="space-y-3">
|
||||
{ai && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Confidence</p>
|
||||
<p className="text-base font-bold">
|
||||
{ai.confidence != null ? `${(ai.confidence * 100).toFixed(0)}%` : '\u2014'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Quality</p>
|
||||
<p className="text-base font-bold">
|
||||
{ai.qualityScore != null ? `${ai.qualityScore}/10` : '\u2014'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Spam Risk</p>
|
||||
<p className={`text-base font-bold ${ai.spamRisk ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{ai.spamRisk ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.finalOutcome && result.finalOutcome !== result.outcome && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Original AI decision:</span>
|
||||
<OutcomeBadge outcome={result.outcome} overridden={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rule-by-rule results */}
|
||||
{result.ruleResultsJson && Array.isArray(result.ruleResultsJson) && result.ruleResultsJson.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">Rule Results</p>
|
||||
<div className="space-y-1">
|
||||
{(result.ruleResultsJson as any[]).map((rule: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm px-2 py-1 rounded border bg-background">
|
||||
<span className="truncate">{rule.ruleName || `Rule ${i + 1}`}</span>
|
||||
<Badge variant={rule.passed ? 'default' : 'destructive'} className="text-xs shrink-0 ml-2">
|
||||
{rule.passed ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Link
|
||||
href={`/admin/projects/${result.projectId}` as Route}
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View Full Project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
{resultsPage.totalPages > 1 && (
|
||||
{resultsPage && resultsPage.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {resultsPage.page} of {resultsPage.totalPages} ({resultsPage.total} total)
|
||||
|
|
@ -615,9 +790,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No results yet</p>
|
||||
<p className="text-sm font-medium">
|
||||
{searchQuery.trim() ? 'No results match your search' : 'No results yet'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Run the filtering job to screen projects
|
||||
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -625,101 +802,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Detail Dialog */}
|
||||
<Dialog open={!!detailResult} onOpenChange={(open) => !open && setDetailResult(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{detailResult?.project?.title || 'Project'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
AI screening feedback and reasoning
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailResult && (() => {
|
||||
const ai = parseAIData(detailResult.aiScreeningJson)
|
||||
const effectiveOutcome = detailResult.finalOutcome || detailResult.outcome
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<OutcomeBadge outcome={effectiveOutcome} overridden={!!detailResult.finalOutcome && detailResult.finalOutcome !== detailResult.outcome} />
|
||||
{detailResult.finalOutcome && detailResult.finalOutcome !== detailResult.outcome && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Original: <OutcomeBadge outcome={detailResult.outcome} overridden={false} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ai && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">Confidence</p>
|
||||
<p className="text-lg font-bold">
|
||||
{ai.confidence != null ? `${(ai.confidence * 100).toFixed(0)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">Quality</p>
|
||||
<p className="text-lg font-bold">
|
||||
{ai.qualityScore != null ? `${ai.qualityScore}/10` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">Spam Risk</p>
|
||||
<p className={`text-lg font-bold ${ai.spamRisk ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{ai.spamRisk ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ai.reasoning && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">AI Reasoning</p>
|
||||
<div className="rounded-lg bg-muted p-3 text-sm whitespace-pre-wrap">
|
||||
{ai.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailResult.overrideReason && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Override Reason</p>
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm">
|
||||
{detailResult.overrideReason}
|
||||
{detailResult.overriddenByUser && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
By {detailResult.overriddenByUser.name || detailResult.overriddenByUser.email}
|
||||
{detailResult.overriddenAt && ` on ${new Date(detailResult.overriddenAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rule-by-rule results */}
|
||||
{detailResult.ruleResultsJson && Array.isArray(detailResult.ruleResultsJson) && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Rule Results</p>
|
||||
<div className="space-y-1">
|
||||
{(detailResult.ruleResultsJson as any[]).map((rule: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm px-2 py-1.5 rounded border">
|
||||
<span>{rule.ruleName || `Rule ${i + 1}`}</span>
|
||||
<Badge variant={rule.passed ? 'default' : 'destructive'} className="text-xs">
|
||||
{rule.passed ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Single Override Dialog */}
|
||||
{/* Single Override Dialog (with reason) */}
|
||||
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -52,6 +53,8 @@ import {
|
|||
Layers,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
|
|
@ -76,13 +79,17 @@ type ProjectStatesTableProps = {
|
|||
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
||||
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
|
||||
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
||||
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
||||
{ roundId },
|
||||
)
|
||||
|
|
@ -145,9 +152,21 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
})
|
||||
}
|
||||
|
||||
const filtered = projectStates?.filter((ps: any) =>
|
||||
stateFilter === 'ALL' ? true : ps.state === stateFilter
|
||||
) ?? []
|
||||
// Apply state filter first, then search filter
|
||||
const filtered = useMemo(() => {
|
||||
let result = projectStates ?? []
|
||||
if (stateFilter !== 'ALL') {
|
||||
result = result.filter((ps: any) => ps.state === stateFilter)
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
result = result.filter((ps: any) =>
|
||||
(ps.project?.title || '').toLowerCase().includes(q) ||
|
||||
(ps.project?.teamName || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}, [projectStates, stateFilter, searchQuery])
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const ids = filtered.map((ps: any) => ps.projectId)
|
||||
|
|
@ -196,7 +215,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||
Assign projects from the Project Pool to this round to get started.
|
||||
</p>
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<Link href={poolLink}>
|
||||
<Button size="sm" className="mt-4">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Go to Project Pool
|
||||
|
|
@ -210,46 +229,70 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top bar: filters + add button */}
|
||||
{/* Top bar: search + filters + add buttons */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
stateFilter === 'ALL'
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||
}`}
|
||||
>
|
||||
All ({projectStates.length})
|
||||
</button>
|
||||
{PROJECT_STATES.map((state) => {
|
||||
const count = counts[state] || 0
|
||||
if (count === 0) return null
|
||||
const cfg = stateConfig[state]
|
||||
return (
|
||||
<button
|
||||
key={state}
|
||||
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
stateFilter === state
|
||||
? cfg.color + ' border-current'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{cfg.label} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
stateFilter === 'ALL'
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||
}`}
|
||||
>
|
||||
All ({projectStates.length})
|
||||
</button>
|
||||
{PROJECT_STATES.map((state) => {
|
||||
const count = counts[state] || 0
|
||||
if (count === 0) return null
|
||||
const cfg = stateConfig[state]
|
||||
return (
|
||||
<button
|
||||
key={state}
|
||||
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
stateFilter === state
|
||||
? cfg.color + ' border-current'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{cfg.label} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<Button size="sm" variant="outline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add from Pool
|
||||
Quick Add
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={poolLink}>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add from Pool
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{searchQuery.trim() && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {filtered.length} of {projectStates.length} projects matching "{searchQuery}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
|
||||
|
|
@ -316,7 +359,12 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{ps.project?.title || 'Unknown'}</p>
|
||||
<Link
|
||||
href={`/admin/projects/${ps.projectId}` as Route}
|
||||
className="font-medium truncate block hover:underline text-foreground"
|
||||
>
|
||||
{ps.project?.title || 'Unknown'}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -341,6 +389,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/projects/${ps.projectId}` as Route}>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
View Project
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
|
||||
const sCfg = stateConfig[state]
|
||||
return (
|
||||
|
|
@ -368,8 +423,25 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && searchQuery.trim() && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No projects match "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Add Dialog */}
|
||||
<QuickAddDialog
|
||||
open={quickAddOpen}
|
||||
onOpenChange={setQuickAddOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
onAssigned={() => {
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Single Remove Confirmation */}
|
||||
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
||||
<AlertDialogContent>
|
||||
|
|
@ -466,3 +538,133 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Add Dialog — inline search + assign projects to this round without leaving the page.
|
||||
*/
|
||||
function QuickAddDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
competitionId,
|
||||
onAssigned,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
competitionId: string
|
||||
onAssigned: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [addingIds, setAddingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the competition to find programId
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId },
|
||||
{ enabled: open && !!competitionId },
|
||||
)
|
||||
|
||||
const programId = (competition as any)?.programId || ''
|
||||
|
||||
const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId,
|
||||
excludeRoundId: roundId,
|
||||
search: search.trim() || undefined,
|
||||
perPage: 10,
|
||||
},
|
||||
{ enabled: open && !!programId },
|
||||
)
|
||||
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Added to round`)
|
||||
onAssigned()
|
||||
// Remove from addingIds
|
||||
setAddingIds(new Set())
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleQuickAssign = (projectId: string) => {
|
||||
setAddingIds((prev) => new Set(prev).add(projectId))
|
||||
assignMutation.mutate({ projectIds: [projectId], roundId })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick Add Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search and assign projects to this round without leaving the page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by project title or team..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && poolResults?.projects.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{poolResults?.projects.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center justify-between gap-3 p-2.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
{project.competitionCategory && (
|
||||
<> · {project.competitionCategory}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
disabled={assignMutation.isPending && addingIds.has(project.id)}
|
||||
onClick={() => handleQuickAssign(project.id)}
|
||||
>
|
||||
{assignMutation.isPending && addingIds.has(project.id) ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{poolResults && poolResults.total > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Showing 10 of {poolResults.total} — refine your search for more specific results
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,15 +42,16 @@ export const programRouter = router({
|
|||
: undefined,
|
||||
})
|
||||
|
||||
// Return programs with rounds flattened
|
||||
// Return programs with rounds flattened, preserving competitionId
|
||||
return programs.map((p) => {
|
||||
const allRounds = (p as any).competitions?.flatMap((c: any) => c.rounds || []) || []
|
||||
const allRounds = (p as any).competitions?.flatMap((c: any) =>
|
||||
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
|
||||
) || []
|
||||
return {
|
||||
...p,
|
||||
// Provide `stages` as alias for backward compatibility
|
||||
stages: allRounds.map((round: any) => ({
|
||||
...round,
|
||||
// Backward-compatible _count shape
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
|
|
@ -60,6 +61,7 @@ export const programRouter = router({
|
|||
rounds: allRounds.map((round: any) => ({
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
competitionId: round.competitionId,
|
||||
status: round.status,
|
||||
votingEndAt: round.windowCloseAt,
|
||||
_count: {
|
||||
|
|
@ -95,8 +97,10 @@ export const programRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Flatten rounds from all competitions
|
||||
const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || []
|
||||
// Flatten rounds from all competitions, preserving competitionId
|
||||
const allRounds = (program as any).competitions?.flatMap((c: any) =>
|
||||
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
|
||||
) || []
|
||||
const rounds = allRounds.map((round: any) => ({
|
||||
...round,
|
||||
_count: {
|
||||
|
|
|
|||
|
|
@ -2,38 +2,120 @@ import { z } from 'zod'
|
|||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { sendAnnouncementEmail } from '@/lib/email'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Send round-entry notification emails to project team members.
|
||||
* Fire-and-forget: errors are logged but never block the assignment.
|
||||
*/
|
||||
async function sendRoundEntryEmails(
|
||||
prisma: PrismaClient,
|
||||
projectIds: string[],
|
||||
roundName: string,
|
||||
) {
|
||||
try {
|
||||
// Fetch projects with team members' user emails + fallback submittedByEmail
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: {
|
||||
user: { select: { email: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const emailPromises: Promise<void>[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
// Collect unique emails for this project
|
||||
const recipients = new Map<string, string | null>()
|
||||
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no team members have emails, use submittedByEmail
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
emailPromises.push(
|
||||
sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
`Your project has entered: ${roundName}`,
|
||||
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
|
||||
'View Your Dashboard',
|
||||
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
|
||||
).catch((err) => {
|
||||
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(emailPromises)
|
||||
} catch (err) {
|
||||
console.error('[round-entry-email] Failed to send round entry emails:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project Pool Router
|
||||
*
|
||||
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
|
||||
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
|
||||
* Manages the project pool for assigning projects to competition rounds.
|
||||
* Shows all projects by default, with optional filtering for unassigned-only
|
||||
* or projects not yet in a specific round.
|
||||
*/
|
||||
export const projectPoolRouter = router({
|
||||
/**
|
||||
* List unassigned projects with filtering and pagination
|
||||
* Projects not assigned to any round
|
||||
* List projects in the pool with filtering and pagination.
|
||||
* By default shows ALL projects. Use filters to narrow:
|
||||
* - unassignedOnly: true → only projects not in any round
|
||||
* - excludeRoundId: "..." → only projects not already in that round
|
||||
*/
|
||||
listUnassigned: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(), // Required - must specify which program
|
||||
programId: z.string(),
|
||||
competitionCategory: z
|
||||
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
||||
.optional(),
|
||||
search: z.string().optional(), // Search in title, teamName, description
|
||||
search: z.string().optional(),
|
||||
unassignedOnly: z.boolean().optional().default(false),
|
||||
excludeRoundId: z.string().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(200).default(20),
|
||||
perPage: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { programId, competitionCategory, search, page, perPage } = input
|
||||
const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
projectRoundStates: { none: {} }, // Only unassigned projects (not in any round)
|
||||
}
|
||||
|
||||
// Optional: only show projects not in any round
|
||||
if (unassignedOnly) {
|
||||
where.projectRoundStates = { none: {} }
|
||||
}
|
||||
|
||||
// Optional: exclude projects already in a specific round
|
||||
if (excludeRoundId && !unassignedOnly) {
|
||||
where.projectRoundStates = {
|
||||
none: { roundId: excludeRoundId },
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by competition category
|
||||
|
|
@ -77,6 +159,22 @@ export const projectPoolRouter = router({
|
|||
teamMembers: true,
|
||||
},
|
||||
},
|
||||
projectRoundStates: {
|
||||
select: {
|
||||
roundId: true,
|
||||
state: true,
|
||||
round: {
|
||||
select: {
|
||||
name: true,
|
||||
roundType: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
round: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
|
|
@ -93,21 +191,11 @@ export const projectPoolRouter = router({
|
|||
|
||||
/**
|
||||
* Bulk assign projects to a round
|
||||
*
|
||||
* Validates that:
|
||||
* - All projects exist
|
||||
* - Round exists
|
||||
*
|
||||
* Creates:
|
||||
* - RoundAssignment entries for each project
|
||||
* - Project.status updated to 'ASSIGNED'
|
||||
* - ProjectStatusHistory records for each project
|
||||
* - Audit log
|
||||
*/
|
||||
assignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
|
||||
projectIds: z.array(z.string()).min(1).max(200),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
|
|
@ -136,10 +224,10 @@ export const projectPoolRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Verify round exists
|
||||
// Verify round exists and get config
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
})
|
||||
|
||||
// Step 2: Perform bulk assignment in a transaction
|
||||
|
|
@ -192,6 +280,12 @@ export const projectPoolRouter = router({
|
|||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send round-entry notification emails if enabled (fire-and-forget)
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
if (config.notifyOnEntry) {
|
||||
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
|
|
@ -200,7 +294,8 @@ export const projectPoolRouter = router({
|
|||
}),
|
||||
|
||||
/**
|
||||
* Assign ALL unassigned projects in a program to a round (server-side, no ID limit)
|
||||
* Assign ALL matching projects in a program to a round (server-side, no ID limit).
|
||||
* Skips projects already in the target round.
|
||||
*/
|
||||
assignAllToRound: adminProcedure
|
||||
.input(
|
||||
|
|
@ -208,22 +303,33 @@ export const projectPoolRouter = router({
|
|||
programId: z.string(),
|
||||
roundId: z.string(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
unassignedOnly: z.boolean().optional().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { programId, roundId, competitionCategory } = input
|
||||
const { programId, roundId, competitionCategory, unassignedOnly } = input
|
||||
|
||||
// Verify round exists
|
||||
await ctx.prisma.round.findUniqueOrThrow({
|
||||
// Verify round exists and get config
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
})
|
||||
|
||||
// Find all unassigned projects
|
||||
// Find projects to assign
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
projectRoundStates: { none: {} },
|
||||
}
|
||||
|
||||
if (unassignedOnly) {
|
||||
// Only projects not in any round
|
||||
where.projectRoundStates = { none: {} }
|
||||
} else {
|
||||
// All projects not already in the target round
|
||||
where.projectRoundStates = {
|
||||
none: { roundId },
|
||||
}
|
||||
}
|
||||
|
||||
if (competitionCategory) {
|
||||
where.competitionCategory = competitionCategory
|
||||
}
|
||||
|
|
@ -271,12 +377,19 @@ export const projectPoolRouter = router({
|
|||
roundId,
|
||||
programId,
|
||||
competitionCategory: competitionCategory || 'ALL',
|
||||
unassignedOnly,
|
||||
projectCount: projectIds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send round-entry notification emails if enabled (fire-and-forget)
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
if (config.notifyOnEntry) {
|
||||
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
|
||||
}
|
||||
|
||||
return { success: true, assignedCount: result.count, roundId }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue