Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s

- Fix criteria not showing for jurors: fetch active form independently via
  getStageForm query instead of relying on existing evaluation record
- Fix scoringMode default from 'global' to 'criteria' (matching schema)
- Parse scale string format ("1-10") into minScore/maxScore for criteria display
- Fix COI dialog dismissal: prevent outside click on evaluate page Dialog
- Fix requiredReviews hardcoded to 3: read from round configJson in 4 locations
- Add jury preferences banner for unconfirmed caps on jury dashboard
- Add updateJuryPreferences tRPC procedure for self-service cap/ratio
- Simplify onboarding: always show jury step, allow cap up to 50
- Add role/ratio/availability fields to jury member invite dialog
- Simplify jury group settings (keep only defaultMaxAssignments)
- Enforce deliberation showCollectiveRankings flag for non-admin users
- Redesign dashboard stat cards: editorial data strip on mobile,
  clean grid layout on desktop (no more generic card pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 12:33:20 +01:00
parent f9016168e7
commit ef1bf24388
16 changed files with 761 additions and 588 deletions

View File

@@ -30,9 +30,16 @@ export default function AssignmentsDashboardPage() {
id: competitionId,
})
const { data: selectedRound } = trpc.round.getById.useQuery(
{ id: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
const { data: unassignedQueue, isLoading: isLoadingQueue } =
trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId: selectedRoundId, requiredReviews: 3 },
{ roundId: selectedRoundId, requiredReviews },
{ enabled: !!selectedRoundId }
)
@@ -111,7 +118,7 @@ export default function AssignmentsDashboardPage() {
</TabsList>
<TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} />
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
</TabsContent>
<TabsContent value="unassigned" className="mt-6">
@@ -119,7 +126,7 @@ export default function AssignmentsDashboardPage() {
<CardHeader>
<CardTitle>Unassigned Projects</CardTitle>
<CardDescription>
Projects with fewer than 3 assignments
Projects with fewer than {requiredReviews} assignments
</CardDescription>
</CardHeader>
<CardContent>
@@ -143,7 +150,7 @@ export default function AssignmentsDashboardPage() {
</p>
</div>
<div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / 3 assignments
{project.assignmentCount || 0} / {requiredReviews} assignments
</div>
</div>
))}
@@ -162,6 +169,7 @@ export default function AssignmentsDashboardPage() {
roundId={selectedRoundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
requiredReviews={requiredReviews}
/>
</div>
)}

View File

@@ -33,7 +33,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
@@ -523,11 +522,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
name: group.name,
description: group.description || '',
defaultMaxAssignments: group.defaultMaxAssignments,
defaultCapMode: group.defaultCapMode,
softCapBuffer: group.softCapBuffer,
categoryQuotasEnabled: group.categoryQuotasEnabled,
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
})
const handleSubmit = (e: React.FormEvent) => {
@@ -562,99 +556,20 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Default Max Assignments</Label>
<Input
type="number"
min="1"
value={formData.defaultMaxAssignments}
onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
}
/>
</div>
<div className="space-y-2">
<Label>Cap Mode</Label>
<Select
value={formData.defaultCapMode}
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{formData.defaultCapMode === 'SOFT' && (
<div className="space-y-2">
<Label>Soft Cap Buffer</Label>
<Input
type="number"
min="0"
value={formData.softCapBuffer}
onChange={(e) =>
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
}
/>
<p className="text-xs text-muted-foreground">
Number of assignments allowed above the cap when in soft mode
</p>
</div>
)}
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Category Quotas Enabled</Label>
<p className="text-xs text-muted-foreground">
Enable category-based assignment quotas
</p>
</div>
<Switch
checked={formData.categoryQuotasEnabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, categoryQuotasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Cap Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own assignment cap during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorCapAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorCapAdjustment: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Ratio Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own startup/concept ratio during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorRatioAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
}
/>
</div>
<div className="space-y-2">
<Label>Default Max Assignments</Label>
<Input
type="number"
min="1"
max="50"
value={formData.defaultMaxAssignments}
onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
}
/>
<p className="text-xs text-muted-foreground">
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
</p>
</div>
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">

View File

