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

@@ -13,7 +13,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
@@ -77,6 +76,7 @@ import {
Trash2,
ArrowRight,
RotateCcw,
ListChecks,
} from 'lucide-react'
import {
Tooltip,
@@ -116,7 +116,11 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -265,6 +269,20 @@ export default function RoundDetailPage() {
}
}, [juryWorkload])
// Auto-select finalization tab when round is closed and not yet finalized
const finalizationAutoSelected = useRef(false)
useEffect(() => {
if (
round &&
!finalizationAutoSelected.current &&
round.status === 'ROUND_CLOSED' &&
!round.finalizedAt
) {
finalizationAutoSelected.current = true
setActiveTab('finalization')
}
}, [round])
// ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
@@ -291,12 +309,12 @@ export default function RoundDetailPage() {
const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
if (closeAndAdvance) {
setCloseAndAdvance(false)
// Small delay to let cache invalidation complete before opening dialog
setTimeout(() => setAdvanceDialogOpen(true), 300)
}
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round closed — use the Finalization tab to review and advance projects')
setCloseAndAdvance(false)
// Auto-switch to finalization tab
setActiveTab('finalization')
},
onError: (err) => {
setCloseAndAdvance(false)
@@ -308,6 +326,7 @@ export default function RoundDetailPage() {
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
const msg = data.pausedRounds?.length
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
: 'Round reopened'
@@ -319,6 +338,8 @@ export default function RoundDetailPage() {
const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round archived')
},
onError: (err) => toast.error(err.message),
@@ -495,6 +516,7 @@ export default function RoundDetailPage() {
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const showFinalization = ['ROUND_CLOSED', 'ROUND_ARCHIVED'].includes(round?.status ?? '')
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
@@ -846,6 +868,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
@@ -1166,49 +1189,54 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (always visible when projects exist) */}
{/* Advance projects — closed rounds go to Finalization tab, active rounds use old dialog */}
{projectCount > 0 && (
<button
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
onClick={() => {
if (showFinalization) {
setActiveTab('finalization')
} else if (isSimpleAdvance || passedCount > 0) {
setAdvanceDialogOpen(true)
} else {
toast.info('Mark projects as "Passed" first in the Projects tab')
}
}}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
(isSimpleAdvance || passedCount > 0)
(showFinalization || isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (showFinalization || isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
{isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
{showFinalization
? 'Review and confirm advancement in the Finalization tab'
: isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
{/* Close & Finalize (active rounds — closes round and opens finalization tab) */}
{status === 'ROUND_ACTIVE' && projectCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
onClick={() => closeMutation.mutate({ roundId })}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-sm font-medium">Close & Finalize</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
Close this round and review outcomes in the Finalization tab
</p>
</div>
</button>
@@ -1289,12 +1317,24 @@ export default function RoundDetailPage() {
</div>
</div>
)}
{/* Notifications Group */}
{projectCount > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Notifications</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} />
</div>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
{/* Advance Projects Dialog — only for active rounds; closed rounds use Finalization tab */}
{!showFinalization && <AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
@@ -1309,7 +1349,7 @@ export default function RoundDetailPage() {
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
/>}
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
@@ -1435,10 +1475,17 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}} />
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}
@@ -2059,6 +2106,13 @@ export default function RoundDetailPage() {
</TabsContent>
)}
{/* ═══════════ FINALIZATION TAB ═══════════ */}
{showFinalization && (
<TabsContent value="finalization" className="space-y-4">
<FinalizationTab roundId={roundId} roundStatus={round.status} />
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
@@ -2123,42 +2177,6 @@ export default function RoundDetailPage() {
/>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md">
<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>
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
{(isEvaluation || isFiltering) && (
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label>