Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION - Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards) - Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review) - Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone - Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields - Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish - Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors - Bulk upload page for admin project imports - File router enhanced with admin upload and submission window procedures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,36 +14,70 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface LiveVotingCriterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
weight: number
|
||||
}
|
||||
|
||||
interface LiveVotingFormProps {
|
||||
sessionId?: string;
|
||||
projectId: string;
|
||||
onVoteSubmit: (vote: { score: number }) => void;
|
||||
disabled?: boolean;
|
||||
projectId: string
|
||||
votingMode?: 'simple' | 'criteria'
|
||||
criteria?: LiveVotingCriterion[]
|
||||
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void
|
||||
disabled?: boolean
|
||||
existingVote?: {
|
||||
score: number
|
||||
criterionScoresJson?: Record<string, number>
|
||||
} | null
|
||||
}
|
||||
|
||||
export function LiveVotingForm({
|
||||
sessionId,
|
||||
projectId,
|
||||
votingMode = 'simple',
|
||||
criteria,
|
||||
onVoteSubmit,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
existingVote,
|
||||
}: LiveVotingFormProps) {
|
||||
const [score, setScore] = useState(50);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const [score, setScore] = useState(existingVote?.score ?? 50)
|
||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
|
||||
existingVote?.criterionScoresJson ?? {}
|
||||
)
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote)
|
||||
|
||||
const handleSubmit = () => {
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
setConfirmDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onVoteSubmit({ score });
|
||||
setHasSubmitted(true);
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
if (votingMode === 'criteria' && criteria) {
|
||||
// Compute weighted score for display
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const normalizedScore = (criterionScores[c.id] / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display
|
||||
|
||||
onVoteSubmit({
|
||||
score: computedScore,
|
||||
criterionScores,
|
||||
})
|
||||
} else {
|
||||
onVoteSubmit({ score })
|
||||
}
|
||||
|
||||
setHasSubmitted(true)
|
||||
setConfirmDialogOpen(false)
|
||||
}
|
||||
|
||||
if (hasSubmitted || disabled) {
|
||||
return (
|
||||
@@ -51,12 +85,126 @@ export function LiveVotingForm({
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
|
||||
<p className="font-medium">Vote Submitted</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
|
||||
{votingMode === 'simple' && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
|
||||
)}
|
||||
{votingMode === 'criteria' && criteria && (
|
||||
<div className="mt-3 text-sm text-muted-foreground space-y-1">
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="flex justify-between gap-4">
|
||||
<span>{c.label}:</span>
|
||||
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Criteria-based voting
|
||||
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
|
||||
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Criteria-Based Voting</CardTitle>
|
||||
<CardDescription>Score each criterion individually</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{criteria.map((criterion) => (
|
||||
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Label className="text-base font-semibold">{criterion.label}</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{criterion.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Weight: {(criterion.weight * 100).toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={criterion.scale}
|
||||
value={criterionScores[criterion.id] ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10)
|
||||
if (!isNaN(val)) {
|
||||
setCriterionScores({
|
||||
...criterionScores,
|
||||
[criterion.id]: Math.min(criterion.scale, Math.max(1, val)),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-20 text-center"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-lg font-bold">/ {criterion.scale}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[criterionScores[criterion.id] ?? 0]}
|
||||
onValueChange={(values) =>
|
||||
setCriterionScores({
|
||||
...criterionScores,
|
||||
[criterion.id]: values[0],
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={criterion.scale}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allScored}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Submit Vote
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="font-medium">Your scores:</p>
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="flex justify-between text-sm">
|
||||
<span>{c.label}:</span>
|
||||
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
This action cannot be undone. Are you sure?
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple voting (0-100 slider)
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
@@ -120,5 +268,5 @@ export function LiveVotingForm({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user