@@ -1,11 +1,12 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { useState, useMemo, useCallback, useRef } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { useDebouncedCallback } from 'use-debounce'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -165,7 +166,8 @@ export default function RoundDetailPage() {
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const pendingSaveRef = useRef(false)
const [activeTab, setActiveTab] = useState('overview')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
@@ -215,8 +217,8 @@ export default function RoundDetailPage() {
)
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
// Sync config from server when not dirty
if (round && !hasChanges) {
// Sync config from server when no pending save
if (round && !pendingSaveRef.current) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
@@ -227,10 +229,15 @@ export default function RoundDetailPage() {
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
pendingSaveRef.current = false
setAutosaveStatus('saved')
setTimeout(() => setAutosaveStatus('idle'), 2000)
},
onError: (err) => {
pendingSaveRef.current = false
setAutosaveStatus('error')
toast.error(err.message)
},
onError: (err) => toast.error(err.message),
})
const activateMutation = trpc.roundEngine.activate.useMutation({
@@ -356,14 +363,17 @@ export default function RoundDetailPage() {
const isTransitioning = activateMutation.isPending || closeMutation.isPending || reopenMutation.isPending || archiveMutation.isPending
const debouncedSave = useDebouncedCallback((newConfig: Record<string, unknown>) => {
setAutosaveStatus('saving')
updateMutation.mutate({ id: roundId, configJson: newConfig })
}, 1500)
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}, [])
const handleSave = useCallback(() => {
updateMutation.mutate({ id: roundId, configJson: config })
}, [roundId, config, updateMutation])
pendingSaveRef.current = true
setAutosaveStatus('saving')
debouncedSave(newConfig)
}, [debouncedSave])
// ── Computed values ────────────────────────────────────────────────────
const projectCount = round?._count?.projectRoundStates ?? 0
@@ -565,15 +575,23 @@ export default function RoundDetailPage() {
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{hasChanges && (
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending} className="bg-white text-[#053d57] hover:bg-white/90">
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1.5" />
)}
Save Config
</Button>
{autosaveStatus === 'saving' && (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving...
</span>
)}
{autosaveStatus === 'saved' && (
<span className="flex items-center gap-1.5 text-xs text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
</span>
)}
{autosaveStatus === 'error' && (
<span className="flex items-center gap-1.5 text-xs text-red-300">
<AlertTriangle className="h-3.5 w-3.5" />
Save failed
</span>
)}
<Link href={poolLink}>
<Button variant="outline" size="sm" className="border-white/40 bg-white/15 text-white hover:bg-white/30 hover:text-white">
@@ -1491,7 +1509,7 @@ export default function RoundDetailPage() {
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">
{/* Coverage Report */}
<CoverageReport roundId={roundId} />
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
{/* Generate Assignments */}
<Card>
@@ -1554,13 +1572,14 @@ export default function RoundDetailPage() {
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
{/* Assignment Preview Sheet */}
<AssignmentPreviewSheet
roundId={roundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
requiredReviews={(config.requiredReviewsPerProject as number) || 3}
/>
{/* CSV Export Dialog */}
@@ -1822,9 +1841,9 @@ export default function RoundDetailPage() {
// ── Unassigned projects queue ────────────────────────────────────────────
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) {
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId, requiredReviews: 3 },
{ roundId, requiredReviews },
{ refetchInterval: 15_000 },
)
@@ -1832,7 +1851,7 @@ function RoundUnassignedQueue({ roundId }: { roundId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-base">Unassigned Projects</CardTitle>
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
<CardDescription>Projects with fewer than {requiredReviews} jury assignments</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (

View File

@@ -51,10 +51,9 @@ type JuryPref = {
juryGroupMemberId: string
juryGroupName: string
currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null
selfServiceRatio: number | null
preferredStartupRatio: number | null
}
export default function OnboardingPage() {
@@ -530,60 +529,59 @@ export default function OnboardingPage() {
{juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? m.preferredStartupRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, cap: v })
return next
})
}
min={1}
max={m.currentCap}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin default: {m.currentCap}. You may reduce this to match your availability.
</p>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, cap: v })
return next
})
}
min={1}
max={50}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin suggestion: {m.currentCap}. Adjust to match your availability.
</p>
</div>
{m.allowRatioAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
</Label>
<Slider
value={[ratioValue * 100]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
return next
})
}
min={0}
max={100}
step={5}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
</Label>
<Slider
value={[ratioValue * 100]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
return next
})
}
min={0}
max={100}
step={10}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
)}
<p className="text-xs text-muted-foreground/70 italic">
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
</p>
</div>
</div>
)
})}

View File

@@ -70,6 +70,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id }
)
// Fetch the active evaluation form for this round (independent of evaluation existence)
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
{ enabled: !!roundId }
)
// Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation()
@@ -116,19 +122,31 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Parse evaluation config from round
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
const scoringMode = evalConfig?.scoringMode ?? 'global'
const scoringMode = evalConfig?.scoringMode ?? 'criteria'
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from evaluation form
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
id: string
label: string
description?: string
weight?: number
minScore?: number
maxScore?: number
}> | undefined
// Get criteria from the active evaluation form (independent of evaluation record)
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
// Parse scale string like "1-10" into minScore/maxScore
let minScore = 1
let maxScore = 10
if (c.scale) {
const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0]
maxScore = parts[1]
}
}
return {
id: c.id,
label: c.label,
description: c.description,
weight: c.weight,
minScore,
maxScore,
}
})
const handleSaveDraft = async () => {
if (!myAssignment) {
@@ -217,8 +235,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
<DialogContent>
<Dialog open={showCOIDialog} onOpenChange={() => { /* prevent dismissal */ }}>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
<DialogDescription className="space-y-3 pt-2">

View File

@@ -30,6 +30,7 @@ import {
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container'
import { JuryPreferencesBanner } from '@/components/jury/preferences-banner'
import { cn } from '@/lib/utils'
function getGreeting(): string {
@@ -757,6 +758,9 @@ export default async function JuryDashboardPage() {
</p>
</div>
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner />
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />