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,14 +13,14 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { WithdrawButton } from '@/components/applicant/withdraw-button'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
Clock,
CheckCircle,
Users,
Crown,
@@ -43,7 +43,7 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
}
export default function ApplicantDashboardPage() {
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
@@ -112,7 +112,6 @@ export default function ApplicantDashboardPage() {
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
@@ -121,32 +120,34 @@ export default function ApplicantDashboardPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{data.logoUrl ? (
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FileText className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div>
</div>
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
<WithdrawButton projectId={project.id} />
)}
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -205,16 +206,11 @@ export default function ApplicantDashboardPage() {
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
{project.submittedAt && (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
@@ -310,23 +306,25 @@ export default function ApplicantDashboardPage() {
<AnimatedCard index={3}>
<Card>
<CardHeader>
<CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
{hasPassedIntake ? (
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
<CompetitionTimelineSidebar />
</CardContent>
</Card>
</AnimatedCard>
{/* Mentoring Request Card — show when there's an active MENTORING round */}
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard
projectId={project.id}
roundId={mentoringRound.id}
roundName={mentoringRound.name}
/>
</AnimatedCard>
))}
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>