feat: add juror progress dashboard with evaluation.getMyProgress query
- Add getMyProgress juryProcedure query to evaluationRouter: fetches all assignments for the current juror in a round, tallies completed/total, advance yes/no counts, per-project numeric scores and averages - Create JurorProgressDashboard client component with progress bar, advance badge summary, and collapsible per-submission score table - Wire dashboard into jury round page, gated by configJson.showJurorProgressDashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||||
|
|
||||||
export default function JuryRoundDetailPage() {
|
export default function JuryRoundDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -53,6 +54,14 @@ export default function JuryRoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const config = (round?.configJson as Record<string, unknown>) ?? {}
|
||||||
|
if (config.showJurorProgressDashboard) {
|
||||||
|
return <JurorProgressDashboard roundId={roundId} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Assigned Projects</CardTitle>
|
<CardTitle>Assigned Projects</CardTitle>
|
||||||
|
|||||||
116
src/components/jury/juror-progress-dashboard.tsx
Normal file
116
src/components/jury/juror-progress-dashboard.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { ChevronDown, ChevronUp, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||||
|
|
||||||
|
export function JurorProgressDashboard({ roundId }: { roundId: string }) {
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const { data, isLoading } = trpc.evaluation.getMyProgress.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-32 w-full" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.total === 0) return null
|
||||||
|
|
||||||
|
const pct = Math.round((data.completed / data.total) * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Your Progress</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
|
||||||
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{data.completed} / {data.total} evaluated
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advance summary */}
|
||||||
|
{(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Advance:</span>
|
||||||
|
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||||
|
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||||
|
{data.advanceCounts.yes} Yes
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
||||||
|
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||||
|
{data.advanceCounts.no} No
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submissions table */}
|
||||||
|
{expanded && data.submissions.length > 0 && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Project</th>
|
||||||
|
<th className="text-center px-3 py-2 font-medium">Avg Score</th>
|
||||||
|
{data.submissions[0]?.criterionScores.map((cs, i) => (
|
||||||
|
<th key={i} className="text-center px-2 py-2 font-medium text-xs max-w-[80px] truncate" title={cs.label}>
|
||||||
|
{cs.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-center px-3 py-2 font-medium">Advance</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.submissions.map((s) => (
|
||||||
|
<tr key={s.projectId} className="border-b last:border-0 hover:bg-muted/20">
|
||||||
|
<td className="px-3 py-2 font-medium truncate max-w-[200px]">{s.projectName}</td>
|
||||||
|
<td className="text-center px-3 py-2">
|
||||||
|
{s.numericAverage != null ? (
|
||||||
|
<span className="font-semibold">{s.numericAverage}</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
{s.criterionScores.map((cs, i) => (
|
||||||
|
<td key={i} className="text-center px-2 py-2 text-muted-foreground">{cs.value}</td>
|
||||||
|
))}
|
||||||
|
<td className="text-center px-3 py-2">
|
||||||
|
{s.advanceDecision === true ? (
|
||||||
|
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 text-xs">YES</Badge>
|
||||||
|
) : s.advanceDecision === false ? (
|
||||||
|
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs">NO</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1588,4 +1588,100 @@ export const evaluationRouter = router({
|
|||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current juror's progress for a round
|
||||||
|
*/
|
||||||
|
getMyProgress: juryProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { roundId } = input
|
||||||
|
const userId = ctx.user.id
|
||||||
|
|
||||||
|
// Get all assignments for this juror in this round
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId, userId },
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
evaluation: {
|
||||||
|
include: { form: { select: { criteriaJson: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = assignments.length
|
||||||
|
let completed = 0
|
||||||
|
let advanceYes = 0
|
||||||
|
let advanceNo = 0
|
||||||
|
|
||||||
|
const submissions: Array<{
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
submittedAt: Date | null
|
||||||
|
advanceDecision: boolean | null
|
||||||
|
criterionScores: Array<{ label: string; value: number }>
|
||||||
|
numericAverage: number | null
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
const ev = a.evaluation
|
||||||
|
if (!ev || ev.status !== 'SUBMITTED') continue
|
||||||
|
completed++
|
||||||
|
|
||||||
|
const criteria = (ev.form?.criteriaJson ?? []) as Array<{
|
||||||
|
id: string; label: string; type?: string; weight?: number
|
||||||
|
}>
|
||||||
|
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
// Find the advance criterion
|
||||||
|
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||||
|
let advanceDecision: boolean | null = null
|
||||||
|
if (advanceCriterion) {
|
||||||
|
const val = scores[advanceCriterion.id]
|
||||||
|
if (typeof val === 'boolean') {
|
||||||
|
advanceDecision = val
|
||||||
|
if (val) advanceYes++
|
||||||
|
else advanceNo++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect numeric criterion scores
|
||||||
|
const numericScores: Array<{ label: string; value: number }> = []
|
||||||
|
for (const c of criteria) {
|
||||||
|
if (c.type === 'numeric' || (!c.type && c.weight !== undefined)) {
|
||||||
|
const val = scores[c.id]
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
numericScores.push({ label: c.label, value: val })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericAverage = numericScores.length > 0
|
||||||
|
? Math.round((numericScores.reduce((sum, s) => sum + s.value, 0) / numericScores.length) * 10) / 10
|
||||||
|
: null
|
||||||
|
|
||||||
|
submissions.push({
|
||||||
|
projectId: a.project.id,
|
||||||
|
projectName: a.project.title,
|
||||||
|
submittedAt: ev.submittedAt,
|
||||||
|
advanceDecision,
|
||||||
|
criterionScores: numericScores,
|
||||||
|
numericAverage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent first
|
||||||
|
submissions.sort((a, b) => {
|
||||||
|
if (!a.submittedAt) return 1
|
||||||
|
if (!b.submittedAt) return -1
|
||||||
|
return b.submittedAt.getTime() - a.submittedAt.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
advanceCounts: { yes: advanceYes, no: advanceNo },
|
||||||
|
submissions,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user