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
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:
@@ -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 && <> · {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} — 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user