Add implementation plan for advance criterion and juror progress dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
844
docs/plans/2026-02-25-advance-criterion-plan.md
Normal file
844
docs/plans/2026-02-25-advance-criterion-plan.md
Normal file
@@ -0,0 +1,844 @@
|
||||
# Advance Criterion & Juror Progress Dashboard — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add an `advance` criterion type to the evaluation form system, a juror-facing progress dashboard showing past submissions with scores and advance decisions, and admin-facing summary card + table column for advancement votes.
|
||||
|
||||
**Architecture:** The `advance` type is added to the existing criterion type union and flows through the same `criteriaJson`/`criterionScoresJson` JSON columns — no Prisma schema migration. A new `showJurorProgressDashboard` field in `EvaluationConfig` gates the juror view. A new tRPC query aggregates the juror's submissions. Admin components get an extra column and a summary card.
|
||||
|
||||
**Tech Stack:** TypeScript, tRPC, Prisma (JSON columns), React, shadcn/ui, Tailwind CSS, Zod
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `advance` to CriterionType and Form Builder Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:57` (CriterionType union)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:96-114` (createDefaultCriterion)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:117-122` (CRITERION_TYPE_OPTIONS)
|
||||
|
||||
**Step 1: Update the CriterionType union**
|
||||
|
||||
In `evaluation-form-builder.tsx` line 57, change:
|
||||
```ts
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
|
||||
```
|
||||
|
||||
**Step 2: Add default creation for `advance` type**
|
||||
|
||||
In `createDefaultCriterion` (line 96), add a new case before `section_header`:
|
||||
```ts
|
||||
case 'advance':
|
||||
return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||
```
|
||||
|
||||
**Step 3: Add `advance` to the type options array**
|
||||
|
||||
In `CRITERION_TYPE_OPTIONS` (line 117), add an import for a suitable icon (e.g., `ArrowUpCircle` from lucide-react) and add the entry. Note: this button will be rendered separately with disable logic, so do NOT add it to `CRITERION_TYPE_OPTIONS`. Instead, we'll add a standalone button in Task 2.
|
||||
|
||||
Actually — to keep things clean, do NOT add `advance` to `CRITERION_TYPE_OPTIONS`. The advance button is rendered separately with one-per-form enforcement. See Task 2.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add advance criterion type to CriterionType union and defaults"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add "Advance to Next Round?" Button in Form Builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:39-54` (imports — add ArrowUpCircle)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:671-690` (add buttons section)
|
||||
|
||||
**Step 1: Add the `ArrowUpCircle` icon import**
|
||||
|
||||
At line 39 in the lucide-react import block, add `ArrowUpCircle` to the imports.
|
||||
|
||||
**Step 2: Add the advance button with one-per-form enforcement**
|
||||
|
||||
After the `CRITERION_TYPE_OPTIONS.map(...)` buttons (around line 685), before the PreviewDialog, add:
|
||||
```tsx
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addCriterion('advance')}
|
||||
disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
|
||||
title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
|
||||
className={cn(
|
||||
'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
|
||||
criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle className="mr-1 h-4 w-4" />
|
||||
Advance to Next Round?
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add advance criterion button with one-per-form enforcement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Edit Mode and Preview for `advance` Criterion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — edit mode section (around lines 237-414)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — preview dialog (around lines 787-798)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — type badge display in list view
|
||||
|
||||
**Step 1: Add edit mode fields for `advance` type**
|
||||
|
||||
In the edit mode form (after the `boolean` block ending around line 414), add a block for `advance`:
|
||||
```tsx
|
||||
{(editDraft.type) === 'advance' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
|
||||
<Input
|
||||
id={`trueLabel-${criterion.id}`}
|
||||
value={editDraft.trueLabel || 'Yes'}
|
||||
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
|
||||
placeholder="Yes"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
|
||||
<Input
|
||||
id={`falseLabel-${criterion.id}`}
|
||||
value={editDraft.falseLabel || 'No'}
|
||||
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
|
||||
placeholder="No"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: No `required` toggle (always true), no `weight`, no `condition` fields for advance type.
|
||||
|
||||
**Step 2: Add the type badge rendering**
|
||||
|
||||
Find where the type badge is shown in list view (around line 237-240). The existing code uses `CRITERION_TYPE_OPTIONS.find(...)`. For `advance`, it won't find a match so will show nothing. Add a fallback or handle it. Where the badge text is resolved, add:
|
||||
```ts
|
||||
editDraft.type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find(...)?.label ?? 'Numeric Score'
|
||||
```
|
||||
|
||||
**Step 3: Add preview rendering for `advance` type**
|
||||
|
||||
In the PreviewDialog (around line 787), after the `boolean` rendering block, add:
|
||||
```tsx
|
||||
{type === 'advance' && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</div>
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add edit mode and preview rendering for advance criterion type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Server-Side — Accept `advance` in `upsertForm` and `submit` Validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/evaluation.ts:1230` (upsertForm Zod input — add 'advance' to type enum)
|
||||
- Modify: `src/server/routers/evaluation.ts:1270-1304` (criteriaJson builder — add advance case)
|
||||
- Modify: `src/server/routers/evaluation.ts:238-260` (submit validation — handle advance type)
|
||||
|
||||
**Step 1: Add `advance` to the Zod type enum in upsertForm input**
|
||||
|
||||
At line 1230, change:
|
||||
```ts
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(),
|
||||
```
|
||||
|
||||
**Step 2: Add advance case in criteriaJson builder**
|
||||
|
||||
After the `boolean` case (line 1295-1300), add:
|
||||
```ts
|
||||
if (type === 'advance') {
|
||||
return {
|
||||
...base,
|
||||
required: true, // always required, override any input
|
||||
trueLabel: c.trueLabel || 'Yes',
|
||||
falseLabel: c.falseLabel || 'No',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add server-side one-per-form validation**
|
||||
|
||||
In the `upsertForm` mutation, after line 1256 (`const { roundId, criteria } = input`), add:
|
||||
```ts
|
||||
// Enforce max one advance criterion per form
|
||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||
if (advanceCount > 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only one advance criterion is allowed per evaluation form',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Handle `advance` in submit validation**
|
||||
|
||||
In the `requireAllCriteriaScored` block (line 242-252), the `scorableCriteria` filter excludes `section_header` and `text`. The `advance` type should be treated like `boolean` — it's a required boolean. Update the missing criteria check:
|
||||
|
||||
At line 250, change:
|
||||
```ts
|
||||
if (c.type === 'boolean') return typeof val !== 'boolean'
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add src/server/routers/evaluation.ts
|
||||
git commit -m "feat: server-side support for advance criterion type in upsertForm and submit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Juror Evaluation Page — Render `advance` Criterion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx:660-703` (boolean rendering — add advance case)
|
||||
- Modify: same file, client-side validation (around line 355-360)
|
||||
|
||||
**Step 1: Add advance criterion rendering in the evaluation form**
|
||||
|
||||
After the boolean rendering block (line 660-703), add a new block for `advance`. It should look similar to boolean but with larger, more prominent buttons and a colored border:
|
||||
|
||||
```tsx
|
||||
if (criterion.type === 'advance') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-semibold text-brand-blue">
|
||||
{criterion.label}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update client-side validation**
|
||||
|
||||
In the client-side submit validation (around line 355-360), where boolean required criteria are checked, ensure `advance` is also handled. Find the block that checks for boolean criteria values and add `|| c.type === 'advance'` to the condition.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx"
|
||||
git commit -m "feat: render advance criterion on juror evaluation page with prominent styling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `showJurorProgressDashboard` to EvaluationConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types/competition-configs.ts:90-141` (EvaluationConfigSchema — add field)
|
||||
- Modify: `src/components/admin/rounds/config/evaluation-config.tsx` (add toggle)
|
||||
|
||||
**Step 1: Add the field to the Zod schema**
|
||||
|
||||
In `EvaluationConfigSchema` (line 90), add after line 103 (`peerReviewEnabled`):
|
||||
```ts
|
||||
showJurorProgressDashboard: z.boolean().default(false),
|
||||
```
|
||||
|
||||
**Step 2: Add the toggle in the admin config UI**
|
||||
|
||||
In `evaluation-config.tsx`, in the Feedback Requirements card (after the `peerReviewEnabled` switch, around line 176), add:
|
||||
```tsx
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
|
||||
<p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showJurorProgressDashboard"
|
||||
checked={(config.showJurorProgressDashboard as boolean) ?? false}
|
||||
onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add src/types/competition-configs.ts src/components/admin/rounds/config/evaluation-config.tsx
|
||||
git commit -m "feat: add showJurorProgressDashboard toggle to EvaluationConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: New tRPC Query — `evaluation.getMyProgress`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/evaluation.ts` (add new juryProcedure query at the end of the router)
|
||||
|
||||
**Step 1: Add the query**
|
||||
|
||||
Add this query to the `evaluationRouter` (before the closing `})` of the router):
|
||||
|
||||
```ts
|
||||
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,
|
||||
}
|
||||
}),
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/server/routers/evaluation.ts
|
||||
git commit -m "feat: add evaluation.getMyProgress tRPC query for juror dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Juror Progress Dashboard Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/jury/juror-progress-dashboard.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
'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'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/components/jury/juror-progress-dashboard.tsx
|
||||
git commit -m "feat: create JurorProgressDashboard component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wire Juror Progress Dashboard into Round Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/[roundId]/page.tsx`
|
||||
|
||||
**Step 1: Import the component and add it to the page**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||
```
|
||||
|
||||
**Step 2: Fetch round config and conditionally render**
|
||||
|
||||
The page already fetches `round` via `trpc.round.getById.useQuery`. Use it to check the config:
|
||||
|
||||
After the heading `<div>` (around line 53) and before the `<Card>` with "Assigned Projects" (line 56), add:
|
||||
```tsx
|
||||
{(() => {
|
||||
const config = (round?.configJson as Record<string, unknown>) ?? {}
|
||||
if (config.showJurorProgressDashboard) {
|
||||
return <JurorProgressDashboard roundId={roundId} />
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(jury)/jury/competitions/[roundId]/page.tsx"
|
||||
git commit -m "feat: wire JurorProgressDashboard into jury round detail page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Admin — Add "Advance" Column to Assignments Table
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:315-319` (column header)
|
||||
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:325-351` (row rendering)
|
||||
|
||||
**Step 1: Add the column header**
|
||||
|
||||
At line 315, change the grid from `grid-cols-[1fr_1fr_100px_70px]` to `grid-cols-[1fr_1fr_80px_80px_70px]` and add an "Advance" header:
|
||||
```tsx
|
||||
<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>Project</span>
|
||||
<span>Status</span>
|
||||
<span>Advance</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Update row grid and add the advance cell**
|
||||
|
||||
At line 325, update the grid class to match: `grid-cols-[1fr_1fr_80px_80px_70px]`.
|
||||
|
||||
After the Status cell (line 351 `</div>`) and before the DropdownMenu (line 352), add:
|
||||
```tsx
|
||||
<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) => 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>
|
||||
```
|
||||
|
||||
**Step 3: Ensure the query includes form data**
|
||||
|
||||
Check that `trpc.assignment.listByStage` includes `evaluation.form` in its response. If it doesn't, we need to add `form: { select: { criteriaJson: true } }` to the evaluation include in the `listByStage` query in `src/server/routers/assignment.ts`. Look for the `listByStage` procedure and update its evaluation include.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/admin/assignment/individual-assignments-table.tsx
|
||||
git add src/server/routers/assignment.ts # if modified
|
||||
git commit -m "feat: add Advance column to admin individual assignments table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Admin — Advancement Summary Card
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/round/advancement-summary-card.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/components/admin/round/advancement-summary-card.tsx
|
||||
git commit -m "feat: create AdvancementSummaryCard admin component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Wire Advancement Summary Card into Admin Round Detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (overview tab, around line 871)
|
||||
|
||||
**Step 1: Import the component**
|
||||
|
||||
Add at the imports section:
|
||||
```ts
|
||||
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||
```
|
||||
|
||||
**Step 2: Add it to the overview tab**
|
||||
|
||||
In the overview tab content (after the Launch Readiness card, around line 943), add:
|
||||
```tsx
|
||||
{isEvaluation && <AdvancementSummaryCard roundId={roundId} />}
|
||||
```
|
||||
|
||||
Where `isEvaluation` is the existing variable that checks `round.roundType === 'EVALUATION'`.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
|
||||
git commit -m "feat: wire AdvancementSummaryCard into admin round overview tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Build and Typecheck
|
||||
|
||||
**Step 1: Run typecheck**
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
Expected: No errors (fix any that appear).
|
||||
|
||||
**Step 2: Run build**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: Successful build.
|
||||
|
||||
**Step 3: Fix any issues and commit**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve any type or build errors from advance criterion feature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Manual QA Checklist
|
||||
|
||||
Run `npm run dev` and verify:
|
||||
|
||||
1. **Form builder**: Admin can add "Advance to Next Round?" criterion. Button disables after one is added. Edit mode shows trueLabel/falseLabel. Preview renders correctly.
|
||||
2. **Juror evaluation**: Advance criterion renders with prominent green/red buttons. Required validation works. Autosave works. Submit stores value in `criterionScoresJson`.
|
||||
3. **Juror dashboard**: When `showJurorProgressDashboard` is enabled in round config, the progress card appears with progress bar, YES/NO counts, and submissions table sorted by date.
|
||||
4. **Admin config**: The "Juror Progress Dashboard" toggle appears in the Evaluation round config.
|
||||
5. **Admin assignments table**: "Advance" column appears with YES/NO/— badges.
|
||||
6. **Admin overview**: `AdvancementSummaryCard` renders with correct counts and stacked bar.
|
||||
Reference in New Issue
Block a user