Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user