MOPC-App/src/app/(admin)/admin/awards/[id]/page.tsx

552 lines
19 KiB
TypeScript
Raw Normal View History

'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { UserAvatar } from '@/components/shared/user-avatar'
import { Pagination } from '@/components/shared/pagination'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
Users,
CheckCircle2,
Brain,
BarChart3,
Loader2,
Crown,
UserPlus,
X,
Play,
Lock,
Pencil,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DRAFT: 'secondary',
NOMINATIONS_OPEN: 'default',
VOTING_OPEN: 'default',
CLOSED: 'outline',
ARCHIVED: 'secondary',
}
export default function AwardDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
page: 1,
perPage: 50,
})
const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId })
const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId })
const { data: allUsers } = trpc.user.list.useQuery({ page: 1, perPage: 100 })
const updateStatus = trpc.specialAward.updateStatus.useMutation()
const runEligibility = trpc.specialAward.runEligibility.useMutation()
const setEligibility = trpc.specialAward.setEligibility.useMutation()
const addJuror = trpc.specialAward.addJuror.useMutation()
const removeJuror = trpc.specialAward.removeJuror.useMutation()
const setWinner = trpc.specialAward.setWinner.useMutation()
const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true)
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
) => {
try {
await updateStatus.mutateAsync({ id: awardId, status })
toast.success(`Status updated to ${status.replace('_', ' ')}`)
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update status'
)
}
}
const handleRunEligibility = async () => {
try {
const result = await runEligibility.mutateAsync({ awardId, includeSubmitted })
toast.success(
`Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible`
)
refetchEligibility()
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to run eligibility'
)
}
}
const handleToggleEligibility = async (
projectId: string,
eligible: boolean
) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible })
refetchEligibility()
} catch {
toast.error('Failed to update eligibility')
}
}
const handleAddJuror = async () => {
if (!selectedJurorId) return
try {
await addJuror.mutateAsync({ awardId, userId: selectedJurorId })
toast.success('Juror added')
setSelectedJurorId('')
refetchJurors()
} catch {
toast.error('Failed to add juror')
}
}
const handleRemoveJuror = async (userId: string) => {
try {
await removeJuror.mutateAsync({ awardId, userId })
refetchJurors()
} catch {
toast.error('Failed to remove juror')
}
}
const handleSetWinner = async (projectId: string) => {
try {
await setWinner.mutateAsync({
awardId,
projectId,
overridden: true,
})
toast.success('Winner set')
refetch()
} catch {
toast.error('Failed to set winner')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-40 w-full" />
</div>
)
}
if (!award) return null
const jurorUserIds = new Set(jurors?.map((j) => j.userId) || [])
const availableUsers =
allUsers?.users.filter((u) => !jurorUserIds.has(u.id)) || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/awards">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Awards
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace('_', ' ')}
</Badge>
<span className="text-muted-foreground">
{award.program.year} Edition
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/awards/${awardId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{award.status === 'DRAFT' && (
<Button
variant="outline"
onClick={() => handleStatusChange('NOMINATIONS_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Nominations
</Button>
)}
{award.status === 'NOMINATIONS_OPEN' && (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
)}
{award.status === 'VOTING_OPEN' && (
<Button
variant="outline"
onClick={() => handleStatusChange('CLOSED')}
disabled={updateStatus.isPending}
>
<Lock className="mr-2 h-4 w-4" />
Close Voting
</Button>
)}
</div>
</div>
{/* Description */}
{award.description && (
<p className="text-muted-foreground">{award.description}</p>
)}
{/* Tabs */}
<Tabs defaultValue="eligibility">
<TabsList>
<TabsTrigger value="eligibility">
<CheckCircle2 className="mr-2 h-4 w-4" />
Eligibility ({award.eligibleCount})
</TabsTrigger>
<TabsTrigger value="jurors">
<Users className="mr-2 h-4 w-4" />
Jurors ({award._count.jurors})
</TabsTrigger>
<TabsTrigger value="results">
<BarChart3 className="mr-2 h-4 w-4" />
Results
</TabsTrigger>
</TabsList>
{/* Eligibility Tab */}
<TabsContent value="eligibility" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<p className="text-sm text-muted-foreground">
{award.eligibleCount} of {award._count.eligibilities} projects
eligible
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="include-submitted"
checked={includeSubmitted}
onCheckedChange={setIncludeSubmitted}
/>
<Label htmlFor="include-submitted" className="text-sm whitespace-nowrap">
Include submitted
</Label>
</div>
{award.useAiEligibility ? (
<Button
onClick={handleRunEligibility}
disabled={runEligibility.isPending}
>
{runEligibility.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
)}
Run AI Eligibility
</Button>
) : (
<Button
onClick={handleRunEligibility}
disabled={runEligibility.isPending}
variant="outline"
>
{runEligibility.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Load All Projects
</Button>
)}
</div>
</div>
{!award.useAiEligibility && (
<p className="text-sm text-muted-foreground italic">
AI eligibility is off for this award. Projects are loaded for manual selection.
</p>
)}
{eligibilityData && eligibilityData.eligibilities.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead>Eligible</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eligibilityData.eligibilities.map((e) => (
<TableRow key={e.id}>
<TableCell>
<div>
<p className="font-medium">{e.project.title}</p>
<p className="text-sm text-muted-foreground">
{e.project.teamName}
</p>
</div>
</TableCell>
<TableCell>
{e.project.competitionCategory ? (
<Badge variant="outline">
{e.project.competitionCategory.replace('_', ' ')}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>{e.project.country || '-'}</TableCell>
<TableCell>
<Switch
checked={e.eligible}
onCheckedChange={(checked) =>
handleToggleEligibility(e.projectId, checked)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Brain className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No eligibility data</p>
<p className="text-sm text-muted-foreground">
Run AI eligibility to evaluate projects against criteria
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Jurors Tab */}
<TabsContent value="jurors" className="space-y-4">
<div className="flex gap-2">
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Select a member..." />
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleAddJuror}
disabled={!selectedJurorId || addJuror.isPending}
>
<UserPlus className="mr-2 h-4 w-4" />
Add Juror
</Button>
</div>
{jurors && jurors.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jurors.map((j) => (
<TableRow key={j.id}>
<TableCell>
<div className="flex items-center gap-3">
<UserAvatar user={j.user} size="sm" />
<div>
<p className="font-medium">
{j.user.name || 'Unnamed'}
</p>
<p className="text-sm text-muted-foreground">
{j.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{j.user.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveJuror(j.userId)}
disabled={removeJuror.isPending}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No jurors assigned</p>
<p className="text-sm text-muted-foreground">
Add members as jurors for this award
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Results Tab */}
<TabsContent value="results" className="space-y-4">
{voteResults && voteResults.results.length > 0 ? (
<>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{voteResults.votedJurorCount} of {voteResults.jurorCount}{' '}
jurors voted
</span>
<Badge variant="outline">
{voteResults.scoringMode.replace('_', ' ')}
</Badge>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Project</TableHead>
<TableHead>Votes</TableHead>
<TableHead>Points</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{voteResults.results.map((r, i) => (
<TableRow
key={r.project.id}
className={
r.project.id === voteResults.winnerId
? 'bg-amber-50 dark:bg-amber-950/20'
: ''
}
>
<TableCell className="font-bold">{i + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{r.project.id === voteResults.winnerId && (
<Crown className="h-4 w-4 text-amber-500" />
)}
<div>
<p className="font-medium">{r.project.title}</p>
<p className="text-sm text-muted-foreground">
{r.project.teamName}
</p>
</div>
</div>
</TableCell>
<TableCell>{r.votes}</TableCell>
<TableCell className="font-semibold">
{r.points}
</TableCell>
<TableCell className="text-right">
{r.project.id !== voteResults.winnerId && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetWinner(r.project.id)}
disabled={setWinner.isPending}
>
<Crown className="mr-1 h-3 w-3" />
Set Winner
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No votes yet</p>
<p className="text-sm text-muted-foreground">
Votes will appear here once jurors submit their selections
</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
)
}