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

@@ -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 (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
</div>
)
}
if (!group) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Jury Group Not Found</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">The requested jury group could not be found.</p>
<Button asChild className="mt-4" variant="outline">
<Link href={'/admin/juries' as Route}>
Back to Juries
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-3">
<Button
variant="ghost"
size="sm"
asChild
className="mb-2"
>
<Link href={'/admin/juries' as Route}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Juries
</Link>
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{group.name}</h1>
<Badge
variant="secondary"
className={cn('text-xs', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{competition?.name ?? 'Loading...'}
</p>
</div>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="members" className="space-y-4">
<TabsList>
<TabsTrigger value="members">
<Users className="h-4 w-4 mr-1" />
Members
</TabsTrigger>
<TabsTrigger value="settings">
<Settings className="h-4 w-4 mr-1" />
Settings
</TabsTrigger>
</TabsList>
{/* Members Tab */}
<TabsContent value="members" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Members</CardTitle>
<CardDescription>
{group.members.length} member{group.members.length === 1 ? '' : 's'}
</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{group.members.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No members yet. Add jury members to this group.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Cap Override</TableHead>
<TableHead>Availability</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.members.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.user.name || 'Unnamed'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell>
<Select
value={member.role}
onValueChange={(value) =>
handleRoleChange(member.id, value as 'CHAIR' | 'MEMBER' | 'OBSERVER')
}
disabled={updateMemberMutation.isPending}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
{member.maxAssignmentsOverride ?? (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{member.availabilityNotes ? (
<span className="text-xs">{member.availabilityNotes}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveMember(member.id)}
disabled={removeMemberMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-4">
<SettingsForm
group={group}
onSave={(data) => updateGroupMutation.mutate({ id: groupId, ...data })}
isPending={updateGroupMutation.isPending}
/>
{/* Self-Service Review Section */}
{selfServiceData && selfServiceData.members.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Self-Service Values</CardTitle>
<CardDescription>
Members who set their own capacity or ratio during onboarding
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Admin Cap</TableHead>
<TableHead>Self-Service Cap</TableHead>
<TableHead>Self-Service Ratio</TableHead>
<TableHead>Preferred Ratio</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selfServiceData.members.map((m) => (
<TableRow key={m.id}>
<TableCell>
<div className="font-medium">{m.userName}</div>
<div className="text-xs text-muted-foreground">{m.userEmail}</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.role}</Badge>
</TableCell>
<TableCell>{m.adminCap}</TableCell>
<TableCell>
{m.selfServiceCap ?? <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
{m.selfServiceRatio !== null ? (
<span>{(m.selfServiceRatio * 100).toFixed(0)}%</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{m.preferredStartupRatio !== null ? (
<span>{(m.preferredStartupRatio * 100).toFixed(0)}%</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* Add Member Dialog */}
<Dialog open={addMemberDialogOpen} onOpenChange={setAddMemberDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
Search for a jury member to add to this group
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Search Users</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{loadingUsers ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : userSearchResults?.users && userSearchResults.users.length > 0 ? (
<div className="border rounded-md max-h-64 overflow-y-auto">
{userSearchResults.users.map((user) => (
<div
key={user.id}
className={cn(
'p-3 border-b last:border-b-0 cursor-pointer hover:bg-muted/50 transition-colors',
selectedUserId === user.id && 'bg-primary/10'
)}
onClick={() => setSelectedUserId(user.id)}
>
<div className="font-medium">{user.name || 'Unnamed'}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
No users found. Try a different search.
</p>
)}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as 'CHAIR' | 'MEMBER' | 'OBSERVER')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Max Assignments Override (optional)</Label>
<Input
type="number"
min="1"
placeholder={`Default: ${group.defaultMaxAssignments}`}
value={maxAssignmentsOverride}
onChange={(e) => setMaxAssignmentsOverride(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddMember} disabled={addMemberMutation.isPending || !selectedUserId}>
{addMemberMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Adding...
</>
) : (
'Add Member'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── 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 (
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Configure jury group defaults and permissions</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Jury group name"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Default Max Assignments</Label>
<Input
type="number"
min="1"
value={formData.defaultMaxAssignments}
onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
}
/>
</div>
<div className="space-y-2">
<Label>Cap Mode</Label>
<Select
value={formData.defaultCapMode}
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{formData.defaultCapMode === 'SOFT' && (
<div className="space-y-2">
<Label>Soft Cap Buffer</Label>
<Input
type="number"
min="0"
value={formData.softCapBuffer}
onChange={(e) =>
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
}
/>
<p className="text-xs text-muted-foreground">
Number of assignments allowed above the cap when in soft mode
</p>
</div>
)}
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Category Quotas Enabled</Label>
<p className="text-xs text-muted-foreground">
Enable category-based assignment quotas
</p>
</div>
<Switch
checked={formData.categoryQuotasEnabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, categoryQuotasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Cap Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own assignment cap during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorCapAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorCapAdjustment: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Ratio Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own startup/concept ratio during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorRatioAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
}
/>
</div>
</div>
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Saving...
</>
) : (
'Save Settings'
)}
</Button>
</form>
</CardContent>
</Card>
)
}