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

- 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:
2026-02-16 01:16:55 +01:00
parent fbb194067d
commit 4c0efb232c
23 changed files with 5745 additions and 891 deletions

View File

@@ -25,7 +25,15 @@ import {
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
type AutoTagRule = {
id: string
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
operator: 'equals' | 'contains' | 'in'
value: string
}
export default function EditAwardPage({
params,
@@ -37,6 +45,14 @@ export default function EditAwardPage({
const utils = trpc.useUtils()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
// Fetch competition rounds for source round selector
const competitionId = award?.competitionId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId! },
{ enabled: !!competitionId }
)
const updateAward = trpc.specialAward.update.useMutation({
onSuccess: () => {
utils.specialAward.get.invalidate({ id: awardId })
@@ -52,6 +68,9 @@ export default function EditAwardPage({
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
const [votingStartAt, setVotingStartAt] = useState('')
const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
// Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -72,6 +91,16 @@ export default function EditAwardPage({
setMaxRankedPicks(String(award.maxRankedPicks || 3))
setVotingStartAt(formatDateForInput(award.votingStartAt))
setVotingEndAt(formatDateForInput(award.votingEndAt))
setEvaluationRoundId(award.evaluationRoundId || '')
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
// Parse autoTagRulesJson
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
setAutoTagRules(rules.rules || [])
} else {
setAutoTagRules([])
}
}
}, [award])
@@ -88,6 +117,9 @@ export default function EditAwardPage({
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
evaluationRoundId: evaluationRoundId || undefined,
eligibilityMode,
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
})
toast.success('Award updated')
router.push(`/admin/awards/${awardId}`)
@@ -98,6 +130,28 @@ export default function EditAwardPage({
}
}
const addRule = () => {
setAutoTagRules([
...autoTagRules,
{
id: `rule-${Date.now()}`,
field: 'competitionCategory',
operator: 'equals',
value: '',
},
])
}
const removeRule = (id: string) => {
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
}
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
setAutoTagRules(
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
)
}
if (isLoading) {
return (
<div className="space-y-6">
@@ -231,6 +285,198 @@ export default function EditAwardPage({
</CardContent>
</Card>
{/* Source Round & Eligibility */}
<Card>
<CardHeader>
<CardTitle>Source Round & Pool</CardTitle>
<CardDescription>
Define which round feeds projects into this award and how they interact with the main competition
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sourceRound">Source Round</Label>
<Select
value={evaluationRoundId || 'none'}
onValueChange={(v) => setEvaluationRoundId(v === 'none' ? '' : v)}
>
<SelectTrigger id="sourceRound">
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No source round</SelectItem>
{competition?.rounds
?.sort((a, b) => a.sortOrder - b.sortOrder)
.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Projects from this round will be considered for award eligibility
</p>
</div>
<div className="space-y-2">
<Label htmlFor="eligibilityMode">Eligibility Mode</Label>
<Select
value={eligibilityMode}
onValueChange={(v) =>
setEligibilityMode(v as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
}
>
<SelectTrigger id="eligibilityMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STAY_IN_MAIN">
Stay in Main Projects remain in competition
</SelectItem>
<SelectItem value="SEPARATE_POOL">
Separate Pool Projects exit to award track
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Whether award-eligible projects continue in the main competition or move to a separate track
</p>
</div>
</div>
</CardContent>
</Card>
{/* Auto-Tag Rules */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Auto-Tag Rules</CardTitle>
<CardDescription>
Deterministic eligibility rules based on project metadata
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addRule}>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{autoTagRules.length === 0 ? (
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p>
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
Rules work together with the source round setting.
</p>
</div>
) : (
<div className="space-y-3">
{autoTagRules.map((rule, index) => (
<div
key={rule.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="flex-1 grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Field</Label>
<Select
value={rule.field}
onValueChange={(v) =>
updateRule(rule.id, {
field: v as AutoTagRule['field'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="competitionCategory">
Competition Category
</SelectItem>
<SelectItem value="country">Country</SelectItem>
<SelectItem value="geographicZone">
Geographic Zone
</SelectItem>
<SelectItem value="tags">Tags</SelectItem>
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Operator</Label>
<Select
value={rule.operator}
onValueChange={(v) =>
updateRule(rule.id, {
operator: v as AutoTagRule['operator'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="in">In (comma-separated)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Value</Label>
<Input
className="h-9"
value={rule.value}
onChange={(e) =>
updateRule(rule.id, { value: e.target.value })
}
placeholder={
rule.operator === 'in'
? 'value1,value2,value3'
: 'Enter value...'
}
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeRule(rule.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{autoTagRules.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
<Info className="h-3 w-3 mt-0.5 shrink-0" />
<p>
<strong>How it works:</strong> Filter from{' '}
<Badge variant="outline" className="mx-1">
{evaluationRoundId
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
?.name || 'Selected Round'
: 'All Projects'}
</Badge>
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
</p>
</div>
)}
</CardContent>
</Card>
{/* Voting Window Card */}
<Card>
<CardHeader>