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>

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>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} 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 { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Plus, Scale, Users, Loader2 } 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',
}
export default function JuriesPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const utils = trpc.useUtils()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [formData, setFormData] = useState({
competitionId: '',
name: '',
description: '',
})
const { data: competitions, isLoading: loadingCompetitions } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const createMutation = trpc.juryGroup.create.useMutation({
onSuccess: () => {
utils.juryGroup.list.invalidate()
toast.success('Jury group created')
setCreateDialogOpen(false)
setFormData({ competitionId: '', name: '', description: '' })
},
onError: (err) => toast.error(err.message),
})
const handleCreate = () => {
if (!formData.competitionId || !formData.name.trim()) {
toast.error('Competition and name are required')
return
}
const slug = formData.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
createMutation.mutate({
competitionId: formData.competitionId,
name: formData.name.trim(),
slug,
description: formData.description || undefined,
})
}
if (!programId) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Juries</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Scale className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">Select an edition from the sidebar</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold">Juries</h1>
<p className="text-sm text-muted-foreground">
Manage jury groups for {currentEdition?.name}
</p>
</div>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Create Jury Group
</Button>
</div>
{/* Loading */}
{loadingCompetitions && (
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!loadingCompetitions && (!competitions || competitions.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Scale className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Create a competition first, then add jury groups.
</p>
</CardContent>
</Card>
)}
{/* Competition Groups */}
{competitions && competitions.length > 0 && (
<div className="space-y-6">
{competitions.map((comp) => (
<CompetitionJuriesSection key={comp.id} competition={comp} />
))}
</div>
)}
{/* Create Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury panel for a competition.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Competition *</Label>
<Select
value={formData.competitionId}
onValueChange={(v) => setFormData({ ...formData, competitionId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Select competition" />
</SelectTrigger>
<SelectContent>
{competitions?.filter((c) => c.status !== 'ARCHIVED').map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Name *</Label>
<Input
placeholder="e.g. Technical Panel A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Creating...
</>
) : (
'Create'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Competition Section ─────────────────────────────────────────────────────
type CompetitionJuriesSectionProps = {
competition: any
}
function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps) {
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({
competitionId: competition.id,
})
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{competition.name}</CardTitle>
<CardDescription>
{juryGroups?.length || 0} jury group{juryGroups?.length === 1 ? '' : 's'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : !juryGroups || juryGroups.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No jury groups configured for this competition.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{juryGroups.map((group) => (
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
<Badge
variant="secondary"
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="h-3 w-3" />
<span>{group._count.members} members</span>
</div>
<div>
{group._count.assignments} assignments
</div>
</div>
<div className="text-xs text-muted-foreground">
Default max: {group.defaultMaxAssignments}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -87,20 +87,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch file requirements from the competition's intake round
// Note: This procedure may need to be updated or removed depending on new system
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
// { projectId },
// { enabled: !!project }
// )
const requirementsData = null // Placeholder until procedure is updated
// Fetch available rounds for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
// Fetch competitions for this project's program to get rounds
const { data: competitions } = trpc.competition.list.useQuery(
{ programId: project?.programId || '' },
{ enabled: !!project?.programId }
)
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
// Get first competition ID to fetch full details with rounds
const competitionId = competitions?.[0]?.id
// Fetch full competition details including rounds
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId || '' },
{ enabled: !!competitionId }
)
// Extract all rounds from the competition
const competitionRounds = competition?.rounds || []
// Fetch requirements for each round
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
trpc.file.listRequirements.useQuery({ roundId: round.id })
)
// Combine requirements from all rounds
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
const utils = trpc.useUtils()
@@ -157,7 +168,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
href={`/admin/programs/${project.programId}`}
className="hover:underline"
>
{programData?.name ?? 'Program'}
Program
</Link>
) : (
<span>No program</span>
@@ -526,84 +537,114 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
Files
</CardTitle>
<CardDescription>
Project documents and materials
Project documents and materials organized by competition round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Required Documents from Competition Intake Round */}
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
<CardContent className="space-y-6">
{/* Requirements organized by round */}
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
const isFulfilled = req.fulfilled
return (
<div
key={req.id ?? `req-${idx}`}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="secondary" className="text-xs shrink-0">
Required
</Badge>
{competitionRounds.map((round: { id: string; name: string }) => {
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
if (roundRequirements.length === 0) return null
return (
<div key={round.id} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{round.name}</h3>
<Badge variant="outline" className="text-xs">
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid gap-2">
{roundRequirements.map((req: any) => {
// Find file that fulfills this requirement
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
const isFulfilled = !!fulfilledFile
return (
<div
key={req.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="destructive" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
{req.description && (
<p className="text-xs text-muted-foreground truncate">
{req.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{req.acceptedMimeTypes.length > 0 && (
<span>
{req.acceptedMimeTypes.map((mime: string) => {
if (mime === 'application/pdf') return 'PDF'
if (mime === 'image/*') return 'Images'
if (mime === 'video/*') return 'Video'
if (mime.includes('wordprocessing')) return 'Word'
if (mime.includes('spreadsheet')) return 'Excel'
if (mime.includes('presentation')) return 'PowerPoint'
return mime.split('/')[1] || mime
}).join(', ')}
</span>
)}
{req.maxSizeMB && (
<span className="shrink-0"> Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
{fulfilledFile.fileName}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{req.description && (
<span className="truncate">{req.description}</span>
)}
{req.maxSizeMB && (
<span className="shrink-0">Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && req.fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{req.fulfilledFile.fileName}
</p>
{!isFulfilled && (
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
Missing
</span>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Missing
</span>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
})}
<Separator />
</>
)}
) : null}
{/* Additional Documents Upload */}
{/* General file upload section */}
<div>
<p className="text-sm font-semibold mb-3">
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
? 'Additional Documents'
: 'Upload New Files'}
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
</p>
<p className="text-xs text-muted-foreground mb-3">
Upload files not tied to specific requirements
</p>
<FileUpload
projectId={projectId}
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
// utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</div>
@@ -613,7 +654,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<>
<Separator />
<div>
<p className="text-sm font-semibold mb-3">All Files</p>
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
<FileViewer
projectId={projectId}
files={files.map((f) => ({

View File

@@ -0,0 +1,689 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ArrowLeft,
CheckCircle2,
Upload,
Search,
X,
Loader2,
FileUp,
AlertCircle,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { Pagination } from '@/components/shared/pagination'
type UploadState = {
progress: number
status: 'uploading' | 'complete' | 'error'
error?: string
}
// Key: `${projectId}:${requirementId}`
type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() {
const [windowId, setWindowId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(50)
const [uploads, setUploads] = useState<UploadMap>({})
// Bulk dialog
const [bulkProject, setBulkProject] = useState<{
id: string
title: string
requirements: Array<{
requirementId: string
label: string
mimeTypes: string[]
required: boolean
file: { id: string; fileName: string } | null
}>
} | null>(null)
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
// Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => {
setDebouncedSearch(value)
setPage(1)
}, 300)
}, [])
// Queries
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
{
submissionWindowId: windowId,
search: debouncedSearch || undefined,
status: statusFilter,
page,
pageSize: perPage,
},
{ enabled: !!windowId }
)
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
// Upload a single file for a project requirement
const uploadFileForRequirement = useCallback(
async (
projectId: string,
requirementId: string,
file: File,
submissionWindowId: string
) => {
const key = `${projectId}:${requirementId}`
setUploads((prev) => ({
...prev,
[key]: { progress: 0, status: 'uploading' },
}))
try {
const { uploadUrl } = await uploadMutation.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
submissionWindowId,
submissionFileRequirementId: requirementId,
})
// XHR upload with progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploads((prev) => ({
...prev,
[key]: { progress, status: 'uploading' },
}))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(`Upload failed with status ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
setUploads((prev) => ({
...prev,
[key]: { progress: 100, status: 'complete' },
}))
// Refetch data to show updated status
refetch()
} catch (error) {
const msg = error instanceof Error ? error.message : 'Upload failed'
setUploads((prev) => ({
...prev,
[key]: { progress: 0, status: 'error', error: msg },
}))
toast.error(`Upload failed: ${msg}`)
}
},
[uploadMutation, refetch]
)
// Handle single cell file pick
const handleCellUpload = useCallback(
(projectId: string, requirementId: string, mimeTypes: string[]) => {
const input = document.createElement('input')
input.type = 'file'
if (mimeTypes.length > 0) {
input.accept = mimeTypes.join(',')
}
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file && windowId) {
uploadFileForRequirement(projectId, requirementId, file, windowId)
}
}
input.click()
},
[windowId, uploadFileForRequirement]
)
// Handle bulk row upload
const handleBulkUploadAll = useCallback(async () => {
if (!bulkProject || !windowId) return
const entries = Object.entries(bulkFiles).filter(
([, file]) => file !== null
) as Array<[string, File]>
if (entries.length === 0) {
toast.error('No files selected')
return
}
// Upload all in parallel
await Promise.allSettled(
entries.map(([reqId, file]) =>
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
)
)
setBulkProject(null)
setBulkFiles({})
toast.success('Bulk upload complete')
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
const progressPercent =
data && data.totalProjects > 0
? Math.round((data.completeCount / data.totalProjects) * 100)
: 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/projects">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
<p className="text-muted-foreground">
Upload required documents for multiple projects at once
</p>
</div>
</div>
{/* Window Selector */}
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Window</CardTitle>
</CardHeader>
<CardContent>
{windowsLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={windowId}
onValueChange={(v) => {
setWindowId(v)
setPage(1)
setUploads({})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a submission window..." />
</SelectTrigger>
<SelectContent>
{windows?.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.competition.program.name} {w.competition.program.year} &mdash; {w.name}{' '}
({w.fileRequirements.length} requirement
{w.fileRequirements.length !== 1 ? 's' : ''})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</CardContent>
</Card>
{/* Content (only if window selected) */}
{windowId && data && (
<>
{/* Progress Summary */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">
{data.completeCount} / {data.totalProjects} projects have complete documents
</p>
<Badge variant={progressPercent === 100 ? 'success' : 'secondary'}>
{progressPercent}%
</Badge>
</div>
<Progress value={progressPercent} className="h-2" />
</CardContent>
</Card>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by project name or team..."
className="pl-10 pr-10"
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('')
setDebouncedSearch('')
setPage(1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as 'all' | 'missing' | 'complete')
setPage(1)
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="missing">Missing files</SelectItem>
<SelectItem value="complete">Complete</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
) : data.projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileUp className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
{debouncedSearch ? 'Try adjusting your search' : 'No projects in this program'}
</p>
</CardContent>
</Card>
) : (
<>
<Card>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Project</TableHead>
<TableHead>Applicant</TableHead>
{data.requirements.map((req) => (
<TableHead key={req.id} className="min-w-[160px] text-center">
<div>
{req.label}
{req.required && (
<span className="text-destructive ml-0.5">*</span>
)}
</div>
<div className="text-[10px] font-normal text-muted-foreground">
{req.mimeTypes.join(', ') || 'Any'}
</div>
</TableHead>
))}
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((row) => {
const missingRequired = row.requirements.filter(
(r) => r.required && !r.file
)
return (
<TableRow
key={row.project.id}
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
>
<TableCell>
<Link
href={`/admin/projects/${row.project.id}`}
className="font-medium hover:underline"
>
{row.project.title}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.project.submittedBy?.name ||
row.project.submittedBy?.email ||
row.project.teamName ||
'-'}
</TableCell>
{row.requirements.map((req) => {
const uploadKey = `${row.project.id}:${req.requirementId}`
const uploadState = uploads[uploadKey]
return (
<TableCell key={req.requirementId} className="text-center">
{uploadState?.status === 'uploading' ? (
<div className="flex flex-col items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<Progress
value={uploadState.progress}
className="h-1 w-16"
/>
<span className="text-[10px] text-muted-foreground">
{uploadState.progress}%
</span>
</div>
) : uploadState?.status === 'error' ? (
<div className="flex flex-col items-center gap-1">
<AlertCircle className="h-4 w-4 text-destructive" />
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
Retry
</Button>
</div>
) : req.file || uploadState?.status === 'complete' ? (
<div className="flex flex-col items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{req.file?.fileName ?? 'Uploaded'}
</span>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
<Upload className="mr-1 h-3 w-3" />
Upload
</Button>
)}
</TableCell>
)
})}
<TableCell className="text-center">
{missingRequired.length > 0 && (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => {
setBulkProject({
id: row.project.id,
title: row.project.title,
requirements: row.requirements,
})
setBulkFiles({})
}}
>
<FileUp className="mr-1 h-3 w-3" />
Upload All ({missingRequired.length})
</Button>
)}
{row.isComplete && (
<Badge variant="success" className="text-xs">
Complete
</Badge>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</Card>
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
onPerPageChange={(pp) => {
setPerPage(pp)
setPage(1)
}}
/>
</>
)}
</>
)}
{/* Bulk Upload Dialog */}
<Dialog
open={!!bulkProject}
onOpenChange={(open) => {
if (!open) {
setBulkProject(null)
setBulkFiles({})
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Upload Files for {bulkProject?.title}</DialogTitle>
<DialogDescription>
Select files for each missing requirement, then upload all at once.
</DialogDescription>
</DialogHeader>
{bulkProject && (
<div className="space-y-4 py-2">
{bulkProject.requirements
.filter((r) => !r.file)
.map((req) => {
const selectedFile = bulkFiles[req.requirementId]
const uploadKey = `${bulkProject.id}:${req.requirementId}`
const uploadState = uploads[uploadKey]
return (
<div
key={req.requirementId}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadState?.status === 'complete' &&
'border-green-500/50 bg-green-500/5',
uploadState?.status === 'error' &&
'border-destructive/50 bg-destructive/5'
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{req.label}
{req.required && (
<span className="text-destructive ml-0.5">*</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{req.mimeTypes.join(', ') || 'Any file type'}
</p>
{selectedFile && !uploadState && (
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{selectedFile.name}
</Badge>
<span className="text-[10px] text-muted-foreground">
{formatFileSize(selectedFile.size)}
</span>
<button
type="button"
onClick={() =>
setBulkFiles((prev) => ({
...prev,
[req.requirementId]: null,
}))
}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{uploadState?.status === 'uploading' && (
<div className="flex items-center gap-2 mt-1">
<Progress
value={uploadState.progress}
className="h-1 flex-1"
/>
<span className="text-xs text-muted-foreground">
{uploadState.progress}%
</span>
</div>
)}
{uploadState?.status === 'error' && (
<p className="text-xs text-destructive mt-1">
{uploadState.error}
</p>
)}
</div>
<div className="shrink-0">
{uploadState?.status === 'complete' ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : uploadState?.status === 'uploading' ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<>
<input
ref={(el) => {
fileInputRefs.current[req.requirementId] = el
}}
type="file"
className="hidden"
accept={
req.mimeTypes.length > 0
? req.mimeTypes.join(',')
: undefined
}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setBulkFiles((prev) => ({
...prev,
[req.requirementId]: file,
}))
}
e.target.value = ''
}}
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() =>
fileInputRefs.current[req.requirementId]?.click()
}
>
{selectedFile ? 'Change' : 'Select'}
</Button>
</>
)}
</div>
</div>
)
})}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
onClick={() => {
setBulkProject(null)
setBulkFiles({})
}}
>
Cancel
</Button>
<Button
onClick={handleBulkUploadAll}
disabled={
Object.values(bulkFiles).filter(Boolean).length === 0 ||
Object.values(uploads).some((u) => u.status === 'uploading')
}
>
{Object.values(uploads).some((u) => u.status === 'uploading') ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload {Object.values(bulkFiles).filter(Boolean).length} File
{Object.values(bulkFiles).filter(Boolean).length !== 1 ? 's' : ''}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -622,6 +622,12 @@ export default function ProjectsPage() {
<Bot className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/bulk-upload">
<FileUp className="mr-2 h-4 w-4" />
Bulk Upload
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,408 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Save,
Loader2,
ChevronDown,
Play,
Square,
Archive,
} from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
import { FileRequirementsEditor } from '@/components/admin/rounds/config/file-requirements-editor'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const roundStatusConfig: Record<string, { label: string; bgClass: string }> = {
ROUND_DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700' },
ROUND_ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700' },
ROUND_CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700' },
ROUND_ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground' },
}
export default function RoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const [confirmAction, setConfirmAction] = useState<string | null>(null)
const utils = trpc.useUtils()
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
// Fetch competition for jury groups (DELIBERATION) and awards
const competitionId = round?.competitionId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId! },
{ enabled: !!competitionId }
)
const juryGroups = competition?.juryGroups?.map((g: any) => ({ id: g.id, name: g.name }))
// Fetch awards linked to this round
const programId = competition?.programId
const { data: awards } = trpc.specialAward.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
// Filter awards by this round
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) || []
// Update local config when round data changes
if (round && !hasChanges) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
}
}
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
},
onError: (err) => toast.error(err.message),
})
// Round lifecycle mutations
const activateMutation = trpc.roundEngine.activate.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round activated')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round archived')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const handleConfigChange = (newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}
const handleSave = () => {
updateMutation.mutate({ id: roundId, configJson: config })
}
const handleLifecycleAction = () => {
if (confirmAction === 'activate') activateMutation.mutate({ roundId })
else if (confirmAction === 'close') closeMutation.mutate({ roundId })
else if (confirmAction === 'archive') archiveMutation.mutate({ roundId })
}
const isLifecyclePending = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!round) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={"/admin/rounds" as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">The requested round does not exist</p>
</div>
</div>
</div>
)
}
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const canActivate = round.status === 'ROUND_DRAFT'
const canClose = round.status === 'ROUND_ACTIVE'
const canArchive = round.status === 'ROUND_CLOSED'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={"/admin/rounds" as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold truncate">{round.name}</h1>
<Badge variant="secondary" className={cn('text-[10px]', roundTypeColors[round.roundType])}>
{round.roundType.replace('_', ' ')}
</Badge>
{/* Status Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors hover:opacity-80',
statusCfg.bgClass,
)}>
{statusCfg.label}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{canActivate && (
<DropdownMenuItem onClick={() => setConfirmAction('activate')}>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Activate Round
</DropdownMenuItem>
)}
{canClose && (
<DropdownMenuItem onClick={() => setConfirmAction('close')}>
<Square className="h-4 w-4 mr-2 text-blue-600" />
Close Round
</DropdownMenuItem>
)}
{canArchive && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmAction('archive')}>
<Archive className="h-4 w-4 mr-2" />
Archive Round
</DropdownMenuItem>
</>
)}
{!canActivate && !canClose && !canArchive && (
<DropdownMenuItem disabled>No actions available</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="config" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="projects">Projects</TabsTrigger>
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="awards">
Awards
{roundAwards.length > 0 && (
<Badge variant="secondary" className="ml-2">
{roundAwards.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="space-y-4">
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
juryGroups={juryGroups}
/>
</TabsContent>
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={round.competitionId} roundId={roundId} />
</TabsContent>
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={round.competitionId} roundId={roundId} />
</TabsContent>
<TabsContent value="documents" className="space-y-4">
<FileRequirementsEditor roundId={roundId} />
</TabsContent>
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
{roundAwards.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No awards linked to this round</p>
<p className="text-xs mt-1">
Create an award and set this round as its source round to see it here
</p>
</div>
) : (
<div className="space-y-3">
{roundAwards.map((award) => {
const eligibleCount = award._count?.eligibilities || 0
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
const ruleCount = autoTagRules?.rules?.length || 0
return (
<Link
key={award.id}
href={`/admin/awards/${award.id}` as Route}
className="block"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4 transition-all hover:bg-muted/50 hover:shadow-sm">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{award.name}</h3>
<Badge
variant={
award.eligibilityMode === 'SEPARATE_POOL'
? 'default'
: 'secondary'
}
className="shrink-0"
>
{award.eligibilityMode === 'SEPARATE_POOL'
? 'Separate Pool'
: 'Stay in Main'}
</Badge>
</div>
{award.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{award.description}
</p>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
<div className="text-right">
<div className="font-medium text-foreground">
{ruleCount}
</div>
<div className="text-xs">
{ruleCount === 1 ? 'rule' : 'rules'}
</div>
</div>
<div className="text-right">
<div className="font-medium text-foreground">
{eligibleCount}
</div>
<div className="text-xs">eligible</div>
</div>
</div>
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Lifecycle Confirmation Dialog */}
<Dialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{confirmAction === 'activate' && 'Activate Round'}
{confirmAction === 'close' && 'Close Round'}
{confirmAction === 'archive' && 'Archive Round'}
</DialogTitle>
<DialogDescription>
{confirmAction === 'activate' && 'This will open the round for submissions and evaluations. Projects will be able to enter this round.'}
{confirmAction === 'close' && 'This will close the round. No more submissions or evaluations will be accepted.'}
{confirmAction === 'archive' && 'This will archive the round. It will no longer appear in active views.'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button onClick={handleLifecycleAction} disabled={isLifecyclePending}>
{isLifecyclePending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,552 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
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 { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
Plus,
Layers,
Calendar,
ChevronDown,
ChevronRight,
Settings,
Users,
FileBox,
Save,
Loader2,
} from 'lucide-react'
import { useEdition } from '@/contexts/edition-context'
const ROUND_TYPES = [
{ value: 'INTAKE', label: 'Intake' },
{ value: 'FILTERING', label: 'Filtering' },
{ value: 'EVALUATION', label: 'Evaluation' },
{ value: 'SUBMISSION', label: 'Submission' },
{ value: 'MENTORING', label: 'Mentoring' },
{ value: 'LIVE_FINAL', label: 'Live Final' },
{ value: 'DELIBERATION', label: 'Deliberation' },
] as const
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const statusConfig = {
DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700', dotClass: 'bg-gray-500' },
ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700', dotClass: 'bg-emerald-500' },
CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700', dotClass: 'bg-blue-500' },
ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground', dotClass: 'bg-muted-foreground' },
} as const
const roundStatusColors: Record<string, string> = {
ROUND_DRAFT: 'bg-gray-100 text-gray-600',
ROUND_ACTIVE: 'bg-emerald-100 text-emerald-700',
ROUND_CLOSED: 'bg-blue-100 text-blue-700',
ROUND_ARCHIVED: 'bg-muted text-muted-foreground',
}
export default function RoundsPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const utils = trpc.useUtils()
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' })
const [expandedCompetitions, setExpandedCompetitions] = useState<Set<string>>(new Set())
const [editingCompetition, setEditingCompetition] = useState<string | null>(null)
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
const [filterType, setFilterType] = useState<string>('all')
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const createRoundMutation = trpc.round.create.useMutation({
onSuccess: () => {
utils.competition.list.invalidate()
toast.success('Round created')
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: '', competitionId: '' })
},
onError: (err) => toast.error(err.message),
})
const updateCompMutation = trpc.competition.update.useMutation({
onSuccess: () => {
utils.competition.list.invalidate()
toast.success('Competition settings saved')
setEditingCompetition(null)
setCompetitionEdits({})
},
onError: (err) => toast.error(err.message),
})
const toggleExpanded = (id: string) => {
setExpandedCompetitions((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !roundForm.competitionId) {
toast.error('All fields are required')
return
}
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const comp = competitions?.find((c) => c.id === roundForm.competitionId)
const nextOrder = comp ? (comp as any).rounds?.length ?? comp._count.rounds : 0
createRoundMutation.mutate({
competitionId: roundForm.competitionId,
name: roundForm.name.trim(),
slug,
roundType: roundForm.roundType as any,
sortOrder: nextOrder,
})
}
const startEditCompetition = (comp: any) => {
setEditingCompetition(comp.id)
setCompetitionEdits({
name: comp.name,
categoryMode: comp.categoryMode,
startupFinalistCount: comp.startupFinalistCount,
conceptFinalistCount: comp.conceptFinalistCount,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
})
}
const saveCompetitionEdit = (id: string) => {
updateCompMutation.mutate({ id, ...competitionEdits } as any)
}
if (!programId) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Rounds</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">Select an edition from the sidebar</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold">Rounds</h1>
<p className="text-sm text-muted-foreground">
Manage all competition rounds for {currentEdition?.name}
</p>
</div>
<Button size="sm" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</div>
{/* Filter */}
<div className="flex items-center gap-3">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Loading */}
{isLoading && (
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!competitions || competitions.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Layers className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Create a competition first, then add rounds to define the evaluation flow.
</p>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Competition
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Competition Groups with Rounds */}
{competitions && competitions.length > 0 && (
<div className="space-y-4">
{competitions.map((comp) => {
const status = comp.status as keyof typeof statusConfig
const cfg = statusConfig[status] || statusConfig.DRAFT
const isExpanded = expandedCompetitions.has(comp.id) || competitions.length === 1
const isEditing = editingCompetition === comp.id
return (
<CompetitionGroup
key={comp.id}
competition={comp}
statusConfig={cfg}
isExpanded={isExpanded}
isEditing={isEditing}
competitionEdits={competitionEdits}
filterType={filterType}
updateCompMutation={updateCompMutation}
onToggle={() => toggleExpanded(comp.id)}
onStartEdit={() => startEditCompetition(comp)}
onCancelEdit={() => { setEditingCompetition(null); setCompetitionEdits({}) }}
onSaveEdit={() => saveCompetitionEdit(comp.id)}
onEditChange={setCompetitionEdits}
/>
)
})}
</div>
)}
{/* Add Round Dialog */}
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Round</DialogTitle>
<DialogDescription>
Create a new round in a competition.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Competition *</Label>
<Select
value={roundForm.competitionId}
onValueChange={(v) => setRoundForm({ ...roundForm, competitionId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Select competition" />
</SelectTrigger>
<SelectContent>
{competitions?.filter((c) => c.status !== 'ARCHIVED').map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Round Name *</Label>
<Input
placeholder="e.g. Initial Screening"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Round Type *</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Competition Group Component ─────────────────────────────────────────────
type CompetitionGroupProps = {
competition: any
statusConfig: { label: string; bgClass: string; dotClass: string }
isExpanded: boolean
isEditing: boolean
competitionEdits: Record<string, unknown>
filterType: string
updateCompMutation: any
onToggle: () => void
onStartEdit: () => void
onCancelEdit: () => void
onSaveEdit: () => void
onEditChange: (edits: Record<string, unknown>) => void
}
function CompetitionGroup({
competition: comp,
statusConfig: cfg,
isExpanded,
isEditing,
competitionEdits,
filterType,
updateCompMutation,
onToggle,
onStartEdit,
onCancelEdit,
onSaveEdit,
onEditChange,
}: CompetitionGroupProps) {
// We need to fetch rounds for this competition
const { data: compDetail } = trpc.competition.getById.useQuery(
{ id: comp.id },
{ enabled: isExpanded }
)
const rounds = compDetail?.rounds ?? []
const filteredRounds = filterType === 'all'
? rounds
: rounds.filter((r: any) => r.roundType === filterType)
return (
<Card>
{/* Competition Header */}
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<button
onClick={onToggle}
className="shrink-0 rounded p-1 hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base cursor-pointer" onClick={onToggle}>
{comp.name}
</CardTitle>
<Badge variant="secondary" className={cn('text-[10px]', cfg.bgClass)}>
{cfg.label}
</Badge>
<span className="text-xs text-muted-foreground">
{comp._count.rounds} rounds
</span>
<span className="text-xs text-muted-foreground">
{comp._count.juryGroups} juries
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => { e.stopPropagation(); onStartEdit() }}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{/* Inline Competition Settings Editor */}
{isEditing && (
<CardContent className="border-t bg-muted/30 pt-4">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Competition Name</Label>
<Input
value={(competitionEdits.name as string) ?? ''}
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Category Mode</Label>
<Input
value={(competitionEdits.categoryMode as string) ?? ''}
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Startup Finalists</Label>
<Input
type="number"
min={1}
className="w-24"
value={(competitionEdits.startupFinalistCount as number) ?? 10}
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Concept Finalists</Label>
<Input
type="number"
min={1}
className="w-24"
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
/>
<Label className="text-xs">Notify on Advance</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
/>
<Label className="text-xs">Deadline Reminders</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={onSaveEdit}
disabled={updateCompMutation.isPending}
>
{updateCompMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
Save
</Button>
<Button size="sm" variant="outline" onClick={onCancelEdit}>Cancel</Button>
</div>
</div>
</CardContent>
)}
{/* Rounds List */}
{isExpanded && (
<CardContent className={cn(isEditing ? '' : 'pt-0')}>
{!compDetail ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : filteredRounds.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
</p>
) : (
<div className="space-y-2">
{filteredRounds.map((round: any, index: number) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}` as Route}
>
<div className="flex items-center gap-3 rounded-lg border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{round.sortOrder + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{round.name}</p>
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
</div>
<Badge
variant="secondary"
className={cn('text-[10px] shrink-0', roundTypeColors[round.roundType])}
>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge
variant="outline"
className={cn('text-[10px] shrink-0 hidden sm:inline-flex', roundStatusColors[round.status])}
>
{round.status.replace('ROUND_', '')}
</Badge>
</div>
</Link>
))}
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -1,48 +1,69 @@
'use client';
'use client'
import { use, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { LiveVotingForm } from '@/components/jury/live-voting-form';
import { toast } from 'sonner';
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { toast } from 'sonner'
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [notes, setNotes] = useState('');
const [priorDataOpen, setPriorDataOpen] = useState(false);
const params = use(paramsPromise)
const utils = trpc.useUtils()
const [notes, setNotes] = useState('')
const [priorDataOpen, setPriorDataOpen] = useState(false)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
// Fetch live voting session data
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId: params.roundId },
{ enabled: !!params.roundId, refetchInterval: 2000 }
)
// Placeholder for prior data - this would need to be implemented in evaluation router
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null;
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote submitted successfully');
utils.liveVoting.getSessionForVoting.invalidate()
toast.success('Vote submitted successfully')
},
onError: (err: any) => {
toast.error(err.message);
}
});
toast.error(err.message)
},
})
const handleVoteSubmit = (vote: { score: number }) => {
if (!cursor?.activeProject?.id) return;
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
if (!projectId) return
const sessionId = sessionData?.session?.id || params.roundId
submitVoteMutation.mutate({
sessionId: params.roundId,
projectId: cursor.activeProject.id,
score: vote.score
});
};
sessionId,
projectId,
score: vote.score,
criterionScores: vote.criterionScores,
})
}
if (!cursor?.activeProject) {
// Extract voting mode and criteria from session
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = (sessionData?.session?.criteriaJson as Array<{
id: string
label: string
description?: string
scale: number
weight: number
}> | undefined)
const activeProject = cursor?.activeProject || sessionData?.currentProject
if (!activeProject) {
return (
<div className="space-y-6">
<Card>
@@ -54,7 +75,7 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
</CardContent>
</Card>
</div>
);
)
}
return (
@@ -64,16 +85,19 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">{cursor.activeProject.title}</CardTitle>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-2">
Live project presentation
</CardDescription>
</div>
{votingMode === 'criteria' && (
<Badge variant="secondary">Criteria Voting</Badge>
)}
</div>
</CardHeader>
<CardContent>
{cursor.activeProject.description && (
<p className="text-muted-foreground">{cursor.activeProject.description}</p>
{activeProject.description && (
<p className="text-muted-foreground">{activeProject.description}</p>
)}
</CardContent>
</Card>
@@ -144,10 +168,16 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
{/* Voting Form */}
<LiveVotingForm
projectId={cursor.activeProject.id}
projectId={activeProject.id}
votingMode={votingMode}
criteria={criteria}
existingVote={sessionData?.userVote ? {
score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
} : null}
onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending}
/>
</div>
);
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Dialog,
DialogContent,
@@ -20,66 +21,201 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Save, Send, AlertCircle } from 'lucide-react'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
export default function JuryEvaluatePage() {
const params = useParams()
type PageProps = {
params: Promise<{ roundId: string; projectId: string }>
}
export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const params = use(paramsPromise)
const router = useRouter()
const roundId = params.roundId as string
const projectId = params.projectId as string
const { roundId, projectId } = params
const utils = trpc.useUtils()
const [showCOIDialog, setShowCOIDialog] = useState(true)
const [coiAccepted, setCoiAccepted] = useState(false)
// Evaluation form state
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
const [globalScore, setGlobalScore] = useState('')
const [feedbackGeneral, setFeedbackGeneral] = useState('')
const [feedbackStrengths, setFeedbackStrengths] = useState('')
const [feedbackWeaknesses, setFeedbackWeaknesses] = useState('')
const utils = trpc.useUtils()
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('')
// Fetch project
const { data: project } = trpc.project.get.useQuery(
{ id: projectId },
{ enabled: !!projectId }
)
// Fetch round to get config
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
)
// Fetch assignment to get evaluation
const { data: assignment } = trpc.roundAssignment.getMyAssignments.useQuery(
{ roundId },
{ enabled: !!roundId }
)
const myAssignment = assignment?.find((a) => a.projectId === projectId)
// Fetch existing evaluation if it exists
const { data: existingEvaluation } = trpc.evaluation.get.useQuery(
{ assignmentId: myAssignment?.id ?? '' },
{ enabled: !!myAssignment?.id }
)
// Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation
const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
toast.success('Draft saved', { duration: 1500 })
},
onError: (err) => toast.error(err.message),
})
// Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => {
utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully')
router.push(`/jury/competitions/${roundId}` as Route)
},
onError: (err) => toast.error(err.message),
})
const handleSubmit = () => {
const score = parseInt(globalScore)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
// Load existing evaluation data
useEffect(() => {
if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0
})
setCriteriaScores(scores)
}
if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString())
}
if (existingEvaluation.binaryDecision !== null) {
setBinaryDecision(existingEvaluation.binaryDecision ? 'accept' : 'reject')
}
if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText)
}
}
}, [existingEvaluation])
// Parse evaluation config from round
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
const scoringMode = evalConfig?.scoringMode ?? 'global'
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from evaluation form
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
id: string
label: string
description?: string
weight?: number
minScore?: number
maxScore?: number
}> | undefined
const handleSaveDraft = async () => {
if (!myAssignment) {
toast.error('Assignment not found')
return
}
if (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
toast.error('Please provide general feedback (minimum 10 characters)')
return
// Create evaluation if it doesn't exist
let evaluationId = existingEvaluation?.id
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
}
// In a real implementation, we would first get or create the evaluation ID
// For now, this is a placeholder that shows the structure
toast.error('Evaluation submission requires an existing evaluation ID. This feature needs backend integration.')
/* Real implementation would be:
submitMutation.mutate({
id: evaluationId, // From assignment.evaluation.id
criterionScoresJson: {}, // Criterion scores
globalScore: score,
binaryDecision: true,
feedbackText: feedbackGeneral,
// Autosave current state
autosaveMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
})
*/
}
if (!coiAccepted && showCOIDialog) {
const handleSubmit = async () => {
if (!myAssignment) {
toast.error('Assignment not found')
return
}
// Validation based on scoring mode
if (scoringMode === 'criteria') {
if (!criteria || criteria.length === 0) {
toast.error('No criteria found for this evaluation')
return
}
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
if (requiredCriteria) {
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
if (!allScored) {
toast.error('Please score all criteria')
return
}
}
}
if (scoringMode === 'global') {
const score = parseInt(globalScore, 10)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
return
}
}
if (scoringMode === 'binary') {
if (!binaryDecision) {
toast.error('Please select accept or reject')
return
}
}
if (requireFeedback) {
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
return
}
}
// Create evaluation if needed
let evaluationId = existingEvaluation?.id
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
}
// Submit
submitMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
}
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
<DialogContent>
@@ -127,6 +263,21 @@ export default function JuryEvaluatePage() {
)
}
if (!round || !project) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Card>
<CardContent className="py-12">
<div className="flex items-center justify-center">
<Skeleton className="h-64 w-full" />
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -140,9 +291,7 @@ export default function JuryEvaluatePage() {
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">
{project?.title || 'Loading...'}
</p>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
@@ -163,61 +312,108 @@ export default function JuryEvaluatePage() {
<CardHeader>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
Provide your assessment of the project
Provide your assessment using the {scoringMode} scoring method
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="globalScore">
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
{/* Criteria-based scoring */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3>
{criteria.map((criterion) => (
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg">
<Label htmlFor={criterion.id}>
{criterion.label}
{evalConfig?.requireAllCriteriaScored !== false && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{criterion.description && (
<p className="text-xs text-muted-foreground">{criterion.description}</p>
)}
<Input
id={criterion.id}
type="number"
min={criterion.minScore ?? 0}
max={criterion.maxScore ?? 10}
value={criteriaScores[criterion.id] ?? ''}
onChange={(e) =>
setCriteriaScores({
...criteriaScores,
[criterion.id]: parseInt(e.target.value, 10) || 0,
})
}
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
/>
</div>
))}
</div>
)}
{/* Global scoring */}
{scoringMode === 'global' && (
<div className="space-y-2">
<Label htmlFor="globalScore">
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
)}
{/* Binary decision */}
{scoringMode === 'binary' && (
<div className="space-y-2">
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
<ThumbsUp className="h-4 w-4 text-emerald-600" />
<span>Accept This project should advance</span>
</Label>
</div>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-red-50/50">
<RadioGroupItem value="reject" id="reject" />
<Label htmlFor="reject" className="flex items-center gap-2 cursor-pointer flex-1">
<ThumbsDown className="h-4 w-4 text-red-600" />
<span>Reject This project should not advance</span>
</Label>
</div>
</RadioGroup>
</div>
)}
{/* Feedback */}
<div className="space-y-2">
<Label htmlFor="feedbackGeneral">
General Feedback <span className="text-destructive">*</span>
<Label htmlFor="feedbackText">
Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id="feedbackGeneral"
value={feedbackGeneral}
onChange={(e) => setFeedbackGeneral(e.target.value)}
placeholder="Provide your overall feedback on the project..."
rows={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackStrengths">Strengths</Label>
<Textarea
id="feedbackStrengths"
value={feedbackStrengths}
onChange={(e) => setFeedbackStrengths(e.target.value)}
placeholder="What are the key strengths of this project?"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackWeaknesses">Areas for Improvement</Label>
<Textarea
id="feedbackWeaknesses"
value={feedbackWeaknesses}
onChange={(e) => setFeedbackWeaknesses(e.target.value)}
placeholder="What areas could be improved?"
rows={4}
id="feedbackText"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
/>
{requireFeedback && (
<p className="text-xs text-muted-foreground">
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
</p>
)}
</div>
</CardContent>
</Card>
@@ -232,14 +428,15 @@ export default function JuryEvaluatePage() {
<div className="flex gap-3">
<Button
variant="outline"
disabled={submitMutation.isPending}
onClick={handleSaveDraft}
disabled={autosaveMutation.isPending || submitMutation.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save Draft
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending}
disabled={submitMutation.isPending || autosaveMutation.isPending}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />