feat: add admin advancement summary card and advance column in assignments table
- Update listByStage query to include evaluation form criteriaJson and criterionScoresJson - Add Advance column to individual assignments table showing YES/NO badge per submitted evaluation - Create AdvancementSummaryCard component showing yes/no/pending vote counts with stacked bar - Wire AdvancementSummaryCard into the EVALUATION round overview tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,7 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
|
|||||||
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
|
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
|
||||||
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
|
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
|
||||||
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
|
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
|
||||||
|
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -942,6 +943,13 @@ export default function RoundDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Advancement Votes Summary — only for EVALUATION rounds */}
|
||||||
|
{isEvaluation && (
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<AdvancementSummaryCard roundId={roundId} />
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filtering Results Summary — only for FILTERING rounds with results */}
|
{/* Filtering Results Summary — only for FILTERING rounds with results */}
|
||||||
{isFiltering && filteringStats && filteringStats.total > 0 && (
|
{isFiltering && filteringStats && filteringStats.total > 0 && (
|
||||||
<AnimatedCard index={1}>
|
<AnimatedCard index={1}>
|
||||||
|
|||||||
@@ -312,17 +312,18 @@ export function IndividualAssignmentsTable({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
||||||
<div className="grid grid-cols-[1fr_1fr_100px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||||
<span>Juror</span>
|
<span>Juror</span>
|
||||||
<span>Project</span>
|
<span>Project</span>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
|
<span>Advance</span>
|
||||||
<span>Actions</span>
|
<span>Actions</span>
|
||||||
</div>
|
</div>
|
||||||
{assignments.map((a: any, idx: number) => (
|
{assignments.map((a: any, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid grid-cols-[1fr_1fr_100px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
'grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -349,6 +350,20 @@ export function IndividualAssignmentsTable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{(() => {
|
||||||
|
const ev = a.evaluation
|
||||||
|
if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs">—</span>
|
||||||
|
const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||||
|
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||||
|
const advCrit = criteria.find((c: any) => c.type === 'advance')
|
||||||
|
if (!advCrit) return <span className="text-muted-foreground text-xs">—</span>
|
||||||
|
const val = scores[advCrit.id]
|
||||||
|
if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
|
||||||
|
if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
|
||||||
|
return <span className="text-muted-foreground text-xs">—</span>
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
|||||||
93
src/components/admin/round/advancement-summary-card.tsx
Normal file
93
src/components/admin/round/advancement-summary-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export function AdvancementSummaryCard({ roundId }: { roundId: string }) {
|
||||||
|
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-40 w-full" />
|
||||||
|
|
||||||
|
if (!assignments || assignments.length === 0) return null
|
||||||
|
|
||||||
|
// Check if form has an advance criterion
|
||||||
|
const firstSubmitted = assignments.find(
|
||||||
|
(a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson
|
||||||
|
)
|
||||||
|
if (!firstSubmitted) return null
|
||||||
|
|
||||||
|
const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||||
|
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||||
|
if (!advanceCriterion) return null
|
||||||
|
|
||||||
|
let yesCount = 0
|
||||||
|
let noCount = 0
|
||||||
|
let pendingCount = 0
|
||||||
|
|
||||||
|
for (const a of assignments as any[]) {
|
||||||
|
const ev = a.evaluation
|
||||||
|
if (!ev || ev.status !== 'SUBMITTED') {
|
||||||
|
pendingCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||||
|
const val = scores[advanceCriterion.id]
|
||||||
|
if (val === true) yesCount++
|
||||||
|
else if (val === false) noCount++
|
||||||
|
else pendingCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = yesCount + noCount + pendingCount
|
||||||
|
const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0
|
||||||
|
const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Advancement Votes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||||
|
<ThumbsUp className="h-5 w-5 text-emerald-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<ThumbsDown className="h-5 w-5 text-red-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-red-700">{noCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">No ({noPct}%)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<Clock className="h-5 w-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Pending</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stacked bar */}
|
||||||
|
<div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
|
||||||
|
{yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
|
||||||
|
{noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -809,7 +809,14 @@ export const assignmentRouter = router({
|
|||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
||||||
project: { select: { id: true, title: true, tags: true } },
|
project: { select: { id: true, title: true, tags: true } },
|
||||||
evaluation: { select: { status: true, submittedAt: true } },
|
evaluation: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
submittedAt: true,
|
||||||
|
criterionScoresJson: true,
|
||||||
|
form: { select: { criteriaJson: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
|
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
Reference in New Issue
Block a user