fix: backfill binaryDecision, fix boolean criterion lookup, add assign buttons
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m9s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m9s
- Backfilled 166 evaluations' binaryDecision from criterionScoresJson on production DB - Fixed roundEvaluationScores and ai-ranking to look in EvaluationForm.criteriaJson instead of round.configJson for the boolean "Move to the Next Stage?" criterion - Added advanceMode (count/threshold) toggle to round config Advancement Targets - Added "Assign to Jurors" button on Unassigned Projects section and Projects tab bulk bar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1434,7 +1434,10 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||||
<TabsContent value="projects" className="space-y-4">
|
<TabsContent value="projects" className="space-y-4">
|
||||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
<ProjectStatesTable competitionId={competitionId} roundId={roundId} onAssignProjects={() => {
|
||||||
|
setActiveTab('assignments')
|
||||||
|
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||||
|
}} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||||
@@ -1797,7 +1800,7 @@ export default function RoundDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<COIReviewSection roundId={roundId} />
|
<COIReviewSection roundId={roundId} />
|
||||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} onAssignUnassigned={() => setPreviewSheetOpen(true)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2157,50 +2160,103 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
|
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
|
||||||
<Label className="text-sm font-medium">Advancement Targets</Label>
|
<Label className="text-sm font-medium">Advancement Targets</Label>
|
||||||
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
|
|
||||||
<div className="mt-2 mb-1 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
|
|
||||||
<p className="text-xs text-amber-700">Advancement targets not configured — all passed projects will be eligible to advance.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
Target number of projects per category to advance from this round
|
How to determine which projects advance from this round
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
{/* Mode toggle */}
|
||||||
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
<div className="flex gap-2 mb-4">
|
||||||
Startup Projects
|
<Button
|
||||||
</Label>
|
type="button"
|
||||||
<Input
|
size="sm"
|
||||||
id="startup-advance-count"
|
variant={(config.advanceMode as string) === 'threshold' ? 'outline' : 'default'}
|
||||||
type="number"
|
className="h-8 text-xs"
|
||||||
min={0}
|
onClick={() => handleConfigChange({ ...config, advanceMode: 'count' })}
|
||||||
className="h-9"
|
>
|
||||||
placeholder="No limit"
|
Fixed Count
|
||||||
value={(config.startupAdvanceCount as number) ?? ''}
|
</Button>
|
||||||
onChange={(e) => {
|
<Button
|
||||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
type="button"
|
||||||
handleConfigChange({ ...config, startupAdvanceCount: val })
|
size="sm"
|
||||||
}}
|
variant={(config.advanceMode as string) === 'threshold' ? 'default' : 'outline'}
|
||||||
/>
|
className="h-8 text-xs"
|
||||||
</div>
|
onClick={() => handleConfigChange({ ...config, advanceMode: 'threshold' })}
|
||||||
<div className="space-y-1.5">
|
>
|
||||||
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
Score Threshold
|
||||||
Concept Projects
|
</Button>
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="concept-advance-count"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="h-9"
|
|
||||||
placeholder="No limit"
|
|
||||||
value={(config.conceptAdvanceCount as number) ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
|
||||||
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(config.advanceMode as string) === 'threshold' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="advance-threshold" className="text-xs text-muted-foreground">
|
||||||
|
Minimum Average Score to Advance
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
All projects scoring at or above this threshold will advance (both categories)
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="advance-threshold"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
className="h-9 w-32"
|
||||||
|
placeholder="e.g. 6.5"
|
||||||
|
value={(config.advanceScoreThreshold as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseFloat(e.target.value) : undefined
|
||||||
|
handleConfigChange({ ...config, advanceScoreThreshold: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
|
||||||
|
<div className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-amber-700">Advancement targets not configured — all passed projects will be eligible to advance.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Target number of projects per category to advance
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Startup Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="startup-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.startupAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, startupAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Concept Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="concept-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.conceptAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { Users } from 'lucide-react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export type RoundUnassignedQueueProps = {
|
export type RoundUnassignedQueueProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
requiredReviews?: number
|
requiredReviews?: number
|
||||||
|
onAssignUnassigned?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
|
export function RoundUnassignedQueue({ roundId, requiredReviews = 3, onAssignUnassigned }: RoundUnassignedQueueProps) {
|
||||||
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||||
{ roundId, requiredReviews },
|
{ roundId, requiredReviews },
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 15_000 },
|
||||||
@@ -18,9 +21,17 @@ export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnas
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium">Unassigned Projects</p>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
<p className="text-sm font-medium">Unassigned Projects</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
||||||
|
</div>
|
||||||
|
{unassigned && unassigned.length > 0 && onAssignUnassigned && (
|
||||||
|
<Button size="sm" variant="outline" onClick={onAssignUnassigned}>
|
||||||
|
<Users className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Assign to Jurors
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
@@ -78,9 +79,10 @@ const stateConfig: Record<ProjectState, { label: string; color: string; icon: Re
|
|||||||
type ProjectStatesTableProps = {
|
type ProjectStatesTableProps = {
|
||||||
competitionId: string
|
competitionId: string
|
||||||
roundId: string
|
roundId: string
|
||||||
|
onAssignProjects?: (projectIds: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: ProjectStatesTableProps) {
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
@@ -321,6 +323,16 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Change State
|
Change State
|
||||||
</Button>
|
</Button>
|
||||||
|
{onAssignProjects && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAssignProjects(Array.from(selectedIds))}
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Assign to Jurors
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -408,18 +408,15 @@ export const rankingRouter = router({
|
|||||||
roundEvaluationScores: adminProcedure
|
roundEvaluationScores: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Fetch the round config to find the boolean criterion ID (legacy fallback)
|
// Find the boolean criterion ID from the EvaluationForm (not round configJson)
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||||
where: { id: input.roundId },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
select: { configJson: true },
|
select: { criteriaJson: true },
|
||||||
})
|
})
|
||||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
const formCriteria = (evalForm?.criteriaJson as Array<{
|
||||||
const criteria = (roundConfig?.criteria ?? roundConfig?.evaluationCriteria ?? []) as Array<{
|
id: string; label: string; type?: string
|
||||||
id: string
|
}> | null) ?? []
|
||||||
label: string
|
const boolCriterionId = formCriteria.find(
|
||||||
type?: string
|
|
||||||
}>
|
|
||||||
const boolCriterionId = criteria.find(
|
|
||||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
)?.id ?? null
|
)?.id ?? null
|
||||||
|
|
||||||
|
|||||||
@@ -247,17 +247,11 @@ function anonymizeProjectsForRanking(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the boolean criterion ID for "Move to the Next Stage?" from round config.
|
* Find the boolean criterion ID for "Move to the Next Stage?" from criteria definitions.
|
||||||
* Returns null if no such criterion exists.
|
* Accepts criteria from EvaluationForm.criteriaJson (preferred) or round configJson (legacy).
|
||||||
*/
|
*/
|
||||||
function findBooleanCriterionId(roundConfig: Record<string, unknown> | null): string | null {
|
function findBooleanCriterionId(criterionDefs: Array<{ id: string; label: string; type?: string }>): string | null {
|
||||||
if (!roundConfig) return null
|
const boolCriterion = criterionDefs.find(
|
||||||
const criteria = (roundConfig.criteria ?? roundConfig.evaluationCriteria ?? []) as Array<{
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
type?: string
|
|
||||||
}>
|
|
||||||
const boolCriterion = criteria.find(
|
|
||||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
)
|
)
|
||||||
return boolCriterion?.id ?? null
|
return boolCriterion?.id ?? null
|
||||||
@@ -593,7 +587,6 @@ export async function fetchAndRankCategory(
|
|||||||
])
|
])
|
||||||
|
|
||||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
const roundConfig = round.configJson as Record<string, unknown> | null
|
||||||
const boolCriterionId = findBooleanCriterionId(roundConfig)
|
|
||||||
|
|
||||||
// Parse evaluation config for criteria weights
|
// Parse evaluation config for criteria weights
|
||||||
const evalConfig = roundConfig as EvaluationConfig | null
|
const evalConfig = roundConfig as EvaluationConfig | null
|
||||||
@@ -603,6 +596,7 @@ export async function fetchAndRankCategory(
|
|||||||
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
|
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
|
||||||
? (evalForm.criteriaJson as unknown as CriterionDef[])
|
? (evalForm.criteriaJson as unknown as CriterionDef[])
|
||||||
: []
|
: []
|
||||||
|
const boolCriterionId = findBooleanCriterionId(criterionDefs)
|
||||||
const numericCriterionIds = new Set(
|
const numericCriterionIds = new Set(
|
||||||
criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id),
|
criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { RoundType, AwardEligibilityMode, AwardScoringMode, AwardStatus } f
|
|||||||
const generalSettingsFields = {
|
const generalSettingsFields = {
|
||||||
startupAdvanceCount: z.number().int().nonnegative().optional(),
|
startupAdvanceCount: z.number().int().nonnegative().optional(),
|
||||||
conceptAdvanceCount: z.number().int().nonnegative().optional(),
|
conceptAdvanceCount: z.number().int().nonnegative().optional(),
|
||||||
|
advanceMode: z.enum(['count', 'threshold']).default('count'),
|
||||||
|
advanceScoreThreshold: z.number().min(0).max(10).optional(),
|
||||||
notifyOnEntry: z.boolean().default(false),
|
notifyOnEntry: z.boolean().default(false),
|
||||||
notifyOnAdvance: z.boolean().default(false),
|
notifyOnAdvance: z.boolean().default(false),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user