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

@@ -22,12 +22,14 @@ interface AssignmentPreviewSheetProps {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
requiredReviews?: number
}
export function AssignmentPreviewSheet({
roundId,
open,
onOpenChange,
requiredReviews = 3,
}: AssignmentPreviewSheetProps) {
const utils = trpc.useUtils()
@@ -36,7 +38,7 @@ export function AssignmentPreviewSheet({
isLoading,
refetch,
} = trpc.roundAssignment.preview.useQuery(
{ roundId, honorIntents: true, requiredReviews: 3 },
{ roundId, honorIntents: true, requiredReviews },
{ enabled: open }
)

View File

@@ -7,11 +7,12 @@ import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
interface CoverageReportProps {
roundId: string
requiredReviews?: number
}
export function CoverageReport({ roundId }: CoverageReportProps) {
export function CoverageReport({ roundId, requiredReviews = 3 }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery(
{ roundId, requiredReviews: 3 },
{ roundId, requiredReviews },
{ refetchInterval: 15_000 },
)
@@ -71,7 +72,7 @@ export function CoverageReport({ roundId }: CoverageReportProps) {
<CardContent>
<div className="text-2xl font-bold">{unassignedCount}</div>
<p className="text-xs text-muted-foreground">
Projects below 3 reviews
Projects below {requiredReviews} reviews
</p>
</CardContent>
</Card>

View File

@@ -15,6 +15,8 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
@@ -38,12 +40,18 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('')
const [role, setRole] = useState<string>('MEMBER')
const [startupRatio, setStartupRatio] = useState<number | null>(null)
const [availabilityNotes, setAvailabilityNotes] = useState('')
// Invite new user state
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('')
const [inviteRole, setInviteRole] = useState<string>('MEMBER')
const [inviteStartupRatio, setInviteStartupRatio] = useState<number | null>(null)
const [inviteAvailabilityNotes, setInviteAvailabilityNotes] = useState('')
const [inviteExpertise, setInviteExpertise] = useState('')
const utils = trpc.useUtils()
@@ -73,9 +81,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
addMember({
juryGroupId,
userId: newUser.id,
role: 'MEMBER',
role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: inviteStartupRatio,
availabilityNotes: inviteAvailabilityNotes.trim() || null,
})
// Send invitation email
sendInvitation({ userId: newUser.id, juryGroupId })
@@ -101,10 +111,16 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
setSelectedUserId('')
setMaxAssignments('')
setCapMode('')
setRole('MEMBER')
setStartupRatio(null)
setAvailabilityNotes('')
setInviteName('')
setInviteEmail('')
setInviteMaxAssignments('')
setInviteCapMode('')
setInviteRole('MEMBER')
setInviteStartupRatio(null)
setInviteAvailabilityNotes('')
setInviteExpertise('')
}
@@ -119,9 +135,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
addMember({
juryGroupId,
userId: selectedUserId,
role: 'MEMBER',
role: role as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: startupRatio,
availabilityNotes: availabilityNotes.trim() || null,
})
}
@@ -212,6 +230,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
@@ -240,6 +271,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
/>
</div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[startupRatio !== null ? startupRatio * 100 : 50]}
onValueChange={([v]) => setStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{startupRatio !== null
? `~${Math.round(startupRatio * 100)}% startups / ~${Math.round((1 - startupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="availabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="availabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={availabilityNotes}
onChange={(e) => setAvailabilityNotes(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
@@ -281,6 +344,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteRole">Role</Label>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger id="inviteRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
@@ -309,6 +385,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
/>
</div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[inviteStartupRatio !== null ? inviteStartupRatio * 100 : 50]}
onValueChange={([v]) => setInviteStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{inviteStartupRatio !== null
? `~${Math.round(inviteStartupRatio * 100)}% startups / ~${Math.round((1 - inviteStartupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="inviteAvailabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="inviteAvailabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={inviteAvailabilityNotes}
onChange={(e) => setInviteAvailabilityNotes(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
<Input

View File

@@ -1,13 +1,6 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
CheckCircle2,
FileEdit,
Users,
} from 'lucide-react'
import { motion } from 'motion/react'
type PipelineRound = {
id: string
@@ -29,91 +22,73 @@ export function RoundStatsEvaluation({ round, activeJurors }: RoundStatsEvaluati
const { assignmentCount, evalSubmitted, evalDraft, evalTotal } = round
const completionPct = evalTotal > 0 ? ((evalSubmitted / evalTotal) * 100).toFixed(0) : '0'
const stats = [
{
value: assignmentCount,
label: 'Assignments',
detail: 'Jury-project pairs',
accent: 'text-brand-blue',
},
{
value: `${evalSubmitted}/${evalTotal}`,
label: 'Submitted',
detail: `${completionPct}% complete`,
accent: 'text-emerald-600',
},
{
value: evalDraft,
label: 'In draft',
detail: evalDraft > 0 ? 'Not yet submitted' : 'No drafts',
accent: evalDraft > 0 ? 'text-amber-600' : 'text-emerald-600',
},
{
value: activeJurors,
label: 'Active jurors',
detail: 'Evaluating',
accent: 'text-brand-teal',
},
]
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Evaluation
<>
{/* Round label */}
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
{round.name} &mdash; Evaluation
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Assignments</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{assignmentCount}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
Jury-project pairs
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Submitted</p>
<p className="mt-1 text-2xl font-bold tabular-nums">
{evalSubmitted}
<span className="text-sm font-normal text-muted-foreground">/{evalTotal}</span>
</p>
<p className="mt-0.5 text-xs text-emerald-600">
{completionPct}% complete
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Mobile: horizontal data strip */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
>
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
</div>
))}
</motion.div>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">In Draft</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{evalDraft}</p>
<p className="mt-0.5 text-xs text-amber-600">
{evalDraft > 0 ? 'Not yet submitted' : 'No drafts'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<FileEdit className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active Jurors</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{activeJurors}</p>
<p className="mt-0.5 text-xs text-violet-600">
Evaluating this round
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
<Users className="h-5 w-5 text-violet-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.06 }}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
</motion.div>
))}
</div>
</div>
</div>
</>
)
}

View File

@@ -1,13 +1,6 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
Filter,
CheckCircle2,
XCircle,
AlertTriangle,
} from 'lucide-react'
import { motion } from 'motion/react'
type PipelineRound = {
id: string
@@ -35,93 +28,80 @@ type RoundStatsFilteringProps = {
export function RoundStatsFiltering({ round }: RoundStatsFilteringProps) {
const { filteringPassed, filteringRejected, filteringFlagged, projectStates } = round
const passRate = projectStates.total > 0
? ((filteringPassed / projectStates.total) * 100).toFixed(0)
: '0'
const rejectRate = projectStates.total > 0
? ((filteringRejected / projectStates.total) * 100).toFixed(0)
: '0'
const stats = [
{
value: projectStates.total,
label: 'To filter',
detail: 'In pipeline',
accent: 'text-brand-blue',
},
{
value: filteringPassed,
label: 'Passed',
detail: `${passRate}% pass rate`,
accent: 'text-emerald-600',
},
{
value: filteringRejected,
label: 'Rejected',
detail: `${rejectRate}% rejected`,
accent: 'text-red-600',
},
{
value: filteringFlagged,
label: 'Flagged',
detail: filteringFlagged > 0 ? 'Manual review' : 'None flagged',
accent: filteringFlagged > 0 ? 'text-amber-600' : 'text-emerald-600',
},
]
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Filtering
<>
{/* Round label */}
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
{round.name} &mdash; Filtering
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects to Filter</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
In pipeline
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<Filter className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Passed</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringPassed}</p>
<p className="mt-0.5 text-xs text-emerald-600">
{projectStates.total > 0
? `${((filteringPassed / projectStates.total) * 100).toFixed(0)}% pass rate`
: 'No results yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Mobile: horizontal data strip */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
>
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
</div>
))}
</motion.div>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-red-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Rejected</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringRejected}</p>
<p className="mt-0.5 text-xs text-red-600">
{projectStates.total > 0
? `${((filteringRejected / projectStates.total) * 100).toFixed(0)}% rejected`
: 'No results yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<XCircle className="h-5 w-5 text-red-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Flagged for Review</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringFlagged}</p>
<p className="mt-0.5 text-xs text-amber-600">
{filteringFlagged > 0 ? 'Needs manual review' : 'None flagged'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.06 }}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
</motion.div>
))}
</div>
</div>
</div>
</>
)
}

