Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite

- 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:
Matt 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,21 +537,34 @@ 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>
{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">
{(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
{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 ?? `req-${idx}`}
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'
@ -557,28 +581,43 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<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">
<Badge variant="destructive" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{req.description && (
<span className="truncate">{req.description}</span>
<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>
<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}
{isFulfilled && fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
{fulfilledFile.fileName}
</p>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-muted-foreground shrink-0 ml-2">
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
Missing
</span>
)}
@ -587,23 +626,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
})}
</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)
// 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
}
// Create evaluation if it doesn't exist
let evaluationId = existingEvaluation?.id
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
}
// 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,
})
}
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 (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
toast.error('Please provide general feedback (minimum 10 characters)')
if (scoringMode === 'binary') {
if (!binaryDecision) {
toast.error('Please select accept or reject')
return
}
// 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,
})
*/
}
if (!coiAccepted && showCOIDialog) {
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,10 +312,46 @@ 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">
{/* 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>
@ -184,40 +369,51 @@ export default function JuryEvaluatePage() {
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
)}
{/* Binary decision */}
{scoringMode === 'binary' && (
<div className="space-y-2">
<Label htmlFor="feedbackGeneral">
General Feedback <span className="text-destructive">*</span>
<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="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" />

View File

@ -1,248 +1,41 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 { IntakeConfig } from '@/components/admin/rounds/config/intake-config'
import { FilteringConfig } from '@/components/admin/rounds/config/filtering-config'
import { EvaluationConfig } from '@/components/admin/rounds/config/evaluation-config'
import { SubmissionConfig } from '@/components/admin/rounds/config/submission-config'
import { MentoringConfig } from '@/components/admin/rounds/config/mentoring-config'
import { LiveFinalConfig } from '@/components/admin/rounds/config/live-final-config'
import { DeliberationConfig } from '@/components/admin/rounds/config/deliberation-config'
type RoundConfigFormProps = {
roundType: string
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
juryGroups?: Array<{ id: string; name: string }>
}
export function RoundConfigForm({ roundType, config, onChange }: RoundConfigFormProps) {
const updateConfig = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
if (roundType === 'INTAKE') {
export function RoundConfigForm({ roundType, config, onChange, juryGroups }: RoundConfigFormProps) {
switch (roundType) {
case 'INTAKE':
return <IntakeConfig config={config} onChange={onChange} />
case 'FILTERING':
return <FilteringConfig config={config} onChange={onChange} />
case 'EVALUATION':
return <EvaluationConfig config={config} onChange={onChange} />
case 'SUBMISSION':
return <SubmissionConfig config={config} onChange={onChange} />
case 'MENTORING':
return <MentoringConfig config={config} onChange={onChange} />
case 'LIVE_FINAL':
return <LiveFinalConfig config={config} onChange={onChange} />
case 'DELIBERATION':
return <DeliberationConfig config={config} onChange={onChange} juryGroups={juryGroups} />
default:
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Intake Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="allowDrafts">Allow Drafts</Label>
<Switch
id="allowDrafts"
checked={(config.allowDrafts as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('allowDrafts', checked)}
/>
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
Unknown round type: {roundType}
</div>
<div className="space-y-2">
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
<Input
id="draftExpiryDays"
type="number"
min={1}
value={(config.draftExpiryDays as number) ?? 30}
onChange={(e) => updateConfig('draftExpiryDays', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
<Input
id="maxFileSizeMB"
type="number"
min={1}
value={(config.maxFileSizeMB as number) ?? 50}
onChange={(e) => updateConfig('maxFileSizeMB', parseInt(e.target.value, 10))}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="publicFormEnabled">Public Form Enabled</Label>
<Switch
id="publicFormEnabled"
checked={(config.publicFormEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('publicFormEnabled', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'FILTERING') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Filtering Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="aiScreeningEnabled">AI Screening</Label>
<Switch
id="aiScreeningEnabled"
checked={(config.aiScreeningEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('aiScreeningEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
<Switch
id="duplicateDetectionEnabled"
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('duplicateDetectionEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
<Switch
id="manualReviewEnabled"
checked={(config.manualReviewEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('manualReviewEnabled', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="batchSize">Batch Size</Label>
<Input
id="batchSize"
type="number"
min={1}
value={(config.batchSize as number) ?? 20}
onChange={(e) => updateConfig('batchSize', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'EVALUATION') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Evaluation Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<Input
id="requiredReviews"
type="number"
min={1}
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => updateConfig('requiredReviewsPerProject', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(value) => updateConfig('scoringMode', value)}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based</SelectItem>
<SelectItem value="global">Global score</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="requireFeedback">Require Feedback</Label>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('requireFeedback', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('coiRequired', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'LIVE_FINAL') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Live Final Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="juryVotingEnabled">Jury Voting</Label>
<Switch
id="juryVotingEnabled"
checked={(config.juryVotingEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('juryVotingEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="audienceVotingEnabled">Audience Voting</Label>
<Switch
id="audienceVotingEnabled"
checked={(config.audienceVotingEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('audienceVotingEnabled', checked)}
/>
</div>
{(config.audienceVotingEnabled as boolean) && (
<div className="space-y-2">
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
<Input
id="audienceVoteWeight"
type="number"
min={0}
max={1}
step={0.1}
value={(config.audienceVoteWeight as number) ?? 0}
onChange={(e) => updateConfig('audienceVoteWeight', parseFloat(e.target.value))}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
<Input
id="presentationDuration"
type="number"
min={1}
value={(config.presentationDurationMinutes as number) ?? 15}
onChange={(e) => updateConfig('presentationDurationMinutes', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
// Default view for other types
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{roundType} Configuration</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Configuration UI for {roundType} rounds is not yet implemented.
</p>
<pre className="mt-4 p-3 bg-muted rounded text-xs overflow-auto">
{JSON.stringify(config, null, 2)}
</pre>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,176 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
type DeliberationConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
juryGroups?: Array<{ id: string; name: string }>
}
export function DeliberationConfig({ config, onChange, juryGroups }: DeliberationConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
return (
<div className="space-y-6">
{/* Jury Group Selection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Deliberation Jury</CardTitle>
<CardDescription>Which jury group participates in this deliberation round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="juryGroupId">Jury Group</Label>
<p className="text-xs text-muted-foreground">
The jury group that will cast votes during deliberation
</p>
{juryGroups && juryGroups.length > 0 ? (
<Select
value={(config.juryGroupId as string) ?? ''}
onValueChange={(v) => update('juryGroupId', v)}
>
<SelectTrigger id="juryGroupId" className="w-72">
<SelectValue placeholder="Select a jury group" />
</SelectTrigger>
<SelectContent>
{juryGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="juryGroupId"
placeholder="Jury group ID"
value={(config.juryGroupId as string) ?? ''}
onChange={(e) => update('juryGroupId', e.target.value)}
/>
)}
</div>
</CardContent>
</Card>
{/* Voting Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">Voting Settings</CardTitle>
<CardDescription>How deliberation votes are structured</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mode">Deliberation Mode</Label>
<p className="text-xs text-muted-foreground">How the final decision is made</p>
<Select
value={(config.mode as string) ?? 'SINGLE_WINNER_VOTE'}
onValueChange={(v) => update('mode', v)}
>
<SelectTrigger id="mode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="votingDuration">Voting Duration (min)</Label>
<p className="text-xs text-muted-foreground">Time limit for voting round</p>
<Input
id="votingDuration"
type="number"
min={1}
className="w-32"
value={(config.votingDuration as number) ?? 60}
onChange={(e) => update('votingDuration', parseInt(e.target.value, 10) || 60)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="topN">Top N Projects</Label>
<p className="text-xs text-muted-foreground">Number of finalists to select</p>
<Input
id="topN"
type="number"
min={1}
className="w-32"
value={(config.topN as number) ?? 3}
onChange={(e) => update('topN', parseInt(e.target.value, 10) || 3)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreakMethod">Tie Break Method</Label>
<Select
value={(config.tieBreakMethod as string) ?? 'ADMIN_DECIDES'}
onValueChange={(v) => update('tieBreakMethod', v)}
>
<SelectTrigger id="tieBreakMethod" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_DECIDES">Admin Decides</SelectItem>
<SelectItem value="RUNOFF">Runoff Vote</SelectItem>
<SelectItem value="SCORE_FALLBACK">Score Fallback (use prior scores)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Visibility & Overrides */}
<Card>
<CardHeader>
<CardTitle className="text-base">Visibility & Overrides</CardTitle>
<CardDescription>What information jurors can see during deliberation</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showCollectiveRankings">Show Collective Rankings</Label>
<p className="text-xs text-muted-foreground">Display aggregate rankings to jurors during voting</p>
</div>
<Switch
id="showCollectiveRankings"
checked={(config.showCollectiveRankings as boolean) ?? false}
onCheckedChange={(v) => update('showCollectiveRankings', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showPriorJuryData">Show Prior Jury Data</Label>
<p className="text-xs text-muted-foreground">Display evaluation scores from previous rounds</p>
</div>
<Switch
id="showPriorJuryData"
checked={(config.showPriorJuryData as boolean) ?? false}
onCheckedChange={(v) => update('showPriorJuryData', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="allowAdminOverride">Allow Admin Override</Label>
<p className="text-xs text-muted-foreground">Admin can override deliberation results</p>
</div>
<Switch
id="allowAdminOverride"
checked={(config.allowAdminOverride as boolean) ?? true}
onCheckedChange={(v) => update('allowAdminOverride', v)}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,283 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
type EvaluationConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const advancementMode = (config.advancementMode as string) ?? 'admin_selection'
const advancementConfig = (config.advancementConfig as {
perCategory?: boolean; startupCount?: number; conceptCount?: number; tieBreaker?: string
}) ?? {}
const updateAdvancement = (key: string, value: unknown) => {
update('advancementConfig', { ...advancementConfig, [key]: value })
}
return (
<div className="space-y-6">
{/* Scoring */}
<Card>
<CardHeader>
<CardTitle className="text-base">Scoring & Reviews</CardTitle>
<CardDescription>How jury members evaluate and score projects</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<p className="text-xs text-muted-foreground">Minimum number of jury evaluations needed</p>
<Input
id="requiredReviews"
type="number"
min={1}
className="w-32"
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => update('requiredReviewsPerProject', parseInt(e.target.value, 10) || 3)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<p className="text-xs text-muted-foreground">How jurors assign scores to projects</p>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(v) => update('scoringMode', v)}
>
<SelectTrigger id="scoringMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based (multiple criteria with weights)</SelectItem>
<SelectItem value="global">Global score (single overall score)</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
<p className="text-xs text-muted-foreground">How much of other jurors&apos; identities are revealed</p>
<Select
value={(config.anonymizationLevel as string) ?? 'fully_anonymous'}
onValueChange={(v) => update('anonymizationLevel', v)}
>
<SelectTrigger id="anonymizationLevel" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fully_anonymous">Fully Anonymous</SelectItem>
<SelectItem value="show_initials">Show Initials</SelectItem>
<SelectItem value="named">Named (full names visible)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-base">Feedback Requirements</CardTitle>
<CardDescription>What jurors must provide alongside scores</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireFeedback">Require Written Feedback</Label>
<p className="text-xs text-muted-foreground">Jurors must write feedback text</p>
</div>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(v) => update('requireFeedback', v)}
/>
</div>
{(config.requireFeedback as boolean) !== false && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="feedbackMinLength">Minimum Feedback Length</Label>
<p className="text-xs text-muted-foreground">Minimum characters (0 = no minimum)</p>
<Input
id="feedbackMinLength"
type="number"
min={0}
className="w-32"
value={(config.feedbackMinLength as number) ?? 0}
onChange={(e) => update('feedbackMinLength', parseInt(e.target.value, 10) || 0)}
/>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireAllCriteriaScored">Require All Criteria Scored</Label>
<p className="text-xs text-muted-foreground">Jurors must score every criterion before submitting</p>
</div>
<Switch
id="requireAllCriteriaScored"
checked={(config.requireAllCriteriaScored as boolean) ?? true}
onCheckedChange={(v) => update('requireAllCriteriaScored', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<p className="text-xs text-muted-foreground">Jurors must declare conflicts of interest</p>
</div>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(v) => update('coiRequired', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="peerReviewEnabled">Peer Review</Label>
<p className="text-xs text-muted-foreground">Allow jurors to see and comment on other evaluations</p>
</div>
<Switch
id="peerReviewEnabled"
checked={(config.peerReviewEnabled as boolean) ?? false}
onCheckedChange={(v) => update('peerReviewEnabled', v)}
/>
</div>
</CardContent>
</Card>
{/* AI Features */}
<Card>
<CardHeader>
<CardTitle className="text-base">AI Features</CardTitle>
<CardDescription>AI-powered evaluation assistance</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="aiSummaryEnabled">AI Evaluation Summary</Label>
<p className="text-xs text-muted-foreground">Generate AI synthesis of all jury evaluations</p>
</div>
<Switch
id="aiSummaryEnabled"
checked={(config.aiSummaryEnabled as boolean) ?? false}
onCheckedChange={(v) => update('aiSummaryEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="generateAiShortlist">AI Shortlist Recommendations</Label>
<p className="text-xs text-muted-foreground">AI suggests which projects should advance</p>
</div>
<Switch
id="generateAiShortlist"
checked={(config.generateAiShortlist as boolean) ?? false}
onCheckedChange={(v) => update('generateAiShortlist', v)}
/>
</div>
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardHeader>
<CardTitle className="text-base">Advancement Rules</CardTitle>
<CardDescription>How projects move to the next round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="advancementMode">Advancement Mode</Label>
<Select
value={advancementMode}
onValueChange={(v) => update('advancementMode', v)}
>
<SelectTrigger id="advancementMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_selection">Admin Selection (manual)</SelectItem>
<SelectItem value="auto_top_n">Auto Top-N (by score)</SelectItem>
<SelectItem value="ai_recommended">AI Recommended</SelectItem>
</SelectContent>
</Select>
</div>
{advancementMode === 'auto_top_n' && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<Label className="text-sm font-medium">Auto Top-N Settings</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="perCategory">Per Category</Label>
<p className="text-xs text-muted-foreground">Apply limits separately for each category</p>
</div>
<Switch
id="perCategory"
checked={advancementConfig.perCategory ?? true}
onCheckedChange={(v) => updateAdvancement('perCategory', v)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupCount">Startup Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of startups to advance</p>
<Input
id="startupCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.startupCount ?? 10}
onChange={(e) => updateAdvancement('startupCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptCount">Business Concept Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of business concepts to advance</p>
<Input
id="conceptCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.conceptCount ?? 10}
onChange={(e) => updateAdvancement('conceptCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreaker">Tie Breaker</Label>
<p className="text-xs text-muted-foreground">How to handle tied scores</p>
<Select
value={advancementConfig.tieBreaker ?? 'admin_decides'}
onValueChange={(v) => updateAdvancement('tieBreaker', v)}
>
<SelectTrigger id="tieBreaker" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Re-vote</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,193 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
type FilteringConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function FilteringConfig({ config, onChange }: FilteringConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const aiEnabled = (config.aiScreeningEnabled as boolean) ?? true
const thresholds = (config.aiConfidenceThresholds as { high: number; medium: number; low: number }) ?? {
high: 0.85,
medium: 0.6,
low: 0.4,
}
const updateThreshold = (key: 'high' | 'medium' | 'low', value: number) => {
update('aiConfidenceThresholds', { ...thresholds, [key]: value })
}
return (
<div className="space-y-6">
{/* AI Screening Section */}
<Card>
<CardHeader>
<CardTitle className="text-base">AI Screening</CardTitle>
<CardDescription>
AI analyzes each project against your criteria and assigns confidence scores.
Projects above the high threshold auto-pass, below low auto-reject, and between are flagged for manual review.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="aiScreeningEnabled">Enable AI Screening</Label>
<p className="text-xs text-muted-foreground">Use AI to pre-screen applications</p>
</div>
<Switch
id="aiScreeningEnabled"
checked={aiEnabled}
onCheckedChange={(v) => update('aiScreeningEnabled', v)}
/>
</div>
{aiEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="space-y-2">
<Label htmlFor="aiCriteriaText">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible. The AI uses this text to evaluate each submission.
Be specific about requirements, disqualifiers, and what constitutes a strong application.
</p>
<Textarea
id="aiCriteriaText"
rows={6}
placeholder="e.g., Projects must address ocean conservation directly. They should have a clear implementation plan, measurable impact metrics, and a team with relevant expertise. Disqualify projects focused solely on freshwater or landlocked environments..."
value={(config.aiCriteriaText as string) ?? ''}
onChange={(e) => update('aiCriteriaText', e.target.value)}
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Set the AI confidence boundaries. Projects scoring above &quot;High&quot; auto-pass,
below &quot;Low&quot; auto-reject, and everything between gets flagged for manual review.
</p>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-emerald-600">High (auto-pass above)</Label>
<span className="text-xs font-mono font-medium">{thresholds.high.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.high]}
onValueChange={([v]) => updateThreshold('high', v)}
className="[&_[role=slider]]:bg-emerald-500"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-amber-600">Medium (review threshold)</Label>
<span className="text-xs font-mono font-medium">{thresholds.medium.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.medium]}
onValueChange={([v]) => updateThreshold('medium', v)}
className="[&_[role=slider]]:bg-amber-500"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-red-600">Low (auto-reject below)</Label>
<span className="text-xs font-mono font-medium">{thresholds.low.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.low]}
onValueChange={([v]) => updateThreshold('low', v)}
className="[&_[role=slider]]:bg-red-500"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoAdvanceEligible">Auto-Advance Eligible</Label>
<p className="text-xs text-muted-foreground">
Automatically advance projects that score above the high threshold
</p>
</div>
<Switch
id="autoAdvanceEligible"
checked={(config.autoAdvanceEligible as boolean) ?? false}
onCheckedChange={(v) => update('autoAdvanceEligible', v)}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* Manual Review & Detection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Review Settings</CardTitle>
<CardDescription>Manual review and quality checks</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
<p className="text-xs text-muted-foreground">Enable admin manual review of flagged projects</p>
</div>
<Switch
id="manualReviewEnabled"
checked={(config.manualReviewEnabled as boolean) ?? true}
onCheckedChange={(v) => update('manualReviewEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
<p className="text-xs text-muted-foreground">Flag potential duplicate submissions</p>
</div>
<Switch
id="duplicateDetectionEnabled"
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
onCheckedChange={(v) => update('duplicateDetectionEnabled', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="batchSize">Processing Batch Size</Label>
<p className="text-xs text-muted-foreground">Number of projects processed per AI batch</p>
<Input
id="batchSize"
type="number"
min={1}
max={100}
className="w-32"
value={(config.batchSize as number) ?? 20}
onChange={(e) => update('batchSize', parseInt(e.target.value, 10) || 20)}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,296 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
type IntakeConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
const MIME_PRESETS = [
{ label: 'PDF', value: 'application/pdf' },
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
{ label: 'Images', value: 'image/*' },
{ label: 'Video', value: 'video/*' },
]
const FIELD_TYPES = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Text Area' },
{ value: 'select', label: 'Dropdown' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'date', label: 'Date' },
]
export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const acceptedCategories = (config.acceptedCategories as string[]) ?? ['STARTUP', 'BUSINESS_CONCEPT']
const allowedMimeTypes = (config.allowedMimeTypes as string[]) ?? ['application/pdf']
const customFields = (config.customFields as Array<{
id: string; label: string; type: string; required: boolean; options?: string[]
}>) ?? []
const toggleCategory = (cat: string) => {
const current = [...acceptedCategories]
const idx = current.indexOf(cat)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(cat)
}
update('acceptedCategories', current)
}
const toggleMime = (mime: string) => {
const current = [...allowedMimeTypes]
const idx = current.indexOf(mime)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(mime)
}
update('allowedMimeTypes', current)
}
const addCustomField = () => {
update('customFields', [
...customFields,
{ id: `field-${Date.now()}`, label: '', type: 'text', required: false },
])
}
const updateCustomField = (index: number, field: typeof customFields[0]) => {
const updated = [...customFields]
updated[index] = field
update('customFields', updated)
}
const removeCustomField = (index: number) => {
update('customFields', customFields.filter((_, i) => i !== index))
}
return (
<div className="space-y-6">
{/* Basic Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">Application Settings</CardTitle>
<CardDescription>Configure how projects are submitted during intake</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="allowDrafts">Allow Drafts</Label>
<p className="text-xs text-muted-foreground">Let applicants save incomplete submissions</p>
</div>
<Switch
id="allowDrafts"
checked={(config.allowDrafts as boolean) ?? true}
onCheckedChange={(v) => update('allowDrafts', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
<p className="text-xs text-muted-foreground">Days before incomplete drafts are automatically deleted</p>
<Input
id="draftExpiryDays"
type="number"
min={1}
className="w-32"
value={(config.draftExpiryDays as number) ?? 30}
onChange={(e) => update('draftExpiryDays', parseInt(e.target.value, 10) || 30)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="publicFormEnabled">Public Application Form</Label>
<p className="text-xs text-muted-foreground">Allow applications without login</p>
</div>
<Switch
id="publicFormEnabled"
checked={(config.publicFormEnabled as boolean) ?? false}
onCheckedChange={(v) => update('publicFormEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="lateSubmissionNotification">Late Submission Notification</Label>
<p className="text-xs text-muted-foreground">Notify admins when submissions arrive after deadline</p>
</div>
<Switch
id="lateSubmissionNotification"
checked={(config.lateSubmissionNotification as boolean) ?? true}
onCheckedChange={(v) => update('lateSubmissionNotification', v)}
/>
</div>
</CardContent>
</Card>
{/* Categories */}
<Card>
<CardHeader>
<CardTitle className="text-base">Accepted Categories</CardTitle>
<CardDescription>Which project categories can submit in this round</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['STARTUP', 'BUSINESS_CONCEPT'].map((cat) => (
<Badge
key={cat}
variant={acceptedCategories.includes(cat) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleCategory(cat)}
>
{cat === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* File Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">File Upload Settings</CardTitle>
<CardDescription>Constraints for uploaded documents</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
<Input
id="maxFileSizeMB"
type="number"
min={1}
className="w-32"
value={(config.maxFileSizeMB as number) ?? 50}
onChange={(e) => update('maxFileSizeMB', parseInt(e.target.value, 10) || 50)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFilesPerSlot">Max Files per Slot</Label>
<Input
id="maxFilesPerSlot"
type="number"
min={1}
className="w-32"
value={(config.maxFilesPerSlot as number) ?? 1}
onChange={(e) => update('maxFilesPerSlot', parseInt(e.target.value, 10) || 1)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Allowed File Types</Label>
<div className="flex flex-wrap gap-2">
{MIME_PRESETS.map((preset) => (
<Badge
key={preset.value}
variant={allowedMimeTypes.includes(preset.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleMime(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
{/* Custom Fields */}
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Application Fields</CardTitle>
<CardDescription>Additional fields applicants must fill in</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{customFields.length === 0 && (
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
)}
{customFields.map((field, idx) => (
<div key={field.id} className="flex items-start gap-3 rounded-lg border p-3">
<div className="flex-1 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={field.label}
placeholder="Field name"
onChange={(e) => updateCustomField(idx, { ...field, label: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={field.type}
onValueChange={(v) => updateCustomField(idx, { ...field, type: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>{ft.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{field.type === 'select' && (
<div className="space-y-1">
<Label className="text-xs">Options (comma-separated)</Label>
<Input
value={(field.options ?? []).join(', ')}
placeholder="Option 1, Option 2, Option 3"
onChange={(e) => updateCustomField(idx, {
...field,
options: e.target.value.split(',').map((o) => o.trim()).filter(Boolean),
})}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
checked={field.required}
onCheckedChange={(v) => updateCustomField(idx, { ...field, required: v })}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeCustomField(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addCustomField}>
<Plus className="h-4 w-4 mr-1" />
Add Field
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,286 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
type LiveFinalConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function LiveFinalConfig({ config, onChange }: LiveFinalConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const audienceEnabled = (config.audienceVotingEnabled as boolean) ?? false
const deliberationEnabled = (config.deliberationEnabled as boolean) ?? false
return (
<div className="space-y-6">
{/* Jury Voting */}
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Voting</CardTitle>
<CardDescription>How the jury panel scores projects during the live event</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="juryVotingEnabled">Enable Jury Voting</Label>
<p className="text-xs text-muted-foreground">Jury members can vote during live presentations</p>
</div>
<Switch
id="juryVotingEnabled"
checked={(config.juryVotingEnabled as boolean) ?? true}
onCheckedChange={(v) => update('juryVotingEnabled', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="votingMode">Voting Mode</Label>
<p className="text-xs text-muted-foreground">How jury members cast their votes</p>
<Select
value={(config.votingMode as string) ?? 'simple'}
onValueChange={(v) => update('votingMode', v)}
>
<SelectTrigger id="votingMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">Simple (single score per project)</SelectItem>
<SelectItem value="criteria">Criteria-based (multiple criteria)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Audience Voting */}
<Card>
<CardHeader>
<CardTitle className="text-base">Audience Voting</CardTitle>
<CardDescription>Public or audience participation in scoring</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="audienceVotingEnabled">Enable Audience Voting</Label>
<p className="text-xs text-muted-foreground">Allow event attendees to vote</p>
</div>
<Switch
id="audienceVotingEnabled"
checked={audienceEnabled}
onCheckedChange={(v) => update('audienceVotingEnabled', v)}
/>
</div>
{audienceEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="space-y-2">
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
<p className="text-xs text-muted-foreground">
How much audience votes count relative to jury (0 = no weight, 1 = equal)
</p>
<Input
id="audienceVoteWeight"
type="number"
min={0}
max={1}
step={0.05}
className="w-32"
value={(config.audienceVoteWeight as number) ?? 0}
onChange={(e) => update('audienceVoteWeight', parseFloat(e.target.value) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audienceVotingMode">Audience Voting Mode</Label>
<p className="text-xs text-muted-foreground">How audience members cast votes</p>
<Select
value={(config.audienceVotingMode as string) ?? 'per_project'}
onValueChange={(v) => update('audienceVotingMode', v)}
>
<SelectTrigger id="audienceVotingMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="per_project">Per Project (vote on each)</SelectItem>
<SelectItem value="per_category">Per Category (one vote per category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
</div>
{(config.audienceVotingMode as string) === 'favorites' && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="audienceMaxFavorites">Max Favorites</Label>
<p className="text-xs text-muted-foreground">How many projects each audience member can pick</p>
<Input
id="audienceMaxFavorites"
type="number"
min={1}
className="w-32"
value={(config.audienceMaxFavorites as number) ?? 3}
onChange={(e) => update('audienceMaxFavorites', parseInt(e.target.value, 10) || 3)}
/>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="audienceRequireIdentification">Require Identification</Label>
<p className="text-xs text-muted-foreground">Audience must log in to vote</p>
</div>
<Switch
id="audienceRequireIdentification"
checked={(config.audienceRequireIdentification as boolean) ?? false}
onCheckedChange={(v) => update('audienceRequireIdentification', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audienceRevealTiming">Reveal Audience Results</Label>
<p className="text-xs text-muted-foreground">When audience vote results become visible</p>
<Select
value={(config.audienceRevealTiming as string) ?? 'at_deliberation'}
onValueChange={(v) => update('audienceRevealTiming', v)}
>
<SelectTrigger id="audienceRevealTiming" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="real_time">Real-time (live updates)</SelectItem>
<SelectItem value="after_jury_scores">After Jury Scores</SelectItem>
<SelectItem value="at_deliberation">At Deliberation</SelectItem>
<SelectItem value="never">Never (admin only)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showAudienceVotesToJury">Show Audience Votes to Jury</Label>
<p className="text-xs text-muted-foreground">Jury can see audience results during deliberation</p>
</div>
<Switch
id="showAudienceVotesToJury"
checked={(config.showAudienceVotesToJury as boolean) ?? false}
onCheckedChange={(v) => update('showAudienceVotesToJury', v)}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* Presentations */}
<Card>
<CardHeader>
<CardTitle className="text-base">Presentation Settings</CardTitle>
<CardDescription>Timing and order for live presentations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="presentationOrderMode">Presentation Order</Label>
<Select
value={(config.presentationOrderMode as string) ?? 'manual'}
onValueChange={(v) => update('presentationOrderMode', v)}
>
<SelectTrigger id="presentationOrderMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual (admin sets order)</SelectItem>
<SelectItem value="random">Random</SelectItem>
<SelectItem value="score_based">Score-based (highest first)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
<Input
id="presentationDuration"
type="number"
min={1}
className="w-32"
value={(config.presentationDurationMinutes as number) ?? 15}
onChange={(e) => update('presentationDurationMinutes', parseInt(e.target.value, 10) || 15)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="qaDuration">Q&A Duration (min)</Label>
<Input
id="qaDuration"
type="number"
min={0}
className="w-32"
value={(config.qaDurationMinutes as number) ?? 5}
onChange={(e) => update('qaDurationMinutes', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Deliberation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Post-Presentation Deliberation</CardTitle>
<CardDescription>Optional deliberation session after all presentations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="deliberationEnabled">Enable Deliberation</Label>
<p className="text-xs text-muted-foreground">Add a deliberation phase after presentations</p>
</div>
<Switch
id="deliberationEnabled"
checked={deliberationEnabled}
onCheckedChange={(v) => update('deliberationEnabled', v)}
/>
</div>
{deliberationEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="deliberationDuration">Deliberation Duration (min)</Label>
<p className="text-xs text-muted-foreground">Time allocated for jury deliberation</p>
<Input
id="deliberationDuration"
type="number"
min={1}
className="w-32"
value={(config.deliberationDurationMinutes as number) ?? 30}
onChange={(e) => update('deliberationDurationMinutes', parseInt(e.target.value, 10) || 30)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="revealPolicy">Results Reveal Policy</Label>
<p className="text-xs text-muted-foreground">When final results are announced</p>
<Select
value={(config.revealPolicy as string) ?? 'ceremony'}
onValueChange={(v) => update('revealPolicy', v)}
>
<SelectTrigger id="revealPolicy" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate (show as votes come in)</SelectItem>
<SelectItem value="delayed">Delayed (admin triggers reveal)</SelectItem>
<SelectItem value="ceremony">Ceremony (formal award announcement)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,133 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
type MentoringConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Mentoring Eligibility</CardTitle>
<CardDescription>Who receives mentoring and how mentors are assigned</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="eligibility">Eligibility</Label>
<p className="text-xs text-muted-foreground">Which projects receive mentoring</p>
<Select
value={(config.eligibility as string) ?? 'requested_only'}
onValueChange={(v) => update('eligibility', v)}
>
<SelectTrigger id="eligibility" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_advancing">All Advancing Projects</SelectItem>
<SelectItem value="requested_only">Requested Only</SelectItem>
<SelectItem value="admin_selected">Admin Selected</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoAssignMentors">Auto-Assign Mentors</Label>
<p className="text-xs text-muted-foreground">Automatically match mentors to projects based on expertise</p>
</div>
<Switch
id="autoAssignMentors"
checked={(config.autoAssignMentors as boolean) ?? false}
onCheckedChange={(v) => update('autoAssignMentors', v)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Communication & Files</CardTitle>
<CardDescription>Features available to mentors and project teams</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="chatEnabled">Chat</Label>
<p className="text-xs text-muted-foreground">Enable messaging between mentor and team</p>
</div>
<Switch
id="chatEnabled"
checked={(config.chatEnabled as boolean) ?? true}
onCheckedChange={(v) => update('chatEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="fileUploadEnabled">File Upload</Label>
<p className="text-xs text-muted-foreground">Allow mentors to upload files for teams</p>
</div>
<Switch
id="fileUploadEnabled"
checked={(config.fileUploadEnabled as boolean) ?? true}
onCheckedChange={(v) => update('fileUploadEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="fileCommentsEnabled">File Comments</Label>
<p className="text-xs text-muted-foreground">Allow mentors to comment on uploaded documents</p>
</div>
<Switch
id="fileCommentsEnabled"
checked={(config.fileCommentsEnabled as boolean) ?? true}
onCheckedChange={(v) => update('fileCommentsEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="filePromotionEnabled">File Promotion</Label>
<p className="text-xs text-muted-foreground">
Allow mentors to promote mentoring files to a submission window
</p>
</div>
<Switch
id="filePromotionEnabled"
checked={(config.filePromotionEnabled as boolean) ?? true}
onCheckedChange={(v) => update('filePromotionEnabled', v)}
/>
</div>
{(config.filePromotionEnabled as boolean) !== false && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="promotionTargetWindowId">Promotion Target Window</Label>
<p className="text-xs text-muted-foreground">
Submission window where promoted files are placed (leave empty for default)
</p>
<Input
id="promotionTargetWindowId"
placeholder="Submission window ID (optional)"
value={(config.promotionTargetWindowId as string) ?? ''}
onChange={(e) => update('promotionTargetWindowId', e.target.value || undefined)}
/>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,108 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
type SubmissionConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
const STATUSES = [
{ value: 'PENDING', label: 'Pending', color: 'bg-gray-100 text-gray-700' },
{ value: 'IN_PROGRESS', label: 'In Progress', color: 'bg-blue-100 text-blue-700' },
{ value: 'PASSED', label: 'Passed', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'REJECTED', label: 'Rejected', color: 'bg-red-100 text-red-700' },
{ value: 'COMPLETED', label: 'Completed', color: 'bg-purple-100 text-purple-700' },
{ value: 'WITHDRAWN', label: 'Withdrawn', color: 'bg-amber-100 text-amber-700' },
]
export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const eligible = (config.eligibleStatuses as string[]) ?? ['PASSED']
const toggleStatus = (status: string) => {
const current = [...eligible]
const idx = current.indexOf(status)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(status)
}
update('eligibleStatuses', current)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Eligibility</CardTitle>
<CardDescription>
Which project states from the previous round are eligible to submit documents in this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Eligible Project Statuses</Label>
<p className="text-xs text-muted-foreground">
Projects with these statuses from the previous round can submit
</p>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Badge
key={s.value}
variant={eligible.includes(s.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleStatus(s.value)}
>
{s.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications & Locking</CardTitle>
<CardDescription>Behavior when the submission round activates</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="notifyEligibleTeams">Notify Eligible Teams</Label>
<p className="text-xs text-muted-foreground">
Send email notification to teams when submission window opens
</p>
</div>
<Switch
id="notifyEligibleTeams"
checked={(config.notifyEligibleTeams as boolean) ?? true}
onCheckedChange={(v) => update('notifyEligibleTeams', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="lockPreviousWindows">Lock Previous Windows</Label>
<p className="text-xs text-muted-foreground">
Prevent uploads to earlier submission windows when this round activates
</p>
</div>
<Switch
id="lockPreviousWindows"
checked={(config.lockPreviousWindows as boolean) ?? true}
onCheckedChange={(v) => update('lockPreviousWindows', v)}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,11 +1,11 @@
'use client';
'use client'
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import {
AlertDialog,
AlertDialogAction,
@ -14,36 +14,70 @@ import {
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { CheckCircle2 } from 'lucide-react';
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { CheckCircle2 } from 'lucide-react'
interface LiveVotingCriterion {
id: string
label: string
description?: string
scale: number
weight: number
}
interface LiveVotingFormProps {
sessionId?: string;
projectId: string;
onVoteSubmit: (vote: { score: number }) => void;
disabled?: boolean;
projectId: string
votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void
disabled?: boolean
existingVote?: {
score: number
criterionScoresJson?: Record<string, number>
} | null
}
export function LiveVotingForm({
sessionId,
projectId,
votingMode = 'simple',
criteria,
onVoteSubmit,
disabled = false
disabled = false,
existingVote,
}: LiveVotingFormProps) {
const [score, setScore] = useState(50);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [score, setScore] = useState(existingVote?.score ?? 50)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {}
)
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote)
const handleSubmit = () => {
setConfirmDialogOpen(true);
};
setConfirmDialogOpen(true)
}
const handleConfirm = () => {
onVoteSubmit({ score });
setHasSubmitted(true);
setConfirmDialogOpen(false);
};
if (votingMode === 'criteria' && criteria) {
// Compute weighted score for display
let weightedSum = 0
for (const c of criteria) {
const normalizedScore = (criterionScores[c.id] / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display
onVoteSubmit({
score: computedScore,
criterionScores,
})
} else {
onVoteSubmit({ score })
}
setHasSubmitted(true)
setConfirmDialogOpen(false)
}
if (hasSubmitted || disabled) {
return (
@ -51,12 +85,126 @@ export function LiveVotingForm({
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
{votingMode === 'simple' && (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
)}
{votingMode === 'criteria' && criteria && (
<div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-4">
<span>{c.label}:</span>
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
)
}
// Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0)
return (
<>
<Card>
<CardHeader>
<CardTitle>Criteria-Based Voting</CardTitle>
<CardDescription>Score each criterion individually</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<Label className="text-base font-semibold">{criterion.label}</Label>
{criterion.description && (
<p className="text-xs text-muted-foreground mt-1">{criterion.description}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Weight: {(criterion.weight * 100).toFixed(0)}%
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="1"
max={criterion.scale}
value={criterionScores[criterion.id] ?? ''}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val)) {
setCriterionScores({
...criterionScores,
[criterion.id]: Math.min(criterion.scale, Math.max(1, val)),
})
}
}}
className="w-20 text-center"
placeholder="0"
/>
<span className="text-lg font-bold">/ {criterion.scale}</span>
</div>
</div>
<Slider
value={[criterionScores[criterion.id] ?? 0]}
onValueChange={(values) =>
setCriterionScores({
...criterionScores,
[criterion.id]: values[0],
})
}
min={0}
max={criterion.scale}
step={1}
className="w-full"
/>
</div>
))}
<Button
onClick={handleSubmit}
disabled={!allScored}
className="w-full"
size="lg"
>
Submit Vote
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p>
{criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span>
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span>
</div>
))}
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone. Are you sure?
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// Simple voting (0-100 slider)
return (
<>
<Card>
@ -120,5 +268,5 @@ export function LiveVotingForm({
</AlertDialogContent>
</AlertDialog>
</>
);
)
}

View File

@ -33,7 +33,8 @@ import {
User,
MessageSquare,
LayoutTemplate,
Medal,
Layers,
Scale,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
@ -69,9 +70,14 @@ const navigation: NavItem[] = [
icon: LayoutDashboard,
},
{
name: 'Competitions',
href: '/admin/competitions',
icon: Medal,
name: 'Rounds',
href: '/admin/rounds',
icon: Layers,
},
{
name: 'Juries',
href: '/admin/juries',
icon: Scale,
},
{
name: 'Awards',

View File

@ -925,4 +925,265 @@ export const fileRouter = router({
return { success: true }
}),
// =========================================================================
// BULK UPLOAD
// =========================================================================
/**
* List projects with their upload status for a given submission window.
* Powers the bulk upload admin page.
*/
listProjectsWithUploadStatus: adminProcedure
.input(
z.object({
submissionWindowId: z.string(),
search: z.string().optional(),
status: z.enum(['all', 'missing', 'complete']).default('all'),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
// Get the submission window with its requirements and competition
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
where: { id: input.submissionWindowId },
include: {
competition: { select: { id: true, programId: true, name: true } },
fileRequirements: { orderBy: { sortOrder: 'asc' } },
},
})
const requirements = window.fileRequirements
// Build project filter
const projectWhere: Record<string, unknown> = {
programId: window.competition.programId,
}
if (input.search) {
projectWhere.OR = [
{ title: { contains: input.search, mode: 'insensitive' } },
{ teamName: { contains: input.search, mode: 'insensitive' } },
]
}
// Get total count first (before status filtering, which happens in-memory)
const allProjects = await ctx.prisma.project.findMany({
where: projectWhere,
select: {
id: true,
title: true,
teamName: true,
submittedByUserId: true,
submittedBy: { select: { id: true, name: true, email: true } },
files: {
where: { submissionWindowId: input.submissionWindowId },
select: {
id: true,
fileName: true,
mimeType: true,
size: true,
createdAt: true,
submissionFileRequirementId: true,
},
},
},
orderBy: { title: 'asc' },
})
// Map projects with their requirement status
const mapped = allProjects.map((project) => {
const reqStatus = requirements.map((req) => {
const file = project.files.find(
(f) => f.submissionFileRequirementId === req.id
)
return {
requirementId: req.id,
label: req.label,
mimeTypes: req.mimeTypes,
required: req.required,
file: file ?? null,
}
})
const totalRequired = reqStatus.filter((r) => r.required).length
const filledRequired = reqStatus.filter(
(r) => r.required && r.file
).length
return {
project: {
id: project.id,
title: project.title,
teamName: project.teamName,
submittedBy: project.submittedBy,
},
requirements: reqStatus,
isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file),
filledCount: reqStatus.filter((r) => r.file).length,
totalCount: reqStatus.length,
}
})
// Apply status filter
const filtered =
input.status === 'missing'
? mapped.filter((p) => !p.isComplete)
: input.status === 'complete'
? mapped.filter((p) => p.isComplete)
: mapped
// Paginate
const total = filtered.length
const totalPages = Math.ceil(total / input.pageSize)
const page = Math.min(input.page, Math.max(totalPages, 1))
const projects = filtered.slice(
(page - 1) * input.pageSize,
page * input.pageSize
)
// Summary stats
const completeCount = mapped.filter((p) => p.isComplete).length
return {
projects,
requirements,
total,
page,
totalPages,
completeCount,
totalProjects: mapped.length,
competition: window.competition,
windowName: window.name,
}
}),
/**
* Admin upload for a specific submission file requirement.
* Creates pre-signed PUT URL + ProjectFile record.
*/
adminUploadForRequirement: adminProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number().int().positive(),
submissionWindowId: z.string(),
submissionFileRequirementId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Block dangerous file extensions
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
if (dangerousExtensions.includes(ext)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${ext}" is not allowed`,
})
}
// Validate requirement exists and belongs to the window
const requirement = await ctx.prisma.submissionFileRequirement.findFirst({
where: {
id: input.submissionFileRequirementId,
submissionWindowId: input.submissionWindowId,
},
})
if (!requirement) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Requirement not found for this submission window',
})
}
// Validate MIME type if requirement specifies allowed types
if (requirement.mimeTypes.length > 0) {
const isAllowed = requirement.mimeTypes.some((allowed) => {
if (allowed.endsWith('/*')) {
return input.mimeType.startsWith(allowed.replace('/*', '/'))
}
return input.mimeType === allowed
})
if (!isAllowed) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.mimeTypes.join(', ')}`,
})
}
}
// Infer fileType from mimeType
let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER'
if (input.mimeType.startsWith('video/')) fileType = 'VIDEO'
else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY'
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
fileType = 'PRESENTATION'
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
// Remove any existing file for this project+requirement combo (replace)
await ctx.prisma.projectFile.deleteMany({
where: {
projectId: input.projectId,
submissionWindowId: input.submissionWindowId,
submissionFileRequirementId: input.submissionFileRequirementId,
},
})
// Create file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId: input.projectId,
fileType,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket,
objectKey,
submissionWindowId: input.submissionWindowId,
submissionFileRequirementId: input.submissionFileRequirementId,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
entityId: file.id,
detailsJson: {
projectId: input.projectId,
fileName: input.fileName,
submissionWindowId: input.submissionWindowId,
submissionFileRequirementId: input.submissionFileRequirementId,
bulkUpload: true,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { uploadUrl, file }
}),
/**
* List submission windows (for the bulk upload window selector)
*/
listSubmissionWindows: adminProcedure
.query(async ({ ctx }) => {
return ctx.prisma.submissionWindow.findMany({
include: {
competition: {
select: { id: true, name: true, program: { select: { name: true, year: true } } },
},
fileRequirements: {
select: { id: true },
},
},
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
})
}),
})

View File

@ -58,6 +58,15 @@ export const specialAwardRouter = router({
program: {
select: { id: true, name: true, year: true },
},
competition: {
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
},
evaluationRound: {
select: { id: true, name: true, roundType: true },
},
awardJuryGroup: {
select: { id: true, name: true },
},
},
})
@ -85,6 +94,10 @@ export const specialAwardRouter = router({
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
maxRankedPicks: z.number().int().min(1).max(20).optional(),
autoTagRulesJson: z.record(z.unknown()).optional(),
competitionId: z.string().optional(),
evaluationRoundId: z.string().optional(),
juryGroupId: z.string().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -104,6 +117,10 @@ export const specialAwardRouter = router({
scoringMode: input.scoringMode,
maxRankedPicks: input.maxRankedPicks,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
competitionId: input.competitionId,
evaluationRoundId: input.evaluationRoundId,
juryGroupId: input.juryGroupId,
eligibilityMode: input.eligibilityMode,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
},
})
@ -140,6 +157,10 @@ export const specialAwardRouter = router({
autoTagRulesJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
competitionId: z.string().nullable().optional(),
evaluationRoundId: z.string().nullable().optional(),
juryGroupId: z.string().nullable().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
})
)
.mutation(async ({ ctx, input }) => {