Files
MOPC-Portal/src/app/(admin)/admin/juries/page.tsx

357 lines
13 KiB
TypeScript
Raw Normal View History

'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, formatEnumLabel } from '@/lib/utils'
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } 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="space-y-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 group">
<CardContent className="p-4">
<div className="space-y-3">
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10 shrink-0">
<Scale className="h-4 w-4 text-brand-blue" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-sm line-clamp-1 group-hover:text-brand-blue transition-colors">
{group.name}
</h3>
<p className="text-xs text-muted-foreground">
{group._count.members} member{group._count.members !== 1 ? 's' : ''}
{' · '}
{group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="secondary"
className={cn('text-[10px]', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-blue transition-colors" />
</div>
</div>
{/* Round assignments */}
{(group as any).rounds?.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{(group as any).rounds.map((r: any) => (
<Badge
key={r.id}
variant="outline"
className={cn(
'text-[10px] gap-1',
r.status === 'ROUND_ACTIVE' && 'border-blue-300 bg-blue-50 text-blue-700',
r.status === 'ROUND_CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
r.status === 'ROUND_DRAFT' && 'border-slate-200 text-slate-500',
)}
>
<CircleDot className="h-2.5 w-2.5" />
{r.name}
</Badge>
))}
</div>
)}
{/* Member preview */}
{(group as any).members?.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="flex -space-x-1.5">
{(group as any).members.slice(0, 5).map((m: any) => (
<div
key={m.id}
className="h-6 w-6 rounded-full bg-brand-blue/10 border-2 border-white flex items-center justify-center text-[9px] font-semibold text-brand-blue"
title={m.user?.name || m.user?.email}
>
{(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
</div>
))}
</div>
{group._count.members > 5 && (
<span className="text-[10px] text-muted-foreground">
+{group._count.members - 5} more
</span>
)}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}