Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
- Pool page: auto-select program from edition context, URL params for roundId/competitionId deep-linking, unassigned toggle, round badges column - Competition detail: rich round cards with project counts, dates, jury info, status badges replacing flat list - Round detail: readiness checklist, embedded assignment dashboard, file requirements in config tab, notifyOnEntry toggle - ProjectStatesTable: search input, project links, quick-add dialog, pool links with context params - FilteringDashboard: expandable rows with AI reasoning inline, quick override buttons, search, clickable stats - Backend: notifyOnEntry in round configJson triggers announcement emails on project assignment via existing email infra Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user