Add special awards management features and fix voting/assignment issues
Build and Push Docker Image / build (push) Successful in 10m4s Details

Special Awards:
- Add delete button with confirmation dialog to award detail page
- Add voting window dates (start/end) to award edit page
- Add manual project eligibility management (add/remove projects)
- Show eligibility method (Auto/Manual) in eligibility table
- Auto-set votingStartAt when opening voting if date is in future

Assignment Suggestions:
- Replace toggle with proper tabs UI (Algorithm vs AI Powered)
- Persist AI suggestions when navigating away (stored in database)
- Show suggestion counts on tab badges
- Independent refresh/start buttons per tab

Round Voting:
- Auto-update votingStartAt to now when activating round if date is in future
- Fixes issue where round was opened but voting dates were in future

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-05 16:29:36 +01:00
parent e01d741f01
commit 13de30775e
5 changed files with 656 additions and 224 deletions

View File

@ -44,6 +44,16 @@ export default function EditAwardPage({
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER') const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
const [useAiEligibility, setUseAiEligibility] = useState(true) const [useAiEligibility, setUseAiEligibility] = useState(true)
const [maxRankedPicks, setMaxRankedPicks] = useState('3') const [maxRankedPicks, setMaxRankedPicks] = useState('3')
const [votingStartAt, setVotingStartAt] = useState('')
const [votingEndAt, setVotingEndAt] = useState('')
// Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => {
if (!date) return ''
const d = new Date(date)
// Format: YYYY-MM-DDTHH:mm
return d.toISOString().slice(0, 16)
}
// Load existing values when award data arrives // Load existing values when award data arrives
useEffect(() => { useEffect(() => {
@ -54,6 +64,8 @@ export default function EditAwardPage({
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED') setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
setUseAiEligibility(award.useAiEligibility) setUseAiEligibility(award.useAiEligibility)
setMaxRankedPicks(String(award.maxRankedPicks || 3)) setMaxRankedPicks(String(award.maxRankedPicks || 3))
setVotingStartAt(formatDateForInput(award.votingStartAt))
setVotingEndAt(formatDateForInput(award.votingEndAt))
} }
}, [award]) }, [award])
@ -68,6 +80,8 @@ export default function EditAwardPage({
useAiEligibility, useAiEligibility,
scoringMode, scoringMode,
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined, maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
}) })
toast.success('Award updated') toast.success('Award updated')
router.push(`/admin/awards/${awardId}`) router.push(`/admin/awards/${awardId}`)
@ -211,6 +225,45 @@ export default function EditAwardPage({
</CardContent> </CardContent>
</Card> </Card>
{/* Voting Window Card */}
<Card>
<CardHeader>
<CardTitle>Voting Window</CardTitle>
<CardDescription>
Set the time period during which jurors can submit their votes
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="votingStart">Voting Opens</Label>
<Input
id="votingStart"
type="datetime-local"
value={votingStartAt}
onChange={(e) => setVotingStartAt(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
When jurors can start voting (leave empty to set when opening voting)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="votingEnd">Voting Closes</Label>
<Input
id="votingEnd"
type="datetime-local"
value={votingEndAt}
onChange={(e) => setVotingEndAt(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Deadline for juror votes
</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={`/admin/awards/${awardId}`}>Cancel</Link> <Link href={`/admin/awards/${awardId}`}>Cancel</Link>

View File

@ -2,6 +2,7 @@
import { use, useState } from 'react' import { use, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -30,7 +31,28 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { Pagination } from '@/components/shared/pagination' import { Pagination } from '@/components/shared/pagination'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -48,6 +70,9 @@ import {
Play, Play,
Lock, Lock,
Pencil, Pencil,
Trash2,
Plus,
Search,
} from 'lucide-react' } from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@ -64,6 +89,7 @@ export default function AwardDetailPage({
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id: awardId } = use(params) const { id: awardId } = use(params)
const router = useRouter()
const { data: award, isLoading, refetch } = const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId }) trpc.specialAward.get.useQuery({ id: awardId })
@ -71,7 +97,7 @@ export default function AwardDetailPage({
trpc.specialAward.listEligible.useQuery({ trpc.specialAward.listEligible.useQuery({
awardId, awardId,
page: 1, page: 1,
perPage: 50, perPage: 500,
}) })
const { data: jurors, refetch: refetchJurors } = const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId }) trpc.specialAward.listJurors.useQuery({ awardId })
@ -79,15 +105,24 @@ export default function AwardDetailPage({
trpc.specialAward.getVoteResults.useQuery({ awardId }) trpc.specialAward.getVoteResults.useQuery({ awardId })
const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 }) const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 })
// Fetch all projects in the program for manual eligibility addition
const { data: allProjects } = trpc.project.list.useQuery(
{ programId: award?.programId ?? '', perPage: 500 },
{ enabled: !!award?.programId }
)
const updateStatus = trpc.specialAward.updateStatus.useMutation() const updateStatus = trpc.specialAward.updateStatus.useMutation()
const runEligibility = trpc.specialAward.runEligibility.useMutation() const runEligibility = trpc.specialAward.runEligibility.useMutation()
const setEligibility = trpc.specialAward.setEligibility.useMutation() const setEligibility = trpc.specialAward.setEligibility.useMutation()
const addJuror = trpc.specialAward.addJuror.useMutation() const addJuror = trpc.specialAward.addJuror.useMutation()
const removeJuror = trpc.specialAward.removeJuror.useMutation() const removeJuror = trpc.specialAward.removeJuror.useMutation()
const setWinner = trpc.specialAward.setWinner.useMutation() const setWinner = trpc.specialAward.setWinner.useMutation()
const deleteAward = trpc.specialAward.delete.useMutation()
const [selectedJurorId, setSelectedJurorId] = useState('') const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true) const [includeSubmitted, setIncludeSubmitted] = useState(true)
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const handleStatusChange = async ( const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@ -165,6 +200,53 @@ export default function AwardDetailPage({
} }
} }
const handleDeleteAward = async () => {
try {
await deleteAward.mutateAsync({ id: awardId })
toast.success('Award deleted')
router.push('/admin/awards')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete award'
)
}
}
const handleAddProjectToEligibility = async (projectId: string) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible: true })
toast.success('Project added to eligibility list')
refetchEligibility()
refetch()
} catch {
toast.error('Failed to add project')
}
}
const handleRemoveFromEligibility = async (projectId: string) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible: false })
toast.success('Project removed from eligibility')
refetchEligibility()
refetch()
} catch {
toast.error('Failed to remove project')
}
}
// Get projects that aren't already in the eligibility list
const eligibleProjectIds = new Set(
eligibilityData?.eligibilities.map((e) => e.projectId) || []
)
const availableProjects = allProjects?.projects.filter(
(p) => !eligibleProjectIds.has(p.id)
) || []
const filteredAvailableProjects = availableProjects.filter(
(p) =>
p.title.toLowerCase().includes(projectSearchQuery.toLowerCase()) ||
p.teamName?.toLowerCase().includes(projectSearchQuery.toLowerCase())
)
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -205,6 +287,11 @@ export default function AwardDetailPage({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{award.program.year} Edition {award.program.year} Edition
</span> </span>
{award.votingStartAt && (
<span className="text-xs text-muted-foreground">
Voting: {new Date(award.votingStartAt).toLocaleDateString()} - {award.votingEndAt ? new Date(award.votingEndAt).toLocaleDateString() : 'No end date'}
</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -243,6 +330,37 @@ export default function AwardDetailPage({
Close Voting Close Voting
</Button> </Button>
)} )}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Award?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{award.name}&quot; and all associated
eligibility data, juror assignments, and votes. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAward}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAward.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete Award
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
@ -312,6 +430,101 @@ export default function AwardDetailPage({
Load All Projects Load All Projects
</Button> </Button>
)} )}
<Dialog open={addProjectDialogOpen} onOpenChange={setAddProjectDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Add Project to Eligibility List</DialogTitle>
<DialogDescription>
Manually add a project that wasn&apos;t included by AI or rule-based filtering
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[400px] overflow-y-auto rounded-md border">
{filteredAvailableProjects.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAvailableProjects.slice(0, 50).map((project) => (
<TableRow key={project.id}>
<TableCell>
<div>
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</TableCell>
<TableCell>
{project.competitionCategory ? (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell className="text-sm">{project.country || '-'}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => {
handleAddProjectToEligibility(project.id)
}}
disabled={setEligibility.isPending}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
{projectSearchQuery
? 'No projects match your search'
: 'All projects are already in the eligibility list'}
</p>
</div>
)}
</div>
{filteredAvailableProjects.length > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing first 50 of {filteredAvailableProjects.length} projects. Use search to filter.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddProjectDialogOpen(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
{!award.useAiEligibility && ( {!award.useAiEligibility && (
@ -328,12 +541,14 @@ export default function AwardDetailPage({
<TableHead>Project</TableHead> <TableHead>Project</TableHead>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead>Country</TableHead> <TableHead>Country</TableHead>
<TableHead>Method</TableHead>
<TableHead>Eligible</TableHead> <TableHead>Eligible</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{eligibilityData.eligibilities.map((e) => ( {eligibilityData.eligibilities.map((e) => (
<TableRow key={e.id}> <TableRow key={e.id} className={!e.eligible ? 'opacity-50' : ''}>
<TableCell> <TableCell>
<div> <div>
<p className="font-medium">{e.project.title}</p> <p className="font-medium">{e.project.title}</p>
@ -352,6 +567,11 @@ export default function AwardDetailPage({
)} )}
</TableCell> </TableCell>
<TableCell>{e.project.country || '-'}</TableCell> <TableCell>{e.project.country || '-'}</TableCell>
<TableCell>
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
</Badge>
</TableCell>
<TableCell> <TableCell>
<Switch <Switch
checked={e.eligible} checked={e.eligible}
@ -360,6 +580,16 @@ export default function AwardDetailPage({
} }
/> />
</TableCell> </TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFromEligibility(e.projectId)}
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -371,7 +601,7 @@ export default function AwardDetailPage({
<Brain className="h-12 w-12 text-muted-foreground/50" /> <Brain className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No eligibility data</p> <p className="mt-2 font-medium">No eligibility data</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Run AI eligibility to evaluate projects against criteria Run AI eligibility to evaluate projects or manually add projects
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -16,6 +16,12 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { import {
Table, Table,
TableBody, TableBody,
@ -64,9 +70,125 @@ import {
Trash2, Trash2,
RefreshCw, RefreshCw,
UserPlus, UserPlus,
Cpu,
Brain,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
// Suggestion type for both algorithm and AI suggestions
interface Suggestion {
userId: string
jurorName: string
projectId: string
projectTitle: string
score: number
reasoning: string[]
}
// Reusable table component for displaying suggestions
function SuggestionsTable({
suggestions,
selectedSuggestions,
onToggle,
onSelectAll,
onApply,
isApplying,
}: {
suggestions: Suggestion[]
selectedSuggestions: Set<string>
onToggle: (key: string) => void
onSelectAll: () => void
onApply: () => void
isApplying: boolean
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedSuggestions.size === suggestions.length && suggestions.length > 0}
onCheckedChange={onSelectAll}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={onApply}
disabled={selectedSuggestions.size === 0 || isApplying}
>
{isApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Apply Selected ({selectedSuggestions.size})
</Button>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Juror</TableHead>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Reasoning</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const key = `${suggestion.userId}-${suggestion.projectId}`
const isSelected = selectedSuggestions.has(key)
return (
<TableRow
key={key}
className={isSelected ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggle(key)}
/>
</TableCell>
<TableCell className="font-medium">
{suggestion.jurorName}
</TableCell>
<TableCell>
{suggestion.projectTitle}
</TableCell>
<TableCell>
<Badge
variant={
suggestion.score >= 60
? 'default'
: suggestion.score >= 40
? 'secondary'
: 'outline'
}
>
{suggestion.score.toFixed(0)}
</Badge>
</TableCell>
<TableCell className="max-w-xs">
<ul className="text-xs text-muted-foreground">
{suggestion.reasoning.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)
}
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
} }
@ -76,7 +198,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [manualDialogOpen, setManualDialogOpen] = useState(false) const [manualDialogOpen, setManualDialogOpen] = useState(false)
const [selectedJuror, setSelectedJuror] = useState<string>('') const [selectedJuror, setSelectedJuror] = useState<string>('')
const [selectedProject, setSelectedProject] = useState<string>('') const [selectedProject, setSelectedProject] = useState<string>('')
const [useAI, setUseAI] = useState(false) const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
const [activeJobId, setActiveJobId] = useState<string | null>(null) const [activeJobId, setActiveJobId] = useState<string | null>(null)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId }) const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
@ -84,10 +206,9 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId }) const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery() const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery()
// AI Assignment job queries // Always fetch latest AI job to check for existing results
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery( const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
{ roundId }, { roundId }
{ enabled: useAI }
) )
// Poll for job status when there's an active job // Poll for job status when there's an active job
@ -107,21 +228,23 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100) ? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
: 0 : 0
// Algorithmic suggestions (default) // Check if there's a completed AI job with stored suggestions
const hasStoredAISuggestions = latestJob?.status === 'COMPLETED' && latestJob?.suggestionsCount > 0
// Algorithmic suggestions (always fetch for algorithm tab)
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery( const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
{ roundId }, { roundId },
{ enabled: !!round && !useAI } { enabled: !!round }
) )
// AI-powered suggestions (expensive - only used after job completes) // AI-powered suggestions - fetch if there are stored results OR if AI tab is active
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery( const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
{ roundId, useAI: true }, { roundId, useAI: true },
{ {
enabled: !!round && useAI && !isAIJobRunning, enabled: !!round && (hasStoredAISuggestions || activeTab === 'ai') && !isAIJobRunning,
staleTime: Infinity, // Never consider stale (only refetch manually) staleTime: Infinity, // Never consider stale (only refetch manually)
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnMount: false,
} }
) )
@ -129,6 +252,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
useEffect(() => { useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) { if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
setActiveJobId(latestJob.id) setActiveJobId(latestJob.id)
setActiveTab('ai') // Switch to AI tab if a job is running
} }
}, [latestJob]) }, [latestJob])
@ -150,6 +274,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const handleStartAIJob = async () => { const handleStartAIJob = async () => {
try { try {
setActiveTab('ai') // Switch to AI tab when starting
const result = await startAIJob.mutateAsync({ roundId }) const result = await startAIJob.mutateAsync({ roundId })
setActiveJobId(result.jobId) setActiveJobId(result.jobId)
toast.info('AI Assignment job started. Progress will update automatically.') toast.info('AI Assignment job started. Progress will update automatically.')
@ -170,10 +295,9 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
reasoning: [s.reasoning], reasoning: [s.reasoning],
})) ?? [] })) ?? []
// Use the appropriate suggestions based on mode // Use the appropriate suggestions based on active tab
const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? []) const currentSuggestions = activeTab === 'ai' ? aiSuggestions : (algorithmicSuggestions ?? [])
const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic const isLoadingCurrentSuggestions = activeTab === 'ai' ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic
// Get available jurors for manual assignment // Get available jurors for manual assignment
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery( const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
@ -264,21 +388,21 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
} }
const handleSelectAllSuggestions = () => { const handleSelectAllSuggestions = () => {
if (suggestions) { if (currentSuggestions) {
if (selectedSuggestions.size === suggestions.length) { if (selectedSuggestions.size === currentSuggestions.length) {
setSelectedSuggestions(new Set()) setSelectedSuggestions(new Set())
} else { } else {
setSelectedSuggestions( setSelectedSuggestions(
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`)) new Set(currentSuggestions.map((s) => `${s.userId}-${s.projectId}`))
) )
} }
} }
} }
const handleApplySelected = async () => { const handleApplySelected = async () => {
if (!suggestions) return if (!currentSuggestions) return
const selected = suggestions.filter((s) => const selected = currentSuggestions.filter((s) =>
selectedSuggestions.has(`${s.userId}-${s.projectId}`) selectedSuggestions.has(`${s.userId}-${s.projectId}`)
) )
@ -522,212 +646,192 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
</Card> </Card>
)} )}
{/* Smart Suggestions */} {/* Smart Suggestions with Tabs */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <CardTitle className="text-lg flex items-center gap-2">
<div> <Sparkles className="h-5 w-5 text-amber-500" />
<CardTitle className="text-lg flex items-center gap-2"> Smart Assignment Suggestions
<Sparkles className="h-5 w-5 text-amber-500" /> </CardTitle>
{useAI ? 'AI Assignment Suggestions' : 'Smart Assignment Suggestions'} <CardDescription>
</CardTitle> Get assignment recommendations using algorithmic matching or AI-powered analysis
<CardDescription> </CardDescription>
{useAI
? 'GPT-powered recommendations analyzing project descriptions and judge expertise'
: 'Algorithmic recommendations based on tag matching and workload balance'}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant={useAI ? 'default' : 'outline'}
size="sm"
onClick={() => {
if (!useAI) {
setUseAI(true)
setSelectedSuggestions(new Set())
// Start AI job if no suggestions yet
if (!aiSuggestionsRaw?.suggestions?.length && !isAIJobRunning) {
handleStartAIJob()
}
} else {
setUseAI(false)
setSelectedSuggestions(new Set())
}
}}
disabled={(!isAIAvailable && !useAI) || isAIJobRunning}
title={!isAIAvailable ? 'OpenAI API key not configured' : undefined}
>
<Sparkles className={`mr-2 h-4 w-4 ${useAI ? 'text-amber-300' : ''}`} />
{useAI ? 'AI Mode' : 'Use AI'}
</Button>
{useAI && !isAIJobRunning && (
<Button
variant="outline"
size="sm"
onClick={handleStartAIJob}
disabled={startAIJob.isPending}
title="Run AI analysis again"
>
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Re-analyze
</Button>
)}
{!useAI && (
<Button
variant="outline"
size="sm"
onClick={() => refetchSuggestions()}
disabled={loadingSuggestions}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
)}
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* AI Job Progress Indicator */} <Tabs value={activeTab} onValueChange={(v) => {
{isAIJobRunning && jobStatus && ( setActiveTab(v as 'algorithm' | 'ai')
<div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900"> setSelectedSuggestions(new Set())
<div className="space-y-3"> }}>
<div className="flex items-center gap-3"> <div className="flex items-center justify-between mb-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" /> <TabsList>
<div className="flex-1"> <TabsTrigger value="algorithm" className="gap-2">
<p className="font-medium text-blue-900 dark:text-blue-100"> <Cpu className="h-4 w-4" />
AI Assignment Analysis in Progress Algorithm
</p> {algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
<p className="text-sm text-blue-700 dark:text-blue-300"> <Badge variant="secondary" className="ml-1 text-xs">
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches {algorithmicSuggestions.length}
</p> </Badge>
</div>
<Badge variant="outline" className="border-blue-300 text-blue-700">
<Clock className="mr-1 h-3 w-3" />
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
</span>
<span className="font-medium text-blue-900 dark:text-blue-100">
{aiJobProgressPercent}%
</span>
</div>
<Progress value={aiJobProgressPercent} className="h-2" />
</div>
</div>
</div>
)}
{isAIJobRunning ? (
// Don't show suggestions section while AI job is running - progress is shown above
null
) : loadingSuggestions ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : suggestions && suggestions.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedSuggestions.size === suggestions.length}
onCheckedChange={handleSelectAllSuggestions}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={handleApplySelected}
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
>
{applySuggestions.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)} )}
Apply Selected ({selectedSuggestions.size}) </TabsTrigger>
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
<Brain className="h-4 w-4" />
AI Powered
{aiSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{aiSuggestions.length}
</Badge>
)}
{isAIJobRunning && (
<Loader2 className="h-3 w-3 animate-spin ml-1" />
)}
</TabsTrigger>
</TabsList>
{/* Tab-specific actions */}
{activeTab === 'algorithm' ? (
<Button
variant="outline"
size="sm"
onClick={() => refetchAlgorithmic()}
disabled={loadingAlgorithmic}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loadingAlgorithmic ? 'animate-spin' : ''}`} />
Refresh
</Button> </Button>
) : (
<div className="flex items-center gap-2">
{!isAIJobRunning && (
<Button
variant="outline"
size="sm"
onClick={handleStartAIJob}
disabled={startAIJob.isPending || !isAIAvailable}
title={!isAIAvailable ? 'OpenAI API key not configured' : hasStoredAISuggestions ? 'Run AI analysis again' : 'Start AI analysis'}
>
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{hasStoredAISuggestions ? 'Re-analyze' : 'Start Analysis'}
</Button>
)}
</div>
)}
</div>
{/* Algorithm Tab Content */}
<TabsContent value="algorithm" className="mt-0">
<div className="text-sm text-muted-foreground mb-4">
Algorithmic recommendations based on tag matching and workload balance
</div>
{loadingAlgorithmic ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : algorithmicSuggestions && algorithmicSuggestions.length > 0 ? (
<SuggestionsTable
suggestions={algorithmicSuggestions}
selectedSuggestions={selectedSuggestions}
onToggle={handleToggleSuggestion}
onSelectAll={handleSelectAllSuggestions}
onApply={handleApplySelected}
isApplying={applySuggestions.isPending}
/>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
<p className="mt-2 font-medium">All projects are covered!</p>
<p className="text-sm text-muted-foreground">
No additional assignments are needed at this time
</p>
</div>
)}
</TabsContent>
{/* AI Tab Content */}
<TabsContent value="ai" className="mt-0">
<div className="text-sm text-muted-foreground mb-4">
GPT-powered recommendations analyzing project descriptions and judge expertise
</div> </div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto"> {/* AI Job Progress Indicator */}
<Table> {isAIJobRunning && jobStatus && (
<TableHeader> <div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<TableRow> <div className="space-y-3">
<TableHead className="w-12"></TableHead> <div className="flex items-center gap-3">
<TableHead>Juror</TableHead> <Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<TableHead>Project</TableHead> <div className="flex-1">
<TableHead>Score</TableHead> <p className="font-medium text-blue-900 dark:text-blue-100">
<TableHead>Reasoning</TableHead> AI Assignment Analysis in Progress
</TableRow> </p>
</TableHeader> <p className="text-sm text-blue-700 dark:text-blue-300">
<TableBody> Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
{suggestions.map((suggestion) => { </p>
const key = `${suggestion.userId}-${suggestion.projectId}` </div>
const isSelected = selectedSuggestions.has(key) <Badge variant="outline" className="border-blue-300 text-blue-700">
<Clock className="mr-1 h-3 w-3" />
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
</span>
<span className="font-medium text-blue-900 dark:text-blue-100">
{aiJobProgressPercent}%
</span>
</div>
<Progress value={aiJobProgressPercent} className="h-2" />
</div>
</div>
</div>
)}
return ( {isAIJobRunning ? null : loadingAI ? (
<TableRow <div className="flex items-center justify-center py-8">
key={key} <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
className={isSelected ? 'bg-muted/50' : ''} </div>
> ) : aiSuggestions.length > 0 ? (
<TableCell> <SuggestionsTable
<Checkbox suggestions={aiSuggestions}
checked={isSelected} selectedSuggestions={selectedSuggestions}
onCheckedChange={() => handleToggleSuggestion(key)} onToggle={handleToggleSuggestion}
/> onSelectAll={handleSelectAllSuggestions}
</TableCell> onApply={handleApplySelected}
<TableCell className="font-medium"> isApplying={applySuggestions.isPending}
{suggestion.jurorName} />
</TableCell> ) : !hasStoredAISuggestions ? (
<TableCell> <div className="flex flex-col items-center justify-center py-8 text-center">
{suggestion.projectTitle} <Brain className="h-12 w-12 text-muted-foreground/50" />
</TableCell> <p className="mt-2 font-medium">No AI analysis yet</p>
<TableCell> <p className="text-sm text-muted-foreground mb-4">
<Badge Click &quot;Start Analysis&quot; to generate AI-powered suggestions
variant={ </p>
suggestion.score >= 60 <Button
? 'default' onClick={handleStartAIJob}
: suggestion.score >= 40 disabled={startAIJob.isPending || !isAIAvailable}
? 'secondary' >
: 'outline' {startAIJob.isPending ? (
} <Loader2 className="mr-2 h-4 w-4 animate-spin" />
> ) : (
{suggestion.score.toFixed(0)} <Brain className="mr-2 h-4 w-4" />
</Badge> )}
</TableCell> Start AI Analysis
<TableCell className="max-w-xs"> </Button>
<ul className="text-xs text-muted-foreground"> </div>
{suggestion.reasoning.map((r, i) => ( ) : (
<li key={i}>{r}</li> <div className="flex flex-col items-center justify-center py-8 text-center">
))} <CheckCircle2 className="h-12 w-12 text-green-500/50" />
</ul> <p className="mt-2 font-medium">All projects are covered!</p>
</TableCell> <p className="text-sm text-muted-foreground">
</TableRow> No additional assignments are needed at this time
) </p>
})} </div>
</TableBody> )}
</Table> </TabsContent>
</div> </Tabs>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
<p className="mt-2 font-medium">All projects are covered!</p>
<p className="text-sm text-muted-foreground">
No additional assignments are needed at this time
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -251,15 +251,31 @@ export const roundRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Get previous status for audit // Get previous status and voting dates for audit
const previousRound = await ctx.prisma.round.findUniqueOrThrow({ const previousRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
select: { status: true }, select: { status: true, votingStartAt: true, votingEndAt: true },
}) })
const now = new Date()
// When activating a round, if votingStartAt is in the future, update it to now
// This ensures voting actually starts when the admin opens the round
let votingStartAtUpdated = false
const updateData: Parameters<typeof ctx.prisma.round.update>[0]['data'] = {
status: input.status,
}
if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') {
if (previousRound.votingStartAt && previousRound.votingStartAt > now) {
updateData.votingStartAt = now
votingStartAtUpdated = true
}
}
const round = await ctx.prisma.round.update({ const round = await ctx.prisma.round.update({
where: { id: input.id }, where: { id: input.id },
data: { status: input.status }, data: updateData,
}) })
// Map status to specific action name // Map status to specific action name
@ -277,7 +293,15 @@ export const roundRouter = router({
action, action,
entityType: 'Round', entityType: 'Round',
entityId: input.id, entityId: input.id,
detailsJson: { status: input.status, previousStatus: previousRound.status }, detailsJson: {
status: input.status,
previousStatus: previousRound.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: previousRound.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },

View File

@ -195,12 +195,28 @@ export const specialAwardRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const current = await ctx.prisma.specialAward.findUniqueOrThrow({ const current = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
select: { status: true }, select: { status: true, votingStartAt: true, votingEndAt: true },
}) })
const now = new Date()
// When opening voting, auto-set votingStartAt to now if it's in the future or not set
let votingStartAtUpdated = false
const updateData: Parameters<typeof ctx.prisma.specialAward.update>[0]['data'] = {
status: input.status,
}
if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') {
// If no voting start date, or if it's in the future, set it to now
if (!current.votingStartAt || current.votingStartAt > now) {
updateData.votingStartAt = now
votingStartAtUpdated = true
}
}
const award = await ctx.prisma.specialAward.update({ const award = await ctx.prisma.specialAward.update({
where: { id: input.id }, where: { id: input.id },
data: { status: input.status }, data: updateData,
}) })
await logAudit({ await logAudit({
@ -211,6 +227,11 @@ export const specialAwardRouter = router({
detailsJson: { detailsJson: {
previousStatus: current.status, previousStatus: current.status,
newStatus: input.status, newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
}, },
}) })