feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -76,13 +76,23 @@ const stateConfig: Record<ProjectState, { label: string; color: string; icon: Re
WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
}
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
type ProjectStatesTableProps = {
competitionId: string
roundId: string
roundStatus?: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
onAssignProjects?: (projectIds: string[]) => void
}
export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: ProjectStatesTableProps) {
export function ProjectStatesTable({ competitionId, roundId, roundStatus, competitionRounds, currentSortOrder, onAssignProjects }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('')
@@ -226,32 +236,65 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
)
}
const hasEarlierRounds = competitionRounds && currentSortOrder != null &&
competitionRounds.some((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
if (!projectStates || projectStates.length === 0) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
<>
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Projects in This Round</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool or import from an earlier round to get started.
</p>
<div className="flex items-center gap-2 mt-4">
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
</Button>
</Link>
{hasEarlierRounds && (
<Button size="sm" onClick={() => { setAddProjectOpen(true) }}>
<ArrowRight className="h-4 w-4 mr-1.5" />
Import from Earlier Round
</Button>
)}
</div>
</div>
<p className="text-sm font-medium">No Projects in This Round</p>
<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={poolLink}>
<Button size="sm" className="mt-4">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
</Button>
</Link>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
defaultTab={hasEarlierRounds ? 'round' : 'create'}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
</>
)
}
return (
<div className="space-y-4">
{/* Finalization hint for closed rounds */}
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
<span className="text-blue-700 dark:text-blue-300">
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
</span>
</div>
)}
{/* Top bar: search + filters + add buttons */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -496,12 +539,14 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
}}
/>
{/* Add Project Dialog (Create New + From Pool) */}
{/* Add Project Dialog (Create New + From Pool + From Round) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
@@ -744,15 +789,21 @@ function AddProjectDialog({
onOpenChange,
roundId,
competitionId,
competitionRounds,
currentSortOrder,
defaultTab,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
defaultTab?: 'create' | 'pool' | 'round'
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
const [activeTab, setActiveTab] = useState<'create' | 'pool' | 'round'>(defaultTab ?? 'create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
@@ -765,6 +816,12 @@ function AddProjectDialog({
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
// ── From Round tab state ──
const [sourceRoundId, setSourceRoundId] = useState('')
const [roundStateFilter, setRoundStateFilter] = useState<string[]>([])
const [roundSearch, setRoundSearch] = useState('')
const [selectedRoundIds, setSelectedRoundIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
@@ -774,6 +831,34 @@ function AddProjectDialog({
)
const programId = (competition as any)?.programId || ''
// Earlier rounds available for import
const earlierRounds = useMemo(() => {
if (!competitionRounds || currentSortOrder == null) return []
return competitionRounds
.filter((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
}, [competitionRounds, currentSortOrder])
// From Round query
const { data: roundProjects, isLoading: roundLoading } = trpc.projectPool.getProjectsInRound.useQuery(
{
roundId: sourceRoundId,
states: roundStateFilter.length > 0 ? roundStateFilter : undefined,
search: roundSearch.trim() || undefined,
},
{ enabled: open && activeTab === 'round' && !!sourceRoundId },
)
// Import mutation
const importMutation = trpc.projectPool.importFromRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.imported} project(s) imported${data.skipped > 0 ? `, ${data.skipped} already in round` : ''}`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
@@ -815,6 +900,10 @@ function AddProjectDialog({
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
setSourceRoundId('')
setRoundStateFilter([])
setRoundSearch('')
setSelectedRoundIds(new Set())
onOpenChange(false)
}
@@ -838,6 +927,24 @@ function AddProjectDialog({
})
}
const handleImportFromRound = () => {
if (selectedRoundIds.size === 0 || !sourceRoundId) return
importMutation.mutate({
sourceRoundId,
targetRoundId: roundId,
projectIds: Array.from(selectedRoundIds),
})
}
const toggleRoundProject = (id: string) => {
setSelectedRoundIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
@@ -847,7 +954,7 @@ function AddProjectDialog({
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
const isMutating = createMutation.isPending || assignMutation.isPending || importMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
@@ -862,10 +969,13 @@ function AddProjectDialog({
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool' | 'round')}>
<TabsList className={`grid w-full ${earlierRounds.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}>
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
{earlierRounds.length > 0 && (
<TabsTrigger value="round">From Round</TabsTrigger>
)}
</TabsList>
{/* ── Create New Tab ── */}
@@ -1012,6 +1122,158 @@ function AddProjectDialog({
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Round Tab ── */}
{earlierRounds.length > 0 && (
<TabsContent value="round" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Source Round</Label>
<Select value={sourceRoundId} onValueChange={(v) => {
setSourceRoundId(v)
setSelectedRoundIds(new Set())
}}>
<SelectTrigger>
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{earlierRounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r._count.projectRoundStates} projects)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{sourceRoundId && (
<>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<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 title or team..."
value={roundSearch}
onChange={(e) => setRoundSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{['PASSED', 'COMPLETED', 'PENDING', 'IN_PROGRESS', 'REJECTED'].map((state) => {
const isActive = roundStateFilter.includes(state)
return (
<button
key={state}
onClick={() => {
setRoundStateFilter(prev =>
isActive ? prev.filter(s => s !== state) : [...prev, state]
)
setSelectedRoundIds(new Set())
}}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{state.charAt(0) + state.slice(1).toLowerCase().replace('_', ' ')}
</button>
)
})}
</div>
<ScrollArea className="h-[280px] rounded-md border">
<div className="p-2 space-y-0.5">
{roundLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!roundLoading && roundProjects?.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{roundSearch.trim() ? `No projects found matching "${roundSearch}"` : 'No projects in this round'}
</p>
)}
{roundProjects && roundProjects.length > 0 && (
<label
className="flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer hover:bg-muted/50 border-b mb-1"
>
<Checkbox
checked={roundProjects.length > 0 && roundProjects.every(p => selectedRoundIds.has(p.id))}
onCheckedChange={() => {
const allIds = roundProjects.map(p => p.id)
const allSelected = allIds.every(id => selectedRoundIds.has(id))
if (allSelected) {
setSelectedRoundIds(new Set())
} else {
setSelectedRoundIds(new Set(allIds))
}
}}
/>
<span className="text-xs font-medium text-muted-foreground">
Select all ({roundProjects.length})
</span>
</label>
)}
{roundProjects?.map((project) => {
const isSelected = selectedRoundIds.has(project.id)
return (
<label
key={project.id}
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRoundProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2 shrink-0">
<Badge variant="outline" className="text-[10px]">
{project.state.charAt(0) + project.state.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</div>
</label>
)
})}
</div>
</ScrollArea>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleImportFromRound}
disabled={selectedRoundIds.size === 0 || isMutating}
>
{importMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedRoundIds.size <= 1
? 'Import to Round'
: `Import ${selectedRoundIds.size} Projects`
}
</Button>
</DialogFooter>
</TabsContent>
)}
</Tabs>
</DialogContent>
</Dialog>