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:
parent
fbb194067d
commit
4c0efb232c
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -87,20 +87,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
// Fetch files (flat list for backward compatibility)
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
// Fetch file requirements from the competition's intake round
|
||||
// Note: This procedure may need to be updated or removed depending on new system
|
||||
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
|
||||
// { projectId },
|
||||
// { enabled: !!project }
|
||||
// )
|
||||
const requirementsData = null // Placeholder until procedure is updated
|
||||
|
||||
// Fetch available rounds for upload selector (if project has a programId)
|
||||
const { data: programData } = trpc.program.get.useQuery(
|
||||
{ id: project?.programId || '' },
|
||||
// Fetch competitions for this project's program to get rounds
|
||||
const { data: competitions } = trpc.competition.list.useQuery(
|
||||
{ programId: project?.programId || '' },
|
||||
{ enabled: !!project?.programId }
|
||||
)
|
||||
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
|
||||
|
||||
// Get first competition ID to fetch full details with rounds
|
||||
const competitionId = competitions?.[0]?.id
|
||||
|
||||
// Fetch full competition details including rounds
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId || '' },
|
||||
{ enabled: !!competitionId }
|
||||
)
|
||||
|
||||
// Extract all rounds from the competition
|
||||
const competitionRounds = competition?.rounds || []
|
||||
|
||||
// Fetch requirements for each round
|
||||
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
|
||||
trpc.file.listRequirements.useQuery({ roundId: round.id })
|
||||
)
|
||||
|
||||
// Combine requirements from all rounds
|
||||
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -157,7 +168,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
href={`/admin/programs/${project.programId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{programData?.name ?? 'Program'}
|
||||
Program
|
||||
</Link>
|
||||
) : (
|
||||
<span>No program</span>
|
||||
|
|
@ -526,84 +537,114 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
Files
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project documents and materials
|
||||
Project documents and materials organized by competition round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Required Documents from Competition Intake Round */}
|
||||
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
|
||||
<CardContent className="space-y-6">
|
||||
{/* Requirements organized by round */}
|
||||
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">Required Documents</p>
|
||||
<div className="grid gap-2">
|
||||
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
|
||||
const isFulfilled = req.fulfilled
|
||||
return (
|
||||
<div
|
||||
key={req.id ?? `req-${idx}`}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isFulfilled
|
||||
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
|
||||
: 'border-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{req.name}</p>
|
||||
{req.isRequired && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
Required
|
||||
</Badge>
|
||||
{competitionRounds.map((round: { id: string; name: string }) => {
|
||||
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
|
||||
if (roundRequirements.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={round.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{round.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{roundRequirements.map((req: any) => {
|
||||
// Find file that fulfills this requirement
|
||||
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
|
||||
const isFulfilled = !!fulfilledFile
|
||||
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isFulfilled
|
||||
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
|
||||
: 'border-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{req.name}</p>
|
||||
{req.isRequired && (
|
||||
<Badge variant="destructive" className="text-xs shrink-0">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{req.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{req.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
{req.acceptedMimeTypes.length > 0 && (
|
||||
<span>
|
||||
{req.acceptedMimeTypes.map((mime: string) => {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime === 'image/*') return 'Images'
|
||||
if (mime === 'video/*') return 'Video'
|
||||
if (mime.includes('wordprocessing')) return 'Word'
|
||||
if (mime.includes('spreadsheet')) return 'Excel'
|
||||
if (mime.includes('presentation')) return 'PowerPoint'
|
||||
return mime.split('/')[1] || mime
|
||||
}).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{req.maxSizeMB && (
|
||||
<span className="shrink-0">• Max {req.maxSizeMB}MB</span>
|
||||
)}
|
||||
</div>
|
||||
{isFulfilled && fulfilledFile && (
|
||||
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
|
||||
✓ {fulfilledFile.fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{req.description && (
|
||||
<span className="truncate">{req.description}</span>
|
||||
)}
|
||||
{req.maxSizeMB && (
|
||||
<span className="shrink-0">Max {req.maxSizeMB}MB</span>
|
||||
)}
|
||||
</div>
|
||||
{isFulfilled && req.fulfilledFile && (
|
||||
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{req.fulfilledFile.fileName}
|
||||
</p>
|
||||
{!isFulfilled && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
|
||||
Missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isFulfilled && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
Missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Additional Documents Upload */}
|
||||
{/* General file upload section */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
|
||||
? 'Additional Documents'
|
||||
: 'Upload New Files'}
|
||||
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Upload files not tied to specific requirements
|
||||
</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
||||
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
|
||||
onUploadComplete={() => {
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
// utils.file.getProjectRequirements.invalidate({ projectId })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -613,7 +654,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">All Files</p>
|
||||
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
|
|
|
|||
|
|
@ -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} — {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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -20,66 +21,201 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ArrowLeft, Save, Send, AlertCircle } from 'lucide-react'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
|
||||
export default function JuryEvaluatePage() {
|
||||
const params = useParams()
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; projectId: string }>
|
||||
}
|
||||
|
||||
export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
const params = use(paramsPromise)
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
const projectId = params.projectId as string
|
||||
const { roundId, projectId } = params
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [showCOIDialog, setShowCOIDialog] = useState(true)
|
||||
const [coiAccepted, setCoiAccepted] = useState(false)
|
||||
|
||||
// Evaluation form state
|
||||
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
|
||||
const [globalScore, setGlobalScore] = useState('')
|
||||
const [feedbackGeneral, setFeedbackGeneral] = useState('')
|
||||
const [feedbackStrengths, setFeedbackStrengths] = useState('')
|
||||
const [feedbackWeaknesses, setFeedbackWeaknesses] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
|
||||
// Fetch project
|
||||
const { data: project } = trpc.project.get.useQuery(
|
||||
{ id: projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
// Fetch round to get config
|
||||
const { data: round } = trpc.round.getById.useQuery(
|
||||
{ id: roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Fetch assignment to get evaluation
|
||||
const { data: assignment } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
const myAssignment = assignment?.find((a) => a.projectId === projectId)
|
||||
|
||||
// Fetch existing evaluation if it exists
|
||||
const { data: existingEvaluation } = trpc.evaluation.get.useQuery(
|
||||
{ assignmentId: myAssignment?.id ?? '' },
|
||||
{ enabled: !!myAssignment?.id }
|
||||
)
|
||||
|
||||
// Start evaluation mutation (creates draft)
|
||||
const startMutation = trpc.evaluation.start.useMutation()
|
||||
|
||||
// Autosave mutation
|
||||
const autosaveMutation = trpc.evaluation.autosave.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Draft saved', { duration: 1500 })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Submit mutation
|
||||
const submitMutation = trpc.evaluation.submit.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundAssignment.getMyAssignments.invalidate()
|
||||
utils.evaluation.get.invalidate()
|
||||
toast.success('Evaluation submitted successfully')
|
||||
router.push(`/jury/competitions/${roundId}` as Route)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const score = parseInt(globalScore)
|
||||
if (isNaN(score) || score < 1 || score > 10) {
|
||||
toast.error('Please enter a valid score between 1 and 10')
|
||||
// Load existing evaluation data
|
||||
useEffect(() => {
|
||||
if (existingEvaluation) {
|
||||
if (existingEvaluation.criterionScoresJson) {
|
||||
const scores: Record<string, number> = {}
|
||||
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
||||
scores[key] = typeof value === 'number' ? value : 0
|
||||
})
|
||||
setCriteriaScores(scores)
|
||||
}
|
||||
if (existingEvaluation.globalScore) {
|
||||
setGlobalScore(existingEvaluation.globalScore.toString())
|
||||
}
|
||||
if (existingEvaluation.binaryDecision !== null) {
|
||||
setBinaryDecision(existingEvaluation.binaryDecision ? 'accept' : 'reject')
|
||||
}
|
||||
if (existingEvaluation.feedbackText) {
|
||||
setFeedbackText(existingEvaluation.feedbackText)
|
||||
}
|
||||
}
|
||||
}, [existingEvaluation])
|
||||
|
||||
// Parse evaluation config from round
|
||||
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
|
||||
const scoringMode = evalConfig?.scoringMode ?? 'global'
|
||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||
|
||||
// Get criteria from evaluation form
|
||||
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
weight?: number
|
||||
minScore?: number
|
||||
maxScore?: number
|
||||
}> | undefined
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!myAssignment) {
|
||||
toast.error('Assignment not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
|
||||
toast.error('Please provide general feedback (minimum 10 characters)')
|
||||
return
|
||||
// Create evaluation if it doesn't exist
|
||||
let evaluationId = existingEvaluation?.id
|
||||
if (!evaluationId) {
|
||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||
evaluationId = newEval.id
|
||||
}
|
||||
|
||||
// In a real implementation, we would first get or create the evaluation ID
|
||||
// For now, this is a placeholder that shows the structure
|
||||
toast.error('Evaluation submission requires an existing evaluation ID. This feature needs backend integration.')
|
||||
|
||||
/* Real implementation would be:
|
||||
submitMutation.mutate({
|
||||
id: evaluationId, // From assignment.evaluation.id
|
||||
criterionScoresJson: {}, // Criterion scores
|
||||
globalScore: score,
|
||||
binaryDecision: true,
|
||||
feedbackText: feedbackGeneral,
|
||||
// Autosave current state
|
||||
autosaveMutation.mutate({
|
||||
id: evaluationId,
|
||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
|
||||
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
||||
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
||||
feedbackText: feedbackText || null,
|
||||
})
|
||||
*/
|
||||
}
|
||||
|
||||
if (!coiAccepted && showCOIDialog) {
|
||||
const handleSubmit = async () => {
|
||||
if (!myAssignment) {
|
||||
toast.error('Assignment not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Validation based on scoring mode
|
||||
if (scoringMode === 'criteria') {
|
||||
if (!criteria || criteria.length === 0) {
|
||||
toast.error('No criteria found for this evaluation')
|
||||
return
|
||||
}
|
||||
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
|
||||
if (requiredCriteria) {
|
||||
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
|
||||
if (!allScored) {
|
||||
toast.error('Please score all criteria')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scoringMode === 'global') {
|
||||
const score = parseInt(globalScore, 10)
|
||||
if (isNaN(score) || score < 1 || score > 10) {
|
||||
toast.error('Please enter a valid score between 1 and 10')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (scoringMode === 'binary') {
|
||||
if (!binaryDecision) {
|
||||
toast.error('Please select accept or reject')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (requireFeedback) {
|
||||
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
||||
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create evaluation if needed
|
||||
let evaluationId = existingEvaluation?.id
|
||||
if (!evaluationId) {
|
||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||
evaluationId = newEval.id
|
||||
}
|
||||
|
||||
// Submit
|
||||
submitMutation.mutate({
|
||||
id: evaluationId,
|
||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
|
||||
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
|
||||
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
||||
feedbackText: feedbackText || 'No feedback provided',
|
||||
})
|
||||
}
|
||||
|
||||
// COI Dialog
|
||||
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
|
||||
return (
|
||||
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
|
||||
<DialogContent>
|
||||
|
|
@ -127,6 +263,21 @@ export default function JuryEvaluatePage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!round || !project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -140,9 +291,7 @@ export default function JuryEvaluatePage() {
|
|||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
Evaluate Project
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{project?.title || 'Loading...'}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,61 +312,108 @@ export default function JuryEvaluatePage() {
|
|||
<CardHeader>
|
||||
<CardTitle>Evaluation Form</CardTitle>
|
||||
<CardDescription>
|
||||
Provide your assessment of the project
|
||||
Provide your assessment using the {scoringMode} scoring method
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="globalScore">
|
||||
Overall Score <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="globalScore"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={globalScore}
|
||||
onChange={(e) => setGlobalScore(e.target.value)}
|
||||
placeholder="Enter score (1-10)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a score from 1 to 10 based on your overall assessment
|
||||
</p>
|
||||
</div>
|
||||
{/* Criteria-based scoring */}
|
||||
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Criteria Scores</h3>
|
||||
{criteria.map((criterion) => (
|
||||
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg">
|
||||
<Label htmlFor={criterion.id}>
|
||||
{criterion.label}
|
||||
{evalConfig?.requireAllCriteriaScored !== false && (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-xs text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={criterion.id}
|
||||
type="number"
|
||||
min={criterion.minScore ?? 0}
|
||||
max={criterion.maxScore ?? 10}
|
||||
value={criteriaScores[criterion.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setCriteriaScores({
|
||||
...criteriaScores,
|
||||
[criterion.id]: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global scoring */}
|
||||
{scoringMode === 'global' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="globalScore">
|
||||
Overall Score <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="globalScore"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={globalScore}
|
||||
onChange={(e) => setGlobalScore(e.target.value)}
|
||||
placeholder="Enter score (1-10)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a score from 1 to 10 based on your overall assessment
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Binary decision */}
|
||||
{scoringMode === 'binary' && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Decision <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
|
||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||
<RadioGroupItem value="accept" id="accept" />
|
||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<ThumbsUp className="h-4 w-4 text-emerald-600" />
|
||||
<span>Accept — This project should advance</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-red-50/50">
|
||||
<RadioGroupItem value="reject" id="reject" />
|
||||
<Label htmlFor="reject" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<ThumbsDown className="h-4 w-4 text-red-600" />
|
||||
<span>Reject — This project should not advance</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackGeneral">
|
||||
General Feedback <span className="text-destructive">*</span>
|
||||
<Label htmlFor="feedbackText">
|
||||
Feedback
|
||||
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="feedbackGeneral"
|
||||
value={feedbackGeneral}
|
||||
onChange={(e) => setFeedbackGeneral(e.target.value)}
|
||||
placeholder="Provide your overall feedback on the project..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackStrengths">Strengths</Label>
|
||||
<Textarea
|
||||
id="feedbackStrengths"
|
||||
value={feedbackStrengths}
|
||||
onChange={(e) => setFeedbackStrengths(e.target.value)}
|
||||
placeholder="What are the key strengths of this project?"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackWeaknesses">Areas for Improvement</Label>
|
||||
<Textarea
|
||||
id="feedbackWeaknesses"
|
||||
value={feedbackWeaknesses}
|
||||
onChange={(e) => setFeedbackWeaknesses(e.target.value)}
|
||||
placeholder="What areas could be improved?"
|
||||
rows={4}
|
||||
id="feedbackText"
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
placeholder="Provide your feedback on the project..."
|
||||
rows={8}
|
||||
/>
|
||||
{requireFeedback && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -232,14 +428,15 @@ export default function JuryEvaluatePage() {
|
|||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={submitMutation.isPending}
|
||||
onClick={handleSaveDraft}
|
||||
disabled={autosaveMutation.isPending || submitMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Draft
|
||||
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitMutation.isPending}
|
||||
disabled={submitMutation.isPending || autosaveMutation.isPending}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
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 (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
Unknown round type: {roundType}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'INTAKE') {
|
||||
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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,425 +1,425 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const MIME_TYPE_PRESETS = [
|
||||
{ label: "PDF", value: "application/pdf" },
|
||||
{ label: "Images", value: "image/*" },
|
||||
{ label: "Video", value: "video/*" },
|
||||
{
|
||||
label: "Word Documents",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime);
|
||||
if (preset) return preset.label;
|
||||
if (mime.endsWith("/*")) return mime.replace("/*", "");
|
||||
return mime;
|
||||
}
|
||||
|
||||
interface FileRequirementsEditorProps {
|
||||
roundId: string;
|
||||
}
|
||||
|
||||
interface RequirementFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptedMimeTypes: string[];
|
||||
maxSizeMB: string;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: RequirementFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
acceptedMimeTypes: [],
|
||||
maxSizeMB: "",
|
||||
isRequired: true,
|
||||
};
|
||||
|
||||
export function FileRequirementsEditor({
|
||||
roundId,
|
||||
}: FileRequirementsEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: requirements = [], isLoading } =
|
||||
trpc.file.listRequirements.useQuery({ roundId });
|
||||
const createMutation = trpc.file.createRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement created");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement updated");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement deleted");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const reorderMutation = trpc.file.reorderRequirements.useMutation({
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RequirementFormData>(emptyForm);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (req: (typeof requirements)[number]) => {
|
||||
setEditingId(req.id);
|
||||
setForm({
|
||||
name: req.name,
|
||||
description: req.description || "",
|
||||
acceptedMimeTypes: req.acceptedMimeTypes,
|
||||
maxSizeMB: req.maxSizeMB?.toString() || "",
|
||||
isRequired: req.isRequired,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined;
|
||||
|
||||
if (editingId) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
maxSizeMB: maxSizeMB || null,
|
||||
isRequired: form.isRequired,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync({
|
||||
roundId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
maxSizeMB,
|
||||
isRequired: form.isRequired,
|
||||
sortOrder: requirements.length,
|
||||
});
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync({ id });
|
||||
};
|
||||
|
||||
const handleMove = async (index: number, direction: "up" | "down") => {
|
||||
const newOrder = [...requirements];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
|
||||
[newOrder[index], newOrder[swapIndex]] = [
|
||||
newOrder[swapIndex],
|
||||
newOrder[index],
|
||||
];
|
||||
await reorderMutation.mutateAsync({
|
||||
roundId,
|
||||
orderedIds: newOrder.map((r) => r.id),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMimeType = (mime: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
|
||||
? prev.acceptedMimeTypes.filter((m) => m !== mime)
|
||||
: [...prev.acceptedMimeTypes, mime],
|
||||
}));
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
File Requirements
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Define required files applicants must upload for this round
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" onClick={openCreate} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : requirements.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No file requirements defined. Applicants can still upload files
|
||||
freely.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requirements.map((req, index) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 bg-background"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(index, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(index, "down")}
|
||||
disabled={index === requirements.length - 1}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium truncate">{req.name}</span>
|
||||
<Badge
|
||||
variant={req.isRequired ? "destructive" : "secondary"}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{req.isRequired ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</div>
|
||||
{req.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{req.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{req.acceptedMimeTypes.map((mime) => (
|
||||
<Badge key={mime} variant="outline" className="text-xs">
|
||||
{getMimeLabel(mime)}
|
||||
</Badge>
|
||||
))}
|
||||
{req.maxSizeMB && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Max {req.maxSizeMB}MB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(req)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(req.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Edit" : "Add"} File Requirement
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define what file applicants need to upload for this round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-name">Name *</Label>
|
||||
<Input
|
||||
id="req-name"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g., Executive Summary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-desc">Description</Label>
|
||||
<Textarea
|
||||
id="req-desc"
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
placeholder="Describe what this file should contain..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Accepted File Types</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MIME_TYPE_PRESETS.map((preset) => (
|
||||
<Badge
|
||||
key={preset.value}
|
||||
variant={
|
||||
form.acceptedMimeTypes.includes(preset.value)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleMimeType(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to accept any file type
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-size">Max File Size (MB)</Label>
|
||||
<Input
|
||||
id="req-size"
|
||||
type="number"
|
||||
value={form.maxSizeMB}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, maxSizeMB: e.target.value }))
|
||||
}
|
||||
placeholder="No limit"
|
||||
min={1}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="req-required">Required</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applicants must upload this file
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="req-required"
|
||||
checked={form.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((p) => ({ ...p, isRequired: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const MIME_TYPE_PRESETS = [
|
||||
{ label: "PDF", value: "application/pdf" },
|
||||
{ label: "Images", value: "image/*" },
|
||||
{ label: "Video", value: "video/*" },
|
||||
{
|
||||
label: "Word Documents",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime);
|
||||
if (preset) return preset.label;
|
||||
if (mime.endsWith("/*")) return mime.replace("/*", "");
|
||||
return mime;
|
||||
}
|
||||
|
||||
interface FileRequirementsEditorProps {
|
||||
roundId: string;
|
||||
}
|
||||
|
||||
interface RequirementFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptedMimeTypes: string[];
|
||||
maxSizeMB: string;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: RequirementFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
acceptedMimeTypes: [],
|
||||
maxSizeMB: "",
|
||||
isRequired: true,
|
||||
};
|
||||
|
||||
export function FileRequirementsEditor({
|
||||
roundId,
|
||||
}: FileRequirementsEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: requirements = [], isLoading } =
|
||||
trpc.file.listRequirements.useQuery({ roundId });
|
||||
const createMutation = trpc.file.createRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement created");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement updated");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement deleted");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const reorderMutation = trpc.file.reorderRequirements.useMutation({
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RequirementFormData>(emptyForm);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (req: (typeof requirements)[number]) => {
|
||||
setEditingId(req.id);
|
||||
setForm({
|
||||
name: req.name,
|
||||
description: req.description || "",
|
||||
acceptedMimeTypes: req.acceptedMimeTypes,
|
||||
maxSizeMB: req.maxSizeMB?.toString() || "",
|
||||
isRequired: req.isRequired,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined;
|
||||
|
||||
if (editingId) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
maxSizeMB: maxSizeMB || null,
|
||||
isRequired: form.isRequired,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync({
|
||||
roundId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
maxSizeMB,
|
||||
isRequired: form.isRequired,
|
||||
sortOrder: requirements.length,
|
||||
});
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync({ id });
|
||||
};
|
||||
|
||||
const handleMove = async (index: number, direction: "up" | "down") => {
|
||||
const newOrder = [...requirements];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
|
||||
[newOrder[index], newOrder[swapIndex]] = [
|
||||
newOrder[swapIndex],
|
||||
newOrder[index],
|
||||
];
|
||||
await reorderMutation.mutateAsync({
|
||||
roundId,
|
||||
orderedIds: newOrder.map((r) => r.id),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMimeType = (mime: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
|
||||
? prev.acceptedMimeTypes.filter((m) => m !== mime)
|
||||
: [...prev.acceptedMimeTypes, mime],
|
||||
}));
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
File Requirements
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Define required files applicants must upload for this round
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" onClick={openCreate} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : requirements.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No file requirements defined. Applicants can still upload files
|
||||
freely.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requirements.map((req, index) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 bg-background"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(index, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(index, "down")}
|
||||
disabled={index === requirements.length - 1}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium truncate">{req.name}</span>
|
||||
<Badge
|
||||
variant={req.isRequired ? "destructive" : "secondary"}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{req.isRequired ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</div>
|
||||
{req.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{req.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{req.acceptedMimeTypes.map((mime) => (
|
||||
<Badge key={mime} variant="outline" className="text-xs">
|
||||
{getMimeLabel(mime)}
|
||||
</Badge>
|
||||
))}
|
||||
{req.maxSizeMB && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Max {req.maxSizeMB}MB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(req)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(req.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Edit" : "Add"} File Requirement
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define what file applicants need to upload for this round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-name">Name *</Label>
|
||||
<Input
|
||||
id="req-name"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g., Executive Summary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-desc">Description</Label>
|
||||
<Textarea
|
||||
id="req-desc"
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
placeholder="Describe what this file should contain..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Accepted File Types</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MIME_TYPE_PRESETS.map((preset) => (
|
||||
<Badge
|
||||
key={preset.value}
|
||||
variant={
|
||||
form.acceptedMimeTypes.includes(preset.value)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleMimeType(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to accept any file type
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-size">Max File Size (MB)</Label>
|
||||
<Input
|
||||
id="req-size"
|
||||
type="number"
|
||||
value={form.maxSizeMB}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, maxSizeMB: e.target.value }))
|
||||
}
|
||||
placeholder="No limit"
|
||||
min={1}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="req-required">Required</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applicants must upload this file
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="req-required"
|
||||
checked={form.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((p) => ({ ...p, isRequired: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "High" auto-pass,
|
||||
below "Low" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</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>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue