Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION - Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards) - Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review) - Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone - Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields - Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish - Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors - Bulk upload page for admin project imports - File router enhanced with admin upload and submission window procedures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
674
src/app/(admin)/admin/juries/[groupId]/page.tsx
Normal file
674
src/app/(admin)/admin/juries/[groupId]/page.tsx
Normal file
@@ -0,0 +1,674 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Users,
|
||||
Settings,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const capModeLabels = {
|
||||
HARD: 'Hard Cap',
|
||||
SOFT: 'Soft Cap',
|
||||
NONE: 'No Cap',
|
||||
}
|
||||
|
||||
const capModeColors = {
|
||||
HARD: 'bg-red-100 text-red-700',
|
||||
SOFT: 'bg-amber-100 text-amber-700',
|
||||
NONE: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
type JuryGroupDetailPageProps = {
|
||||
params: Promise<{ groupId: string }>
|
||||
}
|
||||
|
||||
export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps) {
|
||||
const resolvedParams = use(params)
|
||||
const groupId = resolvedParams.groupId
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [selectedRole, setSelectedRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||
const [maxAssignmentsOverride, setMaxAssignmentsOverride] = useState('')
|
||||
|
||||
const { data: group, isLoading: loadingGroup } = trpc.juryGroup.getById.useQuery(
|
||||
{ id: groupId },
|
||||
{ enabled: !!groupId }
|
||||
)
|
||||
|
||||
const { data: competition, isLoading: loadingCompetition } = trpc.competition.getById.useQuery(
|
||||
{ id: group?.competitionId ?? '' },
|
||||
{ enabled: !!group?.competitionId }
|
||||
)
|
||||
|
||||
const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery(
|
||||
{
|
||||
role: 'JURY_MEMBER',
|
||||
search: userSearch,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
},
|
||||
{ enabled: addMemberDialogOpen }
|
||||
)
|
||||
|
||||
const { data: selfServiceData } = trpc.juryGroup.reviewSelfServiceValues.useQuery(
|
||||
{ juryGroupId: groupId },
|
||||
{ enabled: !!groupId }
|
||||
)
|
||||
|
||||
const addMemberMutation = trpc.juryGroup.addMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: groupId })
|
||||
utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId })
|
||||
toast.success('Member added')
|
||||
setAddMemberDialogOpen(false)
|
||||
setSelectedUserId('')
|
||||
setUserSearch('')
|
||||
setMaxAssignmentsOverride('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const removeMemberMutation = trpc.juryGroup.removeMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: groupId })
|
||||
utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId })
|
||||
toast.success('Member removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMemberMutation = trpc.juryGroup.updateMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: groupId })
|
||||
toast.success('Member updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateGroupMutation = trpc.juryGroup.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: groupId })
|
||||
utils.juryGroup.list.invalidate()
|
||||
toast.success('Jury group updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleAddMember = () => {
|
||||
if (!selectedUserId) {
|
||||
toast.error('Please select a user')
|
||||
return
|
||||
}
|
||||
|
||||
addMemberMutation.mutate({
|
||||
juryGroupId: groupId,
|
||||
userId: selectedUserId,
|
||||
role: selectedRole,
|
||||
maxAssignmentsOverride: maxAssignmentsOverride
|
||||
? parseInt(maxAssignmentsOverride, 10)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveMember = (memberId: string) => {
|
||||
if (!confirm('Remove this member from the jury group?')) return
|
||||
removeMemberMutation.mutate({ id: memberId })
|
||||
}
|
||||
|
||||
const handleRoleChange = (memberId: string, role: 'CHAIR' | 'MEMBER' | 'OBSERVER') => {
|
||||
updateMemberMutation.mutate({ id: memberId, role })
|
||||
}
|
||||
|
||||
if (loadingGroup || loadingCompetition) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold">Jury Group Not Found</h1>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
Back to Juries
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mb-2"
|
||||
>
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Juries
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold">{group.name}</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-xs', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||
>
|
||||
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{competition?.name ?? 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="members" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="members">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Members Tab */}
|
||||
<TabsContent value="members" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
{group.members.length} member{group.members.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddMemberDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{group.members.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No members yet. Add jury members to this group.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Cap Override</TableHead>
|
||||
<TableHead>Availability</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="font-medium">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{member.user.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(value) =>
|
||||
handleRoleChange(member.id, value as 'CHAIR' | 'MEMBER' | 'OBSERVER')
|
||||
}
|
||||
disabled={updateMemberMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{member.maxAssignmentsOverride ?? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{member.availabilityNotes ? (
|
||||
<span className="text-xs">{member.availabilityNotes}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<SettingsForm
|
||||
group={group}
|
||||
onSave={(data) => updateGroupMutation.mutate({ id: groupId, ...data })}
|
||||
isPending={updateGroupMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Self-Service Review Section */}
|
||||
{selfServiceData && selfServiceData.members.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Self-Service Values</CardTitle>
|
||||
<CardDescription>
|
||||
Members who set their own capacity or ratio during onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Admin Cap</TableHead>
|
||||
<TableHead>Self-Service Cap</TableHead>
|
||||
<TableHead>Self-Service Ratio</TableHead>
|
||||
<TableHead>Preferred Ratio</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selfServiceData.members.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{m.userName}</div>
|
||||
<div className="text-xs text-muted-foreground">{m.userEmail}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{m.adminCap}</TableCell>
|
||||
<TableCell>
|
||||
{m.selfServiceCap ?? <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.selfServiceRatio !== null ? (
|
||||
<span>{(m.selfServiceRatio * 100).toFixed(0)}%</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.preferredStartupRatio !== null ? (
|
||||
<span>{(m.preferredStartupRatio * 100).toFixed(0)}%</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Add Member Dialog */}
|
||||
<Dialog open={addMemberDialogOpen} onOpenChange={setAddMemberDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for a jury member to add to this group
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Search Users</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : userSearchResults?.users && userSearchResults.users.length > 0 ? (
|
||||
<div className="border rounded-md max-h-64 overflow-y-auto">
|
||||
{userSearchResults.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={cn(
|
||||
'p-3 border-b last:border-b-0 cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
selectedUserId === user.id && 'bg-primary/10'
|
||||
)}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<div className="font-medium">{user.name || 'Unnamed'}</div>
|
||||
<div className="text-sm text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No users found. Try a different search.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(v) => setSelectedRole(v as 'CHAIR' | 'MEMBER' | 'OBSERVER')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Max Assignments Override (optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder={`Default: ${group.defaultMaxAssignments}`}
|
||||
value={maxAssignmentsOverride}
|
||||
onChange={(e) => setMaxAssignmentsOverride(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddMemberDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddMember} disabled={addMemberMutation.isPending || !selectedUserId}>
|
||||
{addMemberMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
'Add Member'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Settings Form Component ─────────────────────────────────────────────────
|
||||
|
||||
type SettingsFormProps = {
|
||||
group: any
|
||||
onSave: (data: any) => void
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
||||
defaultCapMode: group.defaultCapMode,
|
||||
softCapBuffer: group.softCapBuffer,
|
||||
categoryQuotasEnabled: group.categoryQuotasEnabled,
|
||||
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
|
||||
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Configure jury group defaults and permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Jury group name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Max Assignments</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.defaultMaxAssignments}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cap Mode</Label>
|
||||
<Select
|
||||
value={formData.defaultCapMode}
|
||||
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||
<SelectItem value="NONE">No Cap</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.defaultCapMode === 'SOFT' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Soft Cap Buffer</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.softCapBuffer}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of assignments allowed above the cap when in soft mode
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Category Quotas Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable category-based assignment quotas
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.categoryQuotasEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, categoryQuotasEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Cap Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own assignment cap during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorCapAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorCapAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Ratio Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own startup/concept ratio during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorRatioAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Settings'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
307
src/app/(admin)/admin/juries/page.tsx
Normal file
307
src/app/(admin)/admin/juries/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
|
||||
|
||||
const capModeLabels = {
|
||||
HARD: 'Hard Cap',
|
||||
SOFT: 'Soft Cap',
|
||||
NONE: 'No Cap',
|
||||
}
|
||||
|
||||
const capModeColors = {
|
||||
HARD: 'bg-red-100 text-red-700',
|
||||
SOFT: 'bg-amber-100 text-amber-700',
|
||||
NONE: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
export default function JuriesPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
competitionId: '',
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const { data: competitions, isLoading: loadingCompetitions } = trpc.competition.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const createMutation = trpc.juryGroup.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.list.invalidate()
|
||||
toast.success('Jury group created')
|
||||
setCreateDialogOpen(false)
|
||||
setFormData({ competitionId: '', name: '', description: '' })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.competitionId || !formData.name.trim()) {
|
||||
toast.error('Competition and name are required')
|
||||
return
|
||||
}
|
||||
|
||||
const slug = formData.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
createMutation.mutate({
|
||||
competitionId: formData.competitionId,
|
||||
name: formData.name.trim(),
|
||||
slug,
|
||||
description: formData.description || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold">Juries</h1>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scale className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">Select an edition from the sidebar</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Juries</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage jury groups for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Jury Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loadingCompetitions && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loadingCompetitions && (!competitions || competitions.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Scale className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Create a competition first, then add jury groups.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Competition Groups */}
|
||||
{competitions && competitions.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{competitions.map((comp) => (
|
||||
<CompetitionJuriesSection key={comp.id} competition={comp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Jury Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new jury panel for a competition.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Competition *</Label>
|
||||
<Select
|
||||
value={formData.competitionId}
|
||||
onValueChange={(v) => setFormData({ ...formData, competitionId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select competition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{competitions?.filter((c) => c.status !== 'ARCHIVED').map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g. Technical Panel A"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Competition Section ─────────────────────────────────────────────────────
|
||||
|
||||
type CompetitionJuriesSectionProps = {
|
||||
competition: any
|
||||
}
|
||||
|
||||
function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps) {
|
||||
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({
|
||||
competitionId: competition.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{competition.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{juryGroups?.length || 0} jury group{juryGroups?.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : !juryGroups || juryGroups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No jury groups configured for this competition.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{juryGroups.map((group) => (
|
||||
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||
>
|
||||
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<span>{group._count.members} members</span>
|
||||
</div>
|
||||
<div>
|
||||
{group._count.assignments} assignments
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default max: {group.defaultMaxAssignments}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
689
src/app/(admin)/admin/projects/bulk-upload/page.tsx
Normal file
689
src/app/(admin)/admin/projects/bulk-upload/page.tsx
Normal file
@@ -0,0 +1,689 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Upload,
|
||||
Search,
|
||||
X,
|
||||
Loader2,
|
||||
FileUp,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
|
||||
type UploadState = {
|
||||
progress: number
|
||||
status: 'uploading' | 'complete' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Key: `${projectId}:${requirementId}`
|
||||
type UploadMap = Record<string, UploadState>
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const [windowId, setWindowId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage, setPerPage] = useState(50)
|
||||
const [uploads, setUploads] = useState<UploadMap>({})
|
||||
|
||||
// Bulk dialog
|
||||
const [bulkProject, setBulkProject] = useState<{
|
||||
id: string
|
||||
title: string
|
||||
requirements: Array<{
|
||||
requirementId: string
|
||||
label: string
|
||||
mimeTypes: string[]
|
||||
required: boolean
|
||||
file: { id: string; fileName: string } | null
|
||||
}>
|
||||
} | null>(null)
|
||||
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
|
||||
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => {
|
||||
setDebouncedSearch(value)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
// Queries
|
||||
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
|
||||
|
||||
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
|
||||
{
|
||||
submissionWindowId: windowId,
|
||||
search: debouncedSearch || undefined,
|
||||
status: statusFilter,
|
||||
page,
|
||||
pageSize: perPage,
|
||||
},
|
||||
{ enabled: !!windowId }
|
||||
)
|
||||
|
||||
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
|
||||
|
||||
// Upload a single file for a project requirement
|
||||
const uploadFileForRequirement = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
requirementId: string,
|
||||
file: File,
|
||||
submissionWindowId: string
|
||||
) => {
|
||||
const key = `${projectId}:${requirementId}`
|
||||
setUploads((prev) => ({
|
||||
...prev,
|
||||
[key]: { progress: 0, status: 'uploading' },
|
||||
}))
|
||||
|
||||
try {
|
||||
const { uploadUrl } = await uploadMutation.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
submissionWindowId,
|
||||
submissionFileRequirementId: requirementId,
|
||||
})
|
||||
|
||||
// XHR upload with progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100)
|
||||
setUploads((prev) => ({
|
||||
...prev,
|
||||
[key]: { progress, status: 'uploading' },
|
||||
}))
|
||||
}
|
||||
})
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
})
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error')))
|
||||
xhr.open('PUT', uploadUrl)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
setUploads((prev) => ({
|
||||
...prev,
|
||||
[key]: { progress: 100, status: 'complete' },
|
||||
}))
|
||||
|
||||
// Refetch data to show updated status
|
||||
refetch()
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Upload failed'
|
||||
setUploads((prev) => ({
|
||||
...prev,
|
||||
[key]: { progress: 0, status: 'error', error: msg },
|
||||
}))
|
||||
toast.error(`Upload failed: ${msg}`)
|
||||
}
|
||||
},
|
||||
[uploadMutation, refetch]
|
||||
)
|
||||
|
||||
// Handle single cell file pick
|
||||
const handleCellUpload = useCallback(
|
||||
(projectId: string, requirementId: string, mimeTypes: string[]) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
if (mimeTypes.length > 0) {
|
||||
input.accept = mimeTypes.join(',')
|
||||
}
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file && windowId) {
|
||||
uploadFileForRequirement(projectId, requirementId, file, windowId)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
[windowId, uploadFileForRequirement]
|
||||
)
|
||||
|
||||
// Handle bulk row upload
|
||||
const handleBulkUploadAll = useCallback(async () => {
|
||||
if (!bulkProject || !windowId) return
|
||||
|
||||
const entries = Object.entries(bulkFiles).filter(
|
||||
([, file]) => file !== null
|
||||
) as Array<[string, File]>
|
||||
|
||||
if (entries.length === 0) {
|
||||
toast.error('No files selected')
|
||||
return
|
||||
}
|
||||
|
||||
// Upload all in parallel
|
||||
await Promise.allSettled(
|
||||
entries.map(([reqId, file]) =>
|
||||
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
|
||||
)
|
||||
)
|
||||
|
||||
setBulkProject(null)
|
||||
setBulkFiles({})
|
||||
toast.success('Bulk upload complete')
|
||||
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
|
||||
|
||||
const progressPercent =
|
||||
data && data.totalProjects > 0
|
||||
? Math.round((data.completeCount / data.totalProjects) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Upload required documents for multiple projects at once
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window Selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Submission Window</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{windowsLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={windowId}
|
||||
onValueChange={(v) => {
|
||||
setWindowId(v)
|
||||
setPage(1)
|
||||
setUploads({})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a submission window..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{windows?.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>
|
||||
{w.competition.program.name} {w.competition.program.year} — {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" />
|
||||
|
||||
408
src/app/(admin)/admin/rounds/[roundId]/page.tsx
Normal file
408
src/app/(admin)/admin/rounds/[roundId]/page.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Square,
|
||||
Archive,
|
||||
} from 'lucide-react'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
||||
import { FileRequirementsEditor } from '@/components/admin/rounds/config/file-requirements-editor'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
const roundStatusConfig: Record<string, { label: string; bgClass: string }> = {
|
||||
ROUND_DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700' },
|
||||
ROUND_ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700' },
|
||||
ROUND_CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700' },
|
||||
ROUND_ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground' },
|
||||
}
|
||||
|
||||
export default function RoundDetailPage() {
|
||||
const params = useParams()
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
|
||||
|
||||
// Fetch competition for jury groups (DELIBERATION) and awards
|
||||
const competitionId = round?.competitionId
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId! },
|
||||
{ enabled: !!competitionId }
|
||||
)
|
||||
const juryGroups = competition?.juryGroups?.map((g: any) => ({ id: g.id, name: g.name }))
|
||||
|
||||
// Fetch awards linked to this round
|
||||
const programId = competition?.programId
|
||||
const { data: awards } = trpc.specialAward.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Filter awards by this round
|
||||
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) || []
|
||||
|
||||
// Update local config when round data changes
|
||||
if (round && !hasChanges) {
|
||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
setConfig(roundConfig)
|
||||
}
|
||||
}
|
||||
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round configuration saved')
|
||||
setHasChanges(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Round lifecycle mutations
|
||||
const activateMutation = trpc.roundEngine.activate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round activated')
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeMutation = trpc.roundEngine.close.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round closed')
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round archived')
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleConfigChange = (newConfig: Record<string, unknown>) => {
|
||||
setConfig(newConfig)
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({ id: roundId, configJson: config })
|
||||
}
|
||||
|
||||
const handleLifecycleAction = () => {
|
||||
if (confirmAction === 'activate') activateMutation.mutate({ roundId })
|
||||
else if (confirmAction === 'close') closeMutation.mutate({ roundId })
|
||||
else if (confirmAction === 'archive') archiveMutation.mutate({ roundId })
|
||||
}
|
||||
|
||||
const isLifecyclePending = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={"/admin/rounds" as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">The requested round does not exist</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
|
||||
const canActivate = round.status === 'ROUND_DRAFT'
|
||||
const canClose = round.status === 'ROUND_ACTIVE'
|
||||
const canArchive = round.status === 'ROUND_CLOSED'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={"/admin/rounds" as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', roundTypeColors[round.roundType])}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
|
||||
{/* Status Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors hover:opacity-80',
|
||||
statusCfg.bgClass,
|
||||
)}>
|
||||
{statusCfg.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{canActivate && (
|
||||
<DropdownMenuItem onClick={() => setConfirmAction('activate')}>
|
||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canClose && (
|
||||
<DropdownMenuItem onClick={() => setConfirmAction('close')}>
|
||||
<Square className="h-4 w-4 mr-2 text-blue-600" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canArchive && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setConfirmAction('archive')}>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!canActivate && !canClose && !canArchive && (
|
||||
<DropdownMenuItem disabled>No actions available</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="config" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="awards">
|
||||
Awards
|
||||
{roundAwards.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{roundAwards.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
<RoundConfigForm
|
||||
roundType={round.roundType}
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
juryGroups={juryGroups}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
<ProjectStatesTable competitionId={round.competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="windows" className="space-y-4">
|
||||
<SubmissionWindowManager competitionId={round.competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" className="space-y-4">
|
||||
<FileRequirementsEditor roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{roundAwards.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No awards linked to this round</p>
|
||||
<p className="text-xs mt-1">
|
||||
Create an award and set this round as its source round to see it here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{roundAwards.map((award) => {
|
||||
const eligibleCount = award._count?.eligibilities || 0
|
||||
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
|
||||
const ruleCount = autoTagRules?.rules?.length || 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={award.id}
|
||||
href={`/admin/awards/${award.id}` as Route}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4 transition-all hover:bg-muted/50 hover:shadow-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{award.name}</h3>
|
||||
<Badge
|
||||
variant={
|
||||
award.eligibilityMode === 'SEPARATE_POOL'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{award.eligibilityMode === 'SEPARATE_POOL'
|
||||
? 'Separate Pool'
|
||||
: 'Stay in Main'}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{award.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-foreground">
|
||||
{ruleCount}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{ruleCount === 1 ? 'rule' : 'rules'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-foreground">
|
||||
{eligibleCount}
|
||||
</div>
|
||||
<div className="text-xs">eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Lifecycle Confirmation Dialog */}
|
||||
<Dialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmAction === 'activate' && 'Activate Round'}
|
||||
{confirmAction === 'close' && 'Close Round'}
|
||||
{confirmAction === 'archive' && 'Archive Round'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmAction === 'activate' && 'This will open the round for submissions and evaluations. Projects will be able to enter this round.'}
|
||||
{confirmAction === 'close' && 'This will close the round. No more submissions or evaluations will be accepted.'}
|
||||
{confirmAction === 'archive' && 'This will archive the round. It will no longer appear in active views.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmAction(null)}>Cancel</Button>
|
||||
<Button onClick={handleLifecycleAction} disabled={isLifecyclePending}>
|
||||
{isLifecyclePending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
552
src/app/(admin)/admin/rounds/page.tsx
Normal file
552
src/app/(admin)/admin/rounds/page.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Users,
|
||||
FileBox,
|
||||
Save,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
const ROUND_TYPES = [
|
||||
{ value: 'INTAKE', label: 'Intake' },
|
||||
{ value: 'FILTERING', label: 'Filtering' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
||||
{ value: 'SUBMISSION', label: 'Submission' },
|
||||
{ value: 'MENTORING', label: 'Mentoring' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
||||
{ value: 'DELIBERATION', label: 'Deliberation' },
|
||||
] as const
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700', dotClass: 'bg-gray-500' },
|
||||
ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700', dotClass: 'bg-emerald-500' },
|
||||
CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700', dotClass: 'bg-blue-500' },
|
||||
ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground', dotClass: 'bg-muted-foreground' },
|
||||
} as const
|
||||
|
||||
const roundStatusColors: Record<string, string> = {
|
||||
ROUND_DRAFT: 'bg-gray-100 text-gray-600',
|
||||
ROUND_ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ROUND_CLOSED: 'bg-blue-100 text-blue-700',
|
||||
ROUND_ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' })
|
||||
const [expandedCompetitions, setExpandedCompetitions] = useState<Set<string>>(new Set())
|
||||
const [editingCompetition, setEditingCompetition] = useState<string | null>(null)
|
||||
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
|
||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const createRoundMutation = trpc.round.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.list.invalidate()
|
||||
toast.success('Round created')
|
||||
setAddRoundOpen(false)
|
||||
setRoundForm({ name: '', roundType: '', competitionId: '' })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateCompMutation = trpc.competition.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.list.invalidate()
|
||||
toast.success('Competition settings saved')
|
||||
setEditingCompetition(null)
|
||||
setCompetitionEdits({})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedCompetitions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateRound = () => {
|
||||
if (!roundForm.name.trim() || !roundForm.roundType || !roundForm.competitionId) {
|
||||
toast.error('All fields are required')
|
||||
return
|
||||
}
|
||||
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const comp = competitions?.find((c) => c.id === roundForm.competitionId)
|
||||
const nextOrder = comp ? (comp as any).rounds?.length ?? comp._count.rounds : 0
|
||||
createRoundMutation.mutate({
|
||||
competitionId: roundForm.competitionId,
|
||||
name: roundForm.name.trim(),
|
||||
slug,
|
||||
roundType: roundForm.roundType as any,
|
||||
sortOrder: nextOrder,
|
||||
})
|
||||
}
|
||||
|
||||
const startEditCompetition = (comp: any) => {
|
||||
setEditingCompetition(comp.id)
|
||||
setCompetitionEdits({
|
||||
name: comp.name,
|
||||
categoryMode: comp.categoryMode,
|
||||
startupFinalistCount: comp.startupFinalistCount,
|
||||
conceptFinalistCount: comp.conceptFinalistCount,
|
||||
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
|
||||
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
|
||||
})
|
||||
}
|
||||
|
||||
const saveCompetitionEdit = (id: string) => {
|
||||
updateCompMutation.mutate({ id, ...competitionEdits } as any)
|
||||
}
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold">Rounds</h1>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">Select an edition from the sidebar</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Rounds</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage all competition rounds for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddRoundOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Round
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!competitions || competitions.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Layers className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Create a competition first, then add rounds to define the evaluation flow.
|
||||
</p>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Competition
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Competition Groups with Rounds */}
|
||||
{competitions && competitions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{competitions.map((comp) => {
|
||||
const status = comp.status as keyof typeof statusConfig
|
||||
const cfg = statusConfig[status] || statusConfig.DRAFT
|
||||
const isExpanded = expandedCompetitions.has(comp.id) || competitions.length === 1
|
||||
const isEditing = editingCompetition === comp.id
|
||||
|
||||
return (
|
||||
<CompetitionGroup
|
||||
key={comp.id}
|
||||
competition={comp}
|
||||
statusConfig={cfg}
|
||||
isExpanded={isExpanded}
|
||||
isEditing={isEditing}
|
||||
competitionEdits={competitionEdits}
|
||||
filterType={filterType}
|
||||
updateCompMutation={updateCompMutation}
|
||||
onToggle={() => toggleExpanded(comp.id)}
|
||||
onStartEdit={() => startEditCompetition(comp)}
|
||||
onCancelEdit={() => { setEditingCompetition(null); setCompetitionEdits({}) }}
|
||||
onSaveEdit={() => saveCompetitionEdit(comp.id)}
|
||||
onEditChange={setCompetitionEdits}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Round Dialog */}
|
||||
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new round in a competition.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Competition *</Label>
|
||||
<Select
|
||||
value={roundForm.competitionId}
|
||||
onValueChange={(v) => setRoundForm({ ...roundForm, competitionId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select competition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{competitions?.filter((c) => c.status !== 'ARCHIVED').map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Round Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g. Initial Screening"
|
||||
value={roundForm.name}
|
||||
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Round Type *</Label>
|
||||
<Select
|
||||
value={roundForm.roundType}
|
||||
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
|
||||
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Competition Group Component ─────────────────────────────────────────────
|
||||
|
||||
type CompetitionGroupProps = {
|
||||
competition: any
|
||||
statusConfig: { label: string; bgClass: string; dotClass: string }
|
||||
isExpanded: boolean
|
||||
isEditing: boolean
|
||||
competitionEdits: Record<string, unknown>
|
||||
filterType: string
|
||||
updateCompMutation: any
|
||||
onToggle: () => void
|
||||
onStartEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
onSaveEdit: () => void
|
||||
onEditChange: (edits: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
function CompetitionGroup({
|
||||
competition: comp,
|
||||
statusConfig: cfg,
|
||||
isExpanded,
|
||||
isEditing,
|
||||
competitionEdits,
|
||||
filterType,
|
||||
updateCompMutation,
|
||||
onToggle,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onEditChange,
|
||||
}: CompetitionGroupProps) {
|
||||
// We need to fetch rounds for this competition
|
||||
const { data: compDetail } = trpc.competition.getById.useQuery(
|
||||
{ id: comp.id },
|
||||
{ enabled: isExpanded }
|
||||
)
|
||||
|
||||
const rounds = compDetail?.rounds ?? []
|
||||
const filteredRounds = filterType === 'all'
|
||||
? rounds
|
||||
: rounds.filter((r: any) => r.roundType === filterType)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* Competition Header */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="shrink-0 rounded p-1 hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-base cursor-pointer" onClick={onToggle}>
|
||||
{comp.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', cfg.bgClass)}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comp._count.rounds} rounds
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comp._count.juryGroups} juries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onStartEdit() }}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Inline Competition Settings Editor */}
|
||||
{isEditing && (
|
||||
<CardContent className="border-t bg-muted/30 pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Competition Name</Label>
|
||||
<Input
|
||||
value={(competitionEdits.name as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Category Mode</Label>
|
||||
<Input
|
||||
value={(competitionEdits.categoryMode as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Startup Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24"
|
||||
value={(competitionEdits.startupFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Concept Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24"
|
||||
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
||||
/>
|
||||
<Label className="text-xs">Notify on Advance</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
|
||||
/>
|
||||
<Label className="text-xs">Deadline Reminders</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveEdit}
|
||||
disabled={updateCompMutation.isPending}
|
||||
>
|
||||
{updateCompMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelEdit}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Rounds List */}
|
||||
{isExpanded && (
|
||||
<CardContent className={cn(isEditing ? '' : 'pt-0')}>
|
||||
{!compDetail ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : filteredRounds.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredRounds.map((round: any, index: number) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{round.sortOrder + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] shrink-0', roundTypeColors[round.roundType])}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] shrink-0 hidden sm:inline-flex', roundStatusColors[round.status])}
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user