Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s

## Critical Logic Fixes (Tier 1)
- Fix requiredReviews config key mismatch (always defaulted to 3)
- Fix double-email + stageName/roundName metadata mismatch in notifications
- Fix snake_case config reads in peer review (peerReviewEnabled was always blocked)
- Add server-side COI check to evaluation submit (was client-only)
- Fix hard-coded feedbackText.min(10) — now uses config values
- Fix binaryDecision corruption in non-binary scoring modes
- Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx
- Fix removeFromRound: now cleans up orphaned Assignment records
- Fix 3-day reminder sending wrong email template (was using 24h template)

## High-Priority Logic Fixes (Tier 2)
- Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED)
- Scope AI assignment job to jury group members (was querying all JURY_MEMBERs)
- Add COI awareness to AI assignment generation
- Enforce requireAllCriteriaScored server-side
- Fix expireIntentsForRound nested transaction (now uses caller's tx)
- Implement notifyOnEntry for advancement path
- Implement notifyOnAdvance (was dead config)
- Fix checkRequirementsAndTransition for SubmissionFileRequirement model

## New Features (Tier 3)
- Add Project to Round: dialog with "Create New" and "From Pool" tabs
- Assignment "By Project" mode: select project → assign multiple jurors
- Backend: project.createAndAssignToRound procedure

## UI/UX Improvements (Tier 4+5)
- Add AlertDialog confirmation to header status dropdown
- Replace native confirm() with AlertDialog in assignments table
- Jury stats card now display-only with "Change" link
- Assignments tab restructured into logical card groups
- Inline-editable round name in header
- Back button shows destination label
- Readiness checklist: green check instead of strikethrough
- Gate assignments tab when no jury group assigned
- Relative time on window stats card
- Toast feedback on date saves
- Disable advance button when no target round
- COI section shows placeholder when empty
- Round position shown as "Round X of Y"
- InlineMemberCap edit icon always visible
- Status badge tooltip with description
- Add REMINDER_3_DAYS email template
- Fix maybeSendEmail to respect notification preferences
- Optimize bulk notification email loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-19 12:59:35 +01:00
parent ee8b12e59c
commit baca483fcb
12 changed files with 1814 additions and 609 deletions

View File

@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
const [addProjectOpen, setAddProjectOpen] = useState(false)
const utils = trpc.useUtils()
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" />
Quick Add
Add Project
</Button>
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
</Button>
</Link>
</div>
</div>
@@ -436,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
)}
</div>
{/* Quick Add Dialog */}
{/* Quick Add Dialog (legacy, kept for empty state) */}
<QuickAddDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
@@ -447,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
}}
/>
{/* Add Project Dialog (Create New + From Pool) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent>
@@ -673,3 +682,287 @@ function QuickAddDialog({
</Dialog>
)
}
/**
* Add Project Dialog — two tabs: "Create New" and "From Pool".
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [country, setCountry] = useState('')
const [category, setCategory] = useState<string>('')
// ── From Pool tab state ──
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: poolSearch.trim() || undefined,
perPage: 50,
},
{ enabled: open && activeTab === 'pool' && !!programId },
)
// Create mutation
const createMutation = trpc.project.createAndAssignToRound.useMutation({
onSuccess: () => {
toast.success('Project created and added to round')
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Assign from pool mutation
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.assignedCount} project(s) added to round`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
const resetAndClose = () => {
setTitle('')
setTeamName('')
setDescription('')
setCountry('')
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
onOpenChange(false)
}
const handleCreate = () => {
if (!title.trim()) return
createMutation.mutate({
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
country: country.trim() || undefined,
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
roundId,
})
}
const handleAssignFromPool = () => {
if (selectedPoolIds.size === 0) return
assignMutation.mutate({
projectIds: Array.from(selectedPoolIds),
roundId,
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) resetAndClose()
else onOpenChange(true)
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Project to Round</DialogTitle>
<DialogDescription>
Create a new project or select existing ones to add to this round.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
</TabsList>
{/* ── Create New Tab ── */}
<TabsContent value="create" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="add-project-title">Title *</Label>
<Input
id="add-project-title"
placeholder="Project title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-team">Team Name</Label>
<Input
id="add-project-team"
placeholder="Team or organization name"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="add-project-country">Country</Label>
<Input
id="add-project-country"
placeholder="e.g. France"
value={country}
onChange={(e) => setCountry(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-desc">Description</Label>
<Input
id="add-project-desc"
placeholder="Brief description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || isMutating}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create & Add to Round
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Pool Tab ── */}
<TabsContent value="pool" className="space-y-4 mt-4">
<div className="relative">
<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 project title or team..."
value={poolSearch}
onChange={(e) => setPoolSearch(e.target.value)}
className="pl-8"
/>
</div>
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{poolLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!poolLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
</p>
)}
{poolResults?.projects.map((project: any) => {
const isSelected = selectedPoolIds.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={() => togglePoolProject(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>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</label>
)
})}
</div>
</ScrollArea>
{poolResults && poolResults.total > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing 50 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleAssignFromPool}
disabled={selectedPoolIds.size === 0 || isMutating}
>
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedPoolIds.size <= 1
? 'Add to Round'
: `Add ${selectedPoolIds.size} Projects to Round`
}
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}