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}>

View File

@@ -48,7 +48,10 @@ import {
} from '@/components/ui/alert-dialog'
import { CountrySelect } from '@/components/ui/country-select'
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { UserAvatar } from '@/components/shared/user-avatar'
import {
FolderOpen,
Users,
UserPlus,
Crown,
@@ -59,7 +62,14 @@ import {
CheckCircle,
Clock,
FileText,
ImageIcon,
MapPin,
Waves,
GraduationCap,
Heart,
Calendar,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -86,7 +96,21 @@ const statusLabels: Record<string, { label: string; icon: React.ComponentType<{
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function ApplicantTeamPage() {
const OCEAN_ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export default function ApplicantProjectPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false)
@@ -96,13 +120,20 @@ export default function ApplicantTeamPage() {
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const project = dashboardData?.project
const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => {
if (result.requiresAccountSetup) {
@@ -180,18 +211,18 @@ export default function ApplicantTeamPage() {
)
}
if (!projectId) {
if (!projectId || !project) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
<h1 className="text-2xl font-semibold tracking-tight">Project</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to manage your team.
Submit a project first to view details.
</p>
</CardContent>
</Card>
@@ -210,159 +241,297 @@ export default function ApplicantTeamPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<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">
{logoUrl ? (
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<p className="text-muted-foreground">
Manage your project team
{project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
</p>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Project Details Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Project Information
</CardTitle>
{isIntakeOpen && (
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
Editable during intake
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{/* Description */}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Mentor info */}
{project.mentorAssignment?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
</p>
</div>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
<div className="flex flex-wrap gap-1">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Logo */}
{isTeamLead && projectId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Project Logo
</CardTitle>
<CardDescription>
Click the image to upload or change your project logo.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<ProjectLogoUpload
projectId={projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</CardContent>
</Card>
)}
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team ({teamData?.teamMembers.length || 0} members)
</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
@@ -374,13 +543,16 @@ export default function ApplicantTeamPage() {
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
<div className="relative">
<UserAvatar
user={member.user}
avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
size="md"
/>
{member.role === 'LEAD' && (
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
<Crown className="h-2.5 w-2.5 text-yellow-600" />
</div>
)}
</div>
<div>
@@ -455,25 +627,6 @@ export default function ApplicantTeamPage() {
)}
</CardContent>
</Card>
{/* Team Documents - visible via applicant documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}