From 4c0efb232cc670e0477c4230ef31598968faf51b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 01:16:55 +0100 Subject: [PATCH] Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../(admin)/admin/awards/[id]/edit/page.tsx | 248 ++++- .../(admin)/admin/juries/[groupId]/page.tsx | 674 ++++++++++++++ src/app/(admin)/admin/juries/page.tsx | 307 +++++++ src/app/(admin)/admin/projects/[id]/page.tsx | 191 ++-- .../admin/projects/bulk-upload/page.tsx | 689 ++++++++++++++ src/app/(admin)/admin/projects/page.tsx | 6 + .../(admin)/admin/rounds/[roundId]/page.tsx | 408 +++++++++ src/app/(admin)/admin/rounds/page.tsx | 552 ++++++++++++ .../jury/competitions/[roundId]/live/page.tsx | 102 ++- .../projects/[projectId]/evaluate/page.tsx | 367 ++++++-- .../admin/competition/round-config-form.tsx | 267 +----- .../rounds/config/deliberation-config.tsx | 176 ++++ .../admin/rounds/config/evaluation-config.tsx | 283 ++++++ .../config}/file-requirements-editor.tsx | 850 +++++++++--------- .../admin/rounds/config/filtering-config.tsx | 193 ++++ .../admin/rounds/config/intake-config.tsx | 296 ++++++ .../admin/rounds/config/live-final-config.tsx | 286 ++++++ .../admin/rounds/config/mentoring-config.tsx | 133 +++ .../admin/rounds/config/submission-config.tsx | 108 +++ src/components/jury/live-voting-form.tsx | 204 ++++- src/components/layouts/admin-sidebar.tsx | 14 +- src/server/routers/file.ts | 261 ++++++ src/server/routers/specialAward.ts | 21 + 23 files changed, 5745 insertions(+), 891 deletions(-) create mode 100644 src/app/(admin)/admin/juries/[groupId]/page.tsx create mode 100644 src/app/(admin)/admin/juries/page.tsx create mode 100644 src/app/(admin)/admin/projects/bulk-upload/page.tsx create mode 100644 src/app/(admin)/admin/rounds/[roundId]/page.tsx create mode 100644 src/app/(admin)/admin/rounds/page.tsx create mode 100644 src/components/admin/rounds/config/deliberation-config.tsx create mode 100644 src/components/admin/rounds/config/evaluation-config.tsx rename src/components/admin/{ => rounds/config}/file-requirements-editor.tsx (96%) create mode 100644 src/components/admin/rounds/config/filtering-config.tsx create mode 100644 src/components/admin/rounds/config/intake-config.tsx create mode 100644 src/components/admin/rounds/config/live-final-config.tsx create mode 100644 src/components/admin/rounds/config/mentoring-config.tsx create mode 100644 src/components/admin/rounds/config/submission-config.tsx diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx index 207d5cd..8d00f36 100644 --- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx @@ -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([]) // 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) => { + setAutoTagRules( + autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r)) + ) + } + if (isLoading) { return (
@@ -231,6 +285,198 @@ export default function EditAwardPage({ + {/* Source Round & Eligibility */} + + + Source Round & Pool + + Define which round feeds projects into this award and how they interact with the main competition + + + +
+
+ + +

+ Projects from this round will be considered for award eligibility +

+
+ +
+ + +

+ Whether award-eligible projects continue in the main competition or move to a separate track +

+
+
+
+
+ + {/* Auto-Tag Rules */} + + +
+
+ Auto-Tag Rules + + Deterministic eligibility rules based on project metadata + +
+ +
+
+ + {autoTagRules.length === 0 ? ( +
+ +

+ 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. +

+
+ ) : ( +
+ {autoTagRules.map((rule, index) => ( +
+
+
+ + +
+ +
+ + +
+ +
+ + + updateRule(rule.id, { value: e.target.value }) + } + placeholder={ + rule.operator === 'in' + ? 'value1,value2,value3' + : 'Enter value...' + } + /> +
+
+ + +
+ ))} +
+ )} + + {autoTagRules.length > 0 && ( +
+ +

+ How it works: Filter from{' '} + + {evaluationRoundId + ? competition?.rounds?.find((r) => r.id === evaluationRoundId) + ?.name || 'Selected Round' + : 'All Projects'} + + , where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible. +

+
+ )} +
+
+ {/* Voting Window Card */} diff --git a/src/app/(admin)/admin/juries/[groupId]/page.tsx b/src/app/(admin)/admin/juries/[groupId]/page.tsx new file mode 100644 index 0000000..c1ece75 --- /dev/null +++ b/src/app/(admin)/admin/juries/[groupId]/page.tsx @@ -0,0 +1,674 @@ +'use client' + +import { use, useState } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { + ArrowLeft, + Plus, + Loader2, + Trash2, + Users, + Settings, + Search, +} from 'lucide-react' + +const capModeLabels = { + HARD: 'Hard Cap', + SOFT: 'Soft Cap', + NONE: 'No Cap', +} + +const capModeColors = { + HARD: 'bg-red-100 text-red-700', + SOFT: 'bg-amber-100 text-amber-700', + NONE: 'bg-gray-100 text-gray-600', +} + +type JuryGroupDetailPageProps = { + params: Promise<{ groupId: string }> +} + +export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps) { + const resolvedParams = use(params) + const groupId = resolvedParams.groupId + const router = useRouter() + const utils = trpc.useUtils() + + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false) + const [userSearch, setUserSearch] = useState('') + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRole, setSelectedRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') + const [maxAssignmentsOverride, setMaxAssignmentsOverride] = useState('') + + const { data: group, isLoading: loadingGroup } = trpc.juryGroup.getById.useQuery( + { id: groupId }, + { enabled: !!groupId } + ) + + const { data: competition, isLoading: loadingCompetition } = trpc.competition.getById.useQuery( + { id: group?.competitionId ?? '' }, + { enabled: !!group?.competitionId } + ) + + const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery( + { + role: 'JURY_MEMBER', + search: userSearch, + page: 1, + perPage: 20, + }, + { enabled: addMemberDialogOpen } + ) + + const { data: selfServiceData } = trpc.juryGroup.reviewSelfServiceValues.useQuery( + { juryGroupId: groupId }, + { enabled: !!groupId } + ) + + const addMemberMutation = trpc.juryGroup.addMember.useMutation({ + onSuccess: () => { + utils.juryGroup.getById.invalidate({ id: groupId }) + utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId }) + toast.success('Member added') + setAddMemberDialogOpen(false) + setSelectedUserId('') + setUserSearch('') + setMaxAssignmentsOverride('') + }, + onError: (err) => toast.error(err.message), + }) + + const removeMemberMutation = trpc.juryGroup.removeMember.useMutation({ + onSuccess: () => { + utils.juryGroup.getById.invalidate({ id: groupId }) + utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId }) + toast.success('Member removed') + }, + onError: (err) => toast.error(err.message), + }) + + const updateMemberMutation = trpc.juryGroup.updateMember.useMutation({ + onSuccess: () => { + utils.juryGroup.getById.invalidate({ id: groupId }) + toast.success('Member updated') + }, + onError: (err) => toast.error(err.message), + }) + + const updateGroupMutation = trpc.juryGroup.update.useMutation({ + onSuccess: () => { + utils.juryGroup.getById.invalidate({ id: groupId }) + utils.juryGroup.list.invalidate() + toast.success('Jury group updated') + }, + onError: (err) => toast.error(err.message), + }) + + const handleAddMember = () => { + if (!selectedUserId) { + toast.error('Please select a user') + return + } + + addMemberMutation.mutate({ + juryGroupId: groupId, + userId: selectedUserId, + role: selectedRole, + maxAssignmentsOverride: maxAssignmentsOverride + ? parseInt(maxAssignmentsOverride, 10) + : null, + }) + } + + const handleRemoveMember = (memberId: string) => { + if (!confirm('Remove this member from the jury group?')) return + removeMemberMutation.mutate({ id: memberId }) + } + + const handleRoleChange = (memberId: string, role: 'CHAIR' | 'MEMBER' | 'OBSERVER') => { + updateMemberMutation.mutate({ id: memberId, role }) + } + + if (loadingGroup || loadingCompetition) { + return ( +
+ + + + + + + + + +
+ ) + } + + if (!group) { + return ( +
+

Jury Group Not Found

+ + +

The requested jury group could not be found.

+ +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+ +
+
+
+

{group.name}

+ + {capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]} + +
+

+ {competition?.name ?? 'Loading...'} +

+
+
+
+ + {/* Tabs */} + + + + + Members + + + + Settings + + + + {/* Members Tab */} + + + +
+
+ Members + + {group.members.length} member{group.members.length === 1 ? '' : 's'} + +
+ +
+
+ + {group.members.length === 0 ? ( +

+ No members yet. Add jury members to this group. +

+ ) : ( + + + + Name + Email + Role + Cap Override + Availability + Actions + + + + {group.members.map((member) => ( + + + {member.user.name || 'Unnamed'} + + + {member.user.email} + + + + + + {member.maxAssignmentsOverride ?? ( + + )} + + + {member.availabilityNotes ? ( + {member.availabilityNotes} + ) : ( + + )} + + + + + + ))} + +
+ )} +
+
+
+ + {/* Settings Tab */} + + updateGroupMutation.mutate({ id: groupId, ...data })} + isPending={updateGroupMutation.isPending} + /> + + {/* Self-Service Review Section */} + {selfServiceData && selfServiceData.members.length > 0 && ( + + + Self-Service Values + + Members who set their own capacity or ratio during onboarding + + + + + + + Member + Role + Admin Cap + Self-Service Cap + Self-Service Ratio + Preferred Ratio + + + + {selfServiceData.members.map((m) => ( + + +
{m.userName}
+
{m.userEmail}
+
+ + {m.role} + + {m.adminCap} + + {m.selfServiceCap ?? } + + + {m.selfServiceRatio !== null ? ( + {(m.selfServiceRatio * 100).toFixed(0)}% + ) : ( + + )} + + + {m.preferredStartupRatio !== null ? ( + {(m.preferredStartupRatio * 100).toFixed(0)}% + ) : ( + + )} + +
+ ))} +
+
+
+
+ )} +
+
+ + {/* Add Member Dialog */} + + + + Add Member + + Search for a jury member to add to this group + + +
+
+ +
+ + setUserSearch(e.target.value)} + className="pl-9" + /> +
+
+ + {loadingUsers ? ( +
+ + +
+ ) : userSearchResults?.users && userSearchResults.users.length > 0 ? ( +
+ {userSearchResults.users.map((user) => ( +
setSelectedUserId(user.id)} + > +
{user.name || 'Unnamed'}
+
{user.email}
+
+ ))} +
+ ) : ( +

+ No users found. Try a different search. +

+ )} + +
+ + +
+ +
+ + setMaxAssignmentsOverride(e.target.value)} + /> +
+
+ + + + +
+
+
+ ) +} + +// ─── Settings Form Component ───────────────────────────────────────────────── + +type SettingsFormProps = { + group: any + onSave: (data: any) => void + isPending: boolean +} + +function SettingsForm({ group, onSave, isPending }: SettingsFormProps) { + const [formData, setFormData] = useState({ + name: group.name, + description: group.description || '', + defaultMaxAssignments: group.defaultMaxAssignments, + defaultCapMode: group.defaultCapMode, + softCapBuffer: group.softCapBuffer, + categoryQuotasEnabled: group.categoryQuotasEnabled, + allowJurorCapAdjustment: group.allowJurorCapAdjustment, + allowJurorRatioAdjustment: group.allowJurorRatioAdjustment, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave(formData) + } + + return ( + + + General Settings + Configure jury group defaults and permissions + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Jury group name" + /> +
+ +
+ +