View File

@@ -1,13 +1,6 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
Users,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
import { motion } from 'motion/react'
type RoundStatsGenericProps = {
projectCount: number
@@ -33,89 +26,72 @@ export function RoundStatsGeneric({
const completionPct =
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
const stats = [
{
value: projectCount,
label: 'Projects',
detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
accent: 'text-brand-blue',
},
{
value: totalJurors,
label: 'Jurors',
detail: `${activeJurors} active`,
accent: 'text-brand-teal',
},
{
value: `${submittedCount}/${totalAssignments}`,
label: 'Evaluations',
detail: `${completionPct}% complete`,
accent: 'text-emerald-600',
},
{
value: actionsCount,
label: actionsCount === 1 ? 'Action' : 'Actions',
detail: actionsCount > 0 ? 'Pending' : 'All clear',
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
},
]
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectCount}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
{newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<>
{/* Mobile: horizontal data strip */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
>
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
</div>
))}
</motion.div>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Jury</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{totalJurors}</p>
<p className="mt-0.5 text-xs text-violet-600">
{activeJurors} active
</p>
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.06 }}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
<Users className="h-5 w-5 text-violet-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold tabular-nums">
{submittedCount}
<span className="text-sm font-normal text-muted-foreground">/{totalAssignments}</span>
</p>
<p className="mt-0.5 text-xs text-emerald-600">
{completionPct}% complete
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className={`border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${actionsCount > 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Actions Needed</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{actionsCount}</p>
<p className={`mt-0.5 text-xs ${actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{actionsCount > 0 ? 'Pending actions' : 'All clear'}
</p>
</div>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${actionsCount > 0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}>
{actionsCount > 0
? <AlertTriangle className="h-5 w-5 text-amber-500" />
: <CheckCircle2 className="h-5 w-5 text-emerald-400" />
}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
{s.detail && (
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
)}
</motion.div>
))}
</div>
</div>
</>
)
}

