Files
MOPC-Portal/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
Matt 87d5aea315 Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:20:48 +01:00

218 lines
8.2 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
export default function AssignmentsDashboardPage() {
const params = useParams()
const router = useRouter()
const competitionId = params.competitionId as string
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
onSuccess: () => {
toast.success('AI assignments ready!', {
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
duration: 10000,
})
},
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
})
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
id: competitionId,
})
const { data: selectedRound } = trpc.round.getById.useQuery(
{ id: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
const { data: unassignedQueue, isLoading: isLoadingQueue } =
trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId: selectedRoundId, requiredReviews },
{ enabled: !!selectedRoundId }
)
const rounds = competition?.rounds || []
const currentRound = rounds.find((r) => r.id === selectedRoundId)
if (isLoadingCompetition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!competition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Competition
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>Choose a round to view and manage assignments</CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{rounds.length === 0 ? (
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
) : (
rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType})
</SelectItem>
))
)}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedRoundId && (
<div className="space-y-6">
<div className="flex justify-end gap-2">
<Button
onClick={() => {
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
}}
disabled={aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
) : (
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
)}
</Button>
{aiAssignmentMutation.data && (
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review Assignments
</Button>
)}
</div>
<Tabs defaultValue="coverage" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
</TabsList>
<TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
</TabsContent>
<TabsContent value="unassigned" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Unassigned Projects</CardTitle>
<CardDescription>
Projects with fewer than {requiredReviews} assignments
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingQueue ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : unassignedQueue && unassignedQueue.length > 0 ? (
<div className="space-y-2">
{unassignedQueue.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md"
>
<div>
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.competitionCategory || 'No category'}
</p>
</div>
<div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / {requiredReviews} assignments
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<AssignmentPreviewSheet
roundId={selectedRoundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
requiredReviews={requiredReviews}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
onResetAI={() => aiAssignmentMutation.reset()}
/>
</div>
)}
</div>
)
}