Fix first-login error, awards performance, filter animation, cache invalidation, and query fixes

- Guard onboarding tRPC queries with session hydration check (fixes UNAUTHORIZED on first login)
- Defer expensive queries on awards page until UI elements are opened (dialog/tab)
- Fix perPage: 500 exceeding backend Zod max of 100 on awards eligibility query
- Add smooth open/close animation to project filters collapsible bar
- Fix seeded user status from ACTIVE to INVITED in seed-candidatures.ts
- Add router.refresh() cache invalidation across ~22 admin forms
- Fix geographic analytics query to use programId instead of round.programId
- Fix dashboard queries to scope by programId correctly
- Fix project.listPool and round queries for projects outside round context
- Add rounds page useEffect for state sync after mutations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 21:21:54 +01:00
parent 573785e440
commit 5cae78fe0c
26 changed files with 830 additions and 341 deletions

View File

@@ -69,6 +69,9 @@ import {
FolderOpen,
X,
AlertTriangle,
ArrowRightCircle,
LayoutGrid,
LayoutList,
} from 'lucide-react'
import {
Select,
@@ -180,6 +183,7 @@ export default function ProjectsPage() {
const [page, setPage] = useState(parsed.page)
const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
// Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@@ -373,6 +377,10 @@ export default function ProjectsPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
@@ -387,6 +395,31 @@ export default function ProjectsPage() {
},
})
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
setSelectedIds(new Set())
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const bulkDeleteProjects = trpc.project.bulkDelete.useMutation({
onSuccess: (result) => {
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
setSelectedIds(new Set())
setBulkDeleteConfirmOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to delete projects')
},
})
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
@@ -481,12 +514,6 @@ export default function ProjectsPage() {
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/admin/projects/pool">
<Layers className="mr-2 h-4 w-4" />
Project Pool
</Link>
</Button>
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Tags
@@ -540,34 +567,56 @@ export default function ProjectsPage() {
onChange={handleFiltersChange}
/>
{/* Stats Summary */}
{/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(
data.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
key={status}
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
))}
{data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})
</span>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(
data.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
key={status}
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
))}
{data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<LayoutList className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'card' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('card')}
aria-label="Card view"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
</div>
)}
@@ -623,230 +672,381 @@ export default function ProjectsPage() {
</Card>
) : data ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<TableCell className="relative z-10">
{/* Table View */}
{viewMode === 'table' ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableCell>
)}
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Round</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<div className="flex items-center gap-2">
{project.round ? (
<p>{project.round.name}</p>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
</Badge>
)}
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
</p>
</div>
</TableCell>
<TableCell>{project._count?.files ?? 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{project.createdAt
? new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: '-'}
</TableCell>
<TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} />
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setProjectToAssign({ id: project.id, title: project.title })
setAssignDialogOpen(true)
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
className="shrink-0"
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</TableCell>
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</p>
</div>
</Link>
</TableCell>
<TableCell>
{project.competitionCategory ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div>
<div className="flex items-center gap-2">
{project.round ? (
<p>{project.round.name}</p>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
</p>
</div>
</TableCell>
<TableCell>
{project.tags && project.tags.length > 0 ? (
<div className="flex flex-wrap gap-1 max-w-[200px]">
{project.tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[10px] px-1.5 py-0 font-normal"
>
{tag}
</Badge>
))}
{project.tags.length > 3 && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 font-normal"
>
+{project.tags.length - 3}
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} />
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setProjectToAssign({ id: project.id, title: project.title })
setAssignDialogOpen(true)
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</Card>
{/* Mobile card view (table mode fallback) */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2">
<span>{project.round?.name ?? '-'}</span>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Files</span>
<span>{project._count?.files ?? 0}</span>
</div>
{project.createdAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
)}
</CardContent>
</Card>
</Link>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round?.name ?? 'Unassigned'}</span>
</div>
{project.competitionCategory && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Category</span>
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{project.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
{tag}
</Badge>
))}
{project.tags.length > 4 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+{project.tags.length - 4}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</Link>
</div>
))}
</div>
))}
</div>
</>
) : (
/* Card View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<div key={project.id} className="relative">
<div className="absolute left-3 top-3 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className={`transition-colors hover:bg-muted/50 h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 relative z-10" onClick={(e) => e.preventDefault()}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setProjectToAssign({ id: project.id, title: project.title })
setAssignDialogOpen(true)
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="mt-0.5">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="flex items-center justify-between gap-2">
<StatusBadge status={project.status ?? 'SUBMITTED'} />
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span className="text-right">
{project.round ? (
<>{project.round.name}</>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
</Badge>
)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Jurors</span>
<span>{project._count.assignments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Files</span>
<span>{project._count?.files ?? 0}</span>
</div>
{project.createdAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span className="text-xs">{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1 border-t">
{project.tags.slice(0, 5).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
{tag}
</Badge>
))}
{project.tags.length > 5 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+{project.tags.length - 5}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</Link>
</div>
)
})}
</div>
)}
{/* Pagination */}
<Pagination
@@ -861,48 +1061,72 @@ export default function ProjectsPage() {
) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 w-[95vw] max-w-xl">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2 flex-1">
{/* Assign to Round */}
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
variant="outline"
onClick={() => setBulkAssignDialogOpen(true)}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Round
</Button>
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
</>
)}
{/* Delete */}
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
variant="destructive"
onClick={() => setBulkDeleteConfirmOpen(true)}
>
<X className="mr-1 h-4 w-4" />
Clear
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
className="shrink-0"
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</CardContent>
</Card>
</div>
@@ -1026,6 +1250,98 @@ export default function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Bulk Assign to Round Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignRoundId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
(p.rounds || []).map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{p.name} {p.year} - {r.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setBulkAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
projectIds: Array.from(selectedIds),
roundId: bulkAssignRoundId,
})
}
}}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
>
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={bulkDeleteConfirmOpen} onOpenChange={setBulkDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
Are you sure you want to permanently delete{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>?
This will remove all associated files, assignments, and evaluations.
</p>
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
This action cannot be undone. All project data will be permanently lost.
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleteProjects.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
bulkDeleteProjects.mutate({ ids: Array.from(selectedIds) })
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={bulkDeleteProjects.isPending}
>
{bulkDeleteProjects.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Tagging Dialog */}
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<DialogContent className="sm:max-w-lg">

View File

@@ -55,8 +55,11 @@ export default function ProjectPoolPage() {
{ enabled: !!selectedProgramId }
)
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.round.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)

View File

@@ -26,6 +26,7 @@ import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
const ALL_STATUSES = [
'SUBMITTED',
@@ -140,14 +141,14 @@ export function ProjectFiltersBar({
</CardTitle>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
<CardContent className="space-y-4 pt-0">
{/* Status toggles */}
<div className="space-y-2">
@@ -255,11 +256,21 @@ export function ProjectFiltersBar({
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
{filterOptions?.countries
.map((c) => ({
code: c,
name: getCountryName(c),
flag: getCountryFlag(c),
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => (
<SelectItem key={c.code} value={c.code}>
<span className="flex items-center gap-2">
<span>{c.flag}</span>
<span>{c.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>