View File

@@ -1,13 +1,6 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
CheckCircle2,
Clock,
TrendingUp,
} from 'lucide-react'
import { motion } from 'motion/react'
type PipelineRound = {
id: string
@@ -32,91 +25,77 @@ type RoundStatsIntakeProps = {
export function RoundStatsIntake({ round, newProjectsThisWeek }: RoundStatsIntakeProps) {
const { projectStates } = round
const completePct = projectStates.total > 0
? ((projectStates.PASSED / projectStates.total) * 100).toFixed(0)
: '0'
const stats = [
{
value: projectStates.total,
label: 'Submitted',
detail: 'Total projects',
accent: 'text-brand-blue',
},
{
value: projectStates.PASSED,
label: 'Docs complete',
detail: `${completePct}% of total`,
accent: 'text-emerald-600',
},
{
value: projectStates.PENDING,
label: 'Pending',
detail: projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed',
accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
},
{
value: newProjectsThisWeek,
label: 'This week',
detail: newProjectsThisWeek > 0 ? 'New submissions' : 'No new',
accent: 'text-brand-teal',
},
]
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Intake
<>
{/* Round label */}
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
{round.name} &mdash; Intake
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Projects</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
In this round
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Documents Complete</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PASSED}</p>
<p className="mt-0.5 text-xs text-emerald-600">
{projectStates.total > 0
? `${((projectStates.PASSED / projectStates.total) * 100).toFixed(0)}% of total`
: 'No projects yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Mobile: horizontal data strip */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
>
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
</div>
))}
</motion.div>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Pending Review</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PENDING}</p>
<p className="mt-0.5 text-xs text-amber-600">
{projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<Clock className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">New This Week</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{newProjectsThisWeek}</p>
<p className="mt-0.5 text-xs text-brand-teal">
{newProjectsThisWeek > 0 ? 'Recently submitted' : 'No new submissions'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-teal/10">
<TrendingUp className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.06 }}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
</motion.div>
))}
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import { Scale, CheckCircle2, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
/**
* Shows a blocking banner when a juror has jury group memberships
* with unconfirmed preferences (selfServiceCap is null) linked to
* active rounds. The juror must confirm before proceeding.
*/
export function JuryPreferencesBanner() {
const { data: ctx, isLoading } = trpc.user.getOnboardingContext.useQuery()
const utils = trpc.useUtils()
const unconfirmed = (ctx?.memberships ?? []).filter(
(m) => m.selfServiceCap === null,
)
const [prefs, setPrefs] = useState<Map<string, { cap: number; ratio: number }>>(new Map())
const [confirming, setConfirming] = useState(false)
const updatePref = (id: string, field: 'cap' | 'ratio', value: number) => {
setPrefs((prev) => {
const next = new Map(prev)
const existing = next.get(id) ?? { cap: 15, ratio: 0.5 }
next.set(id, { ...existing, [field]: value })
return next
})
}
const confirmMutation = trpc.user.updateJuryPreferences.useMutation({
onSuccess: () => {
toast.success('Preferences confirmed')
utils.user.getOnboardingContext.invalidate()
setConfirming(false)
},
onError: (err) => {
toast.error(err.message)
setConfirming(false)
},
})
const handleConfirm = () => {
setConfirming(true)
const preferences = unconfirmed.map((m) => {
const pref = prefs.get(m.juryGroupMemberId)
return {
juryGroupMemberId: m.juryGroupMemberId,
selfServiceCap: pref?.cap ?? m.currentCap,
selfServiceRatio: pref?.ratio ?? m.preferredStartupRatio ?? 0.5,
}
})
confirmMutation.mutate({ preferences })
}
if (isLoading || unconfirmed.length === 0) return null
return (
<Card className="border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-amber-600" />
<CardTitle className="text-base">Confirm Your Evaluation Preferences</CardTitle>
</div>
<CardDescription>
Please review and confirm your assignment preferences before evaluations begin.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{unconfirmed.map((m) => {
const pref = prefs.get(m.juryGroupMemberId)
const capValue = pref?.cap ?? m.currentCap
const ratioValue = pref?.ratio ?? m.preferredStartupRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border bg-background p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) => updatePref(m.juryGroupMemberId, 'cap', v)}
min={1}
max={50}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin suggestion: {m.currentCap}. Adjust to match your availability.
</p>
</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]) => updatePref(m.juryGroupMemberId, 'ratio', v / 100)}
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>
)
})}
<Button
onClick={handleConfirm}
disabled={confirming}
className="w-full"
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Confirming...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirm Preferences
</>
)}
</Button>
</CardContent>
</Card>
)
}