feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
@@ -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 && <> · {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>
|
||||
|
||||
Reference in New Issue
Block a user