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

@@ -53,6 +53,7 @@ import {
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -91,6 +92,7 @@ import {
AlertCircle,
Layers,
Info,
Mail,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -155,6 +157,8 @@ export default function AwardDetailPage({
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
const [notifyCustomMessage, setNotifyCustomMessage] = useState('')
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -283,6 +287,19 @@ export default function AwardDetailPage({
onError: (err) => toast.error(err.message),
})
const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery(
{ awardId },
{ enabled: notifyDialogOpen }
)
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
onSuccess: (result) => {
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
setNotifyDialogOpen(false)
setNotifyCustomMessage('')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
) => {
@@ -468,13 +485,72 @@ export default function AwardDetailPage({
</Button>
)}
{award.status === 'NOMINATIONS_OPEN' && (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
<>
<Dialog open={notifyDialogOpen} onOpenChange={setNotifyDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" disabled={award.eligibleCount === 0}>
<Mail className="mr-2 h-4 w-4" />
Notify Pool
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notify Eligible Projects</DialogTitle>
<DialogDescription>
Send &quot;Selected for {award.name}&quot; emails to all {award.eligibleCount} eligible projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{notifyStats && (
<div className="flex flex-wrap gap-2">
{notifyStats.needsInvite > 0 && (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
{notifyStats.needsInvite} will receive Create Account link
</Badge>
)}
{notifyStats.hasAccount > 0 && (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
{notifyStats.hasAccount} will receive Dashboard link
</Badge>
)}
</div>
)}
<div className="space-y-2">
<Label>Custom message (optional)</Label>
<Textarea
placeholder="Add a personal message to include in the email..."
value={notifyCustomMessage}
onChange={(e) => setNotifyCustomMessage(e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => notifyEligible.mutate({
awardId,
customMessage: notifyCustomMessage.trim() || undefined,
})}
disabled={notifyEligible.isPending}
>
{notifyEligible.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sending...</>
) : (
<><Mail className="mr-2 h-4 w-4" />Send {award.eligibleCount} Emails</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
</>
)}
{award.status === 'VOTING_OPEN' && (
<Button