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

@@ -0,0 +1,82 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { UserPlus, Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
interface BulkInviteButtonProps {
roundId: string
}
export function BulkInviteButton({ roundId }: BulkInviteButtonProps) {
const preview = trpc.round.getBulkInvitePreview.useQuery({ roundId })
const inviteMutation = trpc.round.bulkInviteTeamMembers.useMutation({
onSuccess: (data) => {
toast.success(
`Invited ${data.invited} team member${data.invited !== 1 ? 's' : ''}${data.skipped ? ` (${data.skipped} already active/invited)` : ''}`
)
void preview.refetch()
},
onError: (err) => toast.error(err.message),
})
const uninvited = preview.data?.uninvitedCount ?? 0
if (uninvited === 0 && !preview.isLoading) return null
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<UserPlus className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Invite Team Members</p>
<p className="text-xs text-muted-foreground mt-0.5">
{preview.isLoading
? 'Checking...'
: `${uninvited} team member${uninvited !== 1 ? 's' : ''} need invitations`}
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Invite team members?</AlertDialogTitle>
<AlertDialogDescription>
This will send invitation emails to {uninvited} team member
{uninvited !== 1 ? 's' : ''} who haven&apos;t been invited yet.
{preview.data?.alreadyInvitedCount
? ` (${preview.data.alreadyInvitedCount} already invited)`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => inviteMutation.mutate({ roundId })}
disabled={inviteMutation.isPending}
>
{inviteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
`Send ${uninvited} Invitation${uninvited !== 1 ? 's' : ''}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Loader2, Mail, RefreshCw } from 'lucide-react'
interface EmailPreviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
recipientCount: number
previewHtml: string | undefined
isPreviewLoading: boolean
onSend: (customMessage?: string) => void
isSending: boolean
showCustomMessage?: boolean
onRefreshPreview?: (customMessage?: string) => void
}
export function EmailPreviewDialog({
open,
onOpenChange,
title,
description,
recipientCount,
previewHtml,
isPreviewLoading,
onSend,
isSending,
showCustomMessage = true,
onRefreshPreview,
}: EmailPreviewDialogProps) {
const [customMessage, setCustomMessage] = useState('')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden grid grid-cols-1 md:grid-cols-2 gap-4 min-h-0">
{/* Left: Custom message */}
{showCustomMessage && (
<div className="space-y-3">
<Label>Custom Message (optional)</Label>
<Textarea
placeholder="Add a personal message to include in the email..."
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
className="min-h-[200px] resize-none"
/>
{onRefreshPreview && (
<Button
variant="outline"
size="sm"
onClick={() => onRefreshPreview(customMessage || undefined)}
disabled={isPreviewLoading}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Refresh Preview
</Button>
)}
</div>
)}
{/* Right: Email preview */}
<div className="border rounded-lg overflow-hidden bg-gray-50">
<div className="px-3 py-1.5 bg-gray-100 border-b text-xs text-muted-foreground font-medium">
Email Preview
</div>
{isPreviewLoading ? (
<div className="flex items-center justify-center h-[300px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewHtml ? (
<iframe
srcDoc={previewHtml}
sandbox="allow-same-origin"
className="w-full h-[400px] border-0"
title="Email Preview"
/>
) : (
<div className="flex items-center justify-center h-[300px] text-sm text-muted-foreground">
No preview available
</div>
)}
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
Cancel
</Button>
<Button
onClick={() => onSend(customMessage || undefined)}
disabled={isSending || recipientCount === 0}
>
{isSending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send to {recipientCount} recipient{recipientCount !== 1 ? 's' : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Clock,
CheckCircle2,
AlertTriangle,
ArrowRight,
Loader2,
Search,
ChevronDown,
ChevronRight,
Mail,
Send,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { projectStateConfig } from '@/lib/round-config'
// ── Types ──────────────────────────────────────────────────────────────────
interface FinalizationTabProps {
roundId: string
roundStatus: string
}
type ProposedOutcome = 'PASSED' | 'REJECTED'
const stateColors: Record<string, string> = Object.fromEntries(
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
)
const stateLabelColors: Record<string, string> = {
PENDING: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-indigo-100 text-indigo-700',
PASSED: 'bg-green-100 text-green-700',
REJECTED: 'bg-red-100 text-red-700',
WITHDRAWN: 'bg-yellow-100 text-yellow-700',
}
// ── Main Component ─────────────────────────────────────────────────────────
export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) {
const utils = trpc.useUtils()
const { data: summary, isLoading } = trpc.roundEngine.getFinalizationSummary.useQuery(
{ roundId },
)
const [search, setSearch] = useState('')
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
const [advancementMessage, setAdvancementMessage] = useState('')
const [rejectionMessage, setRejectionMessage] = useState('')
// Mutations
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
onSuccess: () => utils.roundEngine.getFinalizationSummary.invalidate({ roundId }),
})
const batchUpdate = trpc.roundEngine.batchUpdateProposedOutcomes.useMutation({
onSuccess: () => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
setSelectedIds(new Set())
toast.success('Proposed outcomes updated')
},
})
const confirmMutation = trpc.roundEngine.confirmFinalization.useMutation({
onSuccess: (data) => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success(
`Finalized: ${data.advanced} advanced, ${data.rejected} rejected, ${data.emailsSent} emails sent`,
)
},
onError: (err) => toast.error(err.message),
})
const endGraceMutation = trpc.roundEngine.endGracePeriod.useMutation({
onSuccess: () => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Grace period ended, projects processed')
},
onError: (err) => toast.error(err.message),
})
const processProjectsMutation = trpc.roundEngine.processRoundProjects.useMutation({
onSuccess: (data) => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success(`Processed ${data.processed} projects — review proposed outcomes below`)
},
onError: (err) => toast.error(err.message),
})
// Filtered projects
const filteredProjects = useMemo(() => {
if (!summary) return []
return summary.projects.filter((p) => {
const matchesSearch =
!search ||
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.teamName?.toLowerCase().includes(search.toLowerCase()) ||
p.country?.toLowerCase().includes(search.toLowerCase())
const matchesFilter =
filterOutcome === 'all' ||
(filterOutcome === 'none' && !p.proposedOutcome) ||
p.proposedOutcome === filterOutcome
return matchesSearch && matchesFilter
})
}, [summary, search, filterOutcome])
// Counts
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
const rejectedCount = summary?.projects.filter((p) => p.proposedOutcome === 'REJECTED').length ?? 0
const undecidedCount = summary?.projects.filter((p) => !p.proposedOutcome).length ?? 0
// Select all toggle
const allSelected = filteredProjects.length > 0 && filteredProjects.every((p) => selectedIds.has(p.id))
const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredProjects.map((p) => p.id)))
}
}
// Bulk set outcome
const handleBulkSetOutcome = (outcome: ProposedOutcome) => {
const outcomes: Record<string, ProposedOutcome> = {}
for (const id of selectedIds) {
outcomes[id] = outcome
}
batchUpdate.mutate({ roundId, outcomes })
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!summary) return null
return (
<div className="space-y-6">
{/* Grace Period Banner */}
{summary.isGracePeriodActive && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
<p className="text-sm text-amber-600 dark:text-amber-400">
Applicants can still submit until{' '}
{summary.gracePeriodEndsAt
? new Date(summary.gracePeriodEndsAt).toLocaleString()
: 'the grace period ends'}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-amber-300 hover:bg-amber-100"
onClick={() => endGraceMutation.mutate({ roundId })}
disabled={endGraceMutation.isPending}
>
{endGraceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
End Grace Period
</Button>
</CardContent>
</Card>
)}
{/* Finalized Banner */}
{summary.isFinalized && (
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
<p className="text-sm text-green-600 dark:text-green-400">
Finalized on{' '}
{summary.finalizedAt
? new Date(summary.finalizedAt).toLocaleString()
: 'unknown date'}
</p>
</div>
</CardContent>
</Card>
)}
{/* Needs Processing Banner */}
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600" />
<div>
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
<p className="text-sm text-blue-600 dark:text-blue-400">
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-blue-300 hover:bg-blue-100"
onClick={() => processProjectsMutation.mutate({ roundId })}
disabled={processProjectsMutation.isPending}
>
{processProjectsMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Process Projects
</Button>
</CardContent>
</Card>
)}
{/* Summary Stats Bar */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{([
['Pending', summary.stats.pending, 'bg-gray-100 text-gray-700'],
['In Progress', summary.stats.inProgress, 'bg-blue-100 text-blue-700'],
['Completed', summary.stats.completed, 'bg-indigo-100 text-indigo-700'],
['Passed', summary.stats.passed, 'bg-green-100 text-green-700'],
['Rejected', summary.stats.rejected, 'bg-red-100 text-red-700'],
['Withdrawn', summary.stats.withdrawn, 'bg-yellow-100 text-yellow-700'],
] as const).map(([label, count, cls]) => (
<div key={label} className="rounded-lg border p-3 text-center">
<div className="text-2xl font-bold">{count}</div>
<div className={cn('text-xs font-medium mt-1 inline-flex px-2 py-0.5 rounded-full', cls)}>{label}</div>
</div>
))}
</div>
{/* Category Target Progress */}
{(summary.categoryTargets.startupTarget != null || summary.categoryTargets.conceptTarget != null) && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Advancement Targets</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{summary.categoryTargets.startupTarget != null && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Startup</span>
<span className="font-medium">
{summary.categoryTargets.startupProposed} / {summary.categoryTargets.startupTarget}
</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
summary.categoryTargets.startupProposed > summary.categoryTargets.startupTarget
? 'bg-amber-500'
: 'bg-green-500',
)}
style={{
width: `${Math.min(100, (summary.categoryTargets.startupProposed / summary.categoryTargets.startupTarget) * 100)}%`,
}}
/>
</div>
</div>
)}
{summary.categoryTargets.conceptTarget != null && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Business Concept</span>
<span className="font-medium">
{summary.categoryTargets.conceptProposed} / {summary.categoryTargets.conceptTarget}
</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
summary.categoryTargets.conceptProposed > summary.categoryTargets.conceptTarget
? 'bg-amber-500'
: 'bg-green-500',
)}
style={{
width: `${Math.min(100, (summary.categoryTargets.conceptProposed / summary.categoryTargets.conceptTarget) * 100)}%`,
}}
/>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Proposed Outcomes Table */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<CardTitle className="text-base">Proposed Outcomes</CardTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
{passedCount} advancing
</Badge>
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
{rejectedCount} rejected
</Badge>
{undecidedCount > 0 && (
<Badge variant="outline">{undecidedCount} undecided</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Search + Filter */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterOutcome} onValueChange={(v) => setFilterOutcome(v as typeof filterOutcome)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
<SelectItem value="none">Undecided</SelectItem>
</SelectContent>
</Select>
</div>
{/* Bulk actions */}
{selectedIds.size > 0 && !summary.isFinalized && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkSetOutcome('PASSED')}
disabled={batchUpdate.isPending}
>
Set as Pass
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkSetOutcome('REJECTED')}
disabled={batchUpdate.isPending}
className="text-destructive"
>
Set as Reject
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
Clear
</Button>
</div>
)}
{/* Table */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{!summary.isFinalized && (
<th className="w-10 px-3 py-2.5">
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</th>
)}
<th className="text-left px-3 py-2.5 font-medium">Project</th>
<th className="text-left px-3 py-2.5 font-medium hidden sm:table-cell">Category</th>
<th className="text-left px-3 py-2.5 font-medium hidden md:table-cell">Country</th>
<th className="text-center px-3 py-2.5 font-medium">Current State</th>
{summary.roundType === 'EVALUATION' && (
<th className="text-center px-3 py-2.5 font-medium hidden lg:table-cell">Score / Rank</th>
)}
<th className="text-center px-3 py-2.5 font-medium w-[160px]">Proposed Outcome</th>
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge
variant="secondary"
className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}
>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
))}
{filteredProjects.length === 0 && (
<tr>
<td
colSpan={summary.isFinalized ? 6 : 7}
className="px-3 py-8 text-center text-muted-foreground"
>
No projects match your search/filter
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Email Customization + Confirm */}
{!summary.isFinalized && !summary.isGracePeriodActive && (
<Card>
<CardHeader className="pb-3">
<button
className="flex items-center gap-2 text-left w-full"
onClick={() => setEmailSectionOpen(!emailSectionOpen)}
>
{emailSectionOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<Mail className="h-4 w-4" />
<CardTitle className="text-base">Email Customization</CardTitle>
<span className="text-xs text-muted-foreground ml-2">(optional)</span>
</button>
</CardHeader>
{emailSectionOpen && (
<CardContent className="space-y-4">
{/* Account stats */}
{(summary.accountStats.needsInvite > 0 || summary.accountStats.hasAccount > 0) && (
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 border text-sm">
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summary.accountStats.needsInvite > 0 && (
<span>
<strong>{summary.accountStats.needsInvite}</strong> project{summary.accountStats.needsInvite !== 1 ? 's' : ''} will receive a{' '}
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs">Create Account</Badge>{' '}
invite link
</span>
)}
{summary.accountStats.hasAccount > 0 && (
<span>
<strong>{summary.accountStats.hasAccount}</strong> project{summary.accountStats.hasAccount !== 1 ? 's' : ''} will receive a{' '}
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">View Dashboard</Badge>{' '}
link
</span>
)}
</div>
</div>
)}
<div>
<label className="text-sm font-medium mb-1.5 block">Advancement Message</label>
<Textarea
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
value={advancementMessage}
onChange={(e) => setAdvancementMessage(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Rejection Message</label>
<Textarea
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
value={rejectionMessage}
onChange={(e) => setRejectionMessage(e.target.value)}
rows={3}
/>
</div>
</CardContent>
)}
<CardContent className="pt-0">
<div className="flex items-center justify-between border-t pt-4">
<div className="text-sm text-muted-foreground">
{summary.nextRound ? (
<span>
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
<strong>{summary.nextRound.name}</strong>
</span>
) : (
<span>
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will be marked as passed
</span>
)}
{' | '}
<strong>{rejectedCount}</strong> rejected
{undecidedCount > 0 && (
<span className="text-amber-600"> | {undecidedCount} undecided (will not be processed)</span>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={passedCount + rejectedCount === 0 || confirmMutation.isPending}
>
{confirmMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
<Send className="h-4 w-4 mr-1.5" />
Finalize Round
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Finalization</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>This will:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
{summary.nextRound && (
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
)}
<li>Send email notifications to all affected teams</li>
</ul>
{undecidedCount > 0 && (
<p className="text-amber-600 flex items-center gap-1.5 mt-2">
<AlertTriangle className="h-4 w-4" />
{undecidedCount} project{undecidedCount !== 1 ? 's' : ''} with no proposed outcome will not be affected.
</p>
)}
<p className="font-medium mt-3">This action cannot be undone.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
confirmMutation.mutate({
roundId,
targetRoundId: summary.nextRound?.id,
advancementMessage: advancementMessage || undefined,
rejectionMessage: rejectionMessage || undefined,
})
}
>
Yes, Finalize Round
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Trophy } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps {
roundId: string
targetRoundId?: string
}
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage },
{ enabled: open }
)
const sendMutation = trpc.round.sendAdvancementNotifications.useMutation({
onSuccess: (data) => {
toast.success(
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
</div>
</button>
<EmailPreviewDialog
open={open}
onOpenChange={setOpen}
title="Notify Advanced Teams"
description="Send advancement notification emails to project team members"
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>
</>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { XCircle } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps {
roundId: string
}
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage },
{ enabled: open }
)
const sendMutation = trpc.round.sendRejectionNotifications.useMutation({
onSuccess: (data) => {
toast.success(
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
</div>
</button>
<EmailPreviewDialog
open={open}
onOpenChange={setOpen}
title="Notify Non-Advanced Teams"
description="Send rejection notification emails to project team members"
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>
</>
)
}

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>