Add visual progress indicator for AI assignment batches

- Add AssignmentJob model to track AI assignment progress
- Create startAIAssignmentJob mutation for background processing
- Add getAIAssignmentJobStatus query for polling progress
- Update AI assignment service with progress callback support
- Add progress bar UI showing batch/project processing status
- Add toast notifications for job completion/failure
- Add AI_SUGGESTIONS_READY notification type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 17:40:26 +01:00
parent 148925cb95
commit 6f6d5ef501
5 changed files with 439 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { Suspense, use, useState } from 'react'
import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
@@ -77,23 +77,47 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedJuror, setSelectedJuror] = useState<string>('')
const [selectedProject, setSelectedProject] = useState<string>('')
const [useAI, setUseAI] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery()
// AI Assignment job queries
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
{ roundId },
{ enabled: useAI }
)
// Poll for job status when there's an active job
const { data: jobStatus } = trpc.assignment.getAIAssignmentJobStatus.useQuery(
{ jobId: activeJobId! },
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
}
)
// Start AI assignment job mutation
const startAIJob = trpc.assignment.startAIAssignmentJob.useMutation()
const isAIJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
const aiJobProgressPercent = jobStatus?.totalBatches
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
: 0
// Algorithmic suggestions (default)
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
{ roundId },
{ enabled: !!round && !useAI }
)
// AI-powered suggestions (expensive - disable auto refetch)
// AI-powered suggestions (expensive - only used after job completes)
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
{ roundId, useAI: true },
{
enabled: !!round && useAI,
enabled: !!round && useAI && !isAIJobRunning,
staleTime: Infinity, // Never consider stale (only refetch manually)
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -101,6 +125,41 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
}
)
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
setActiveJobId(latestJob.id)
}
}, [latestJob])
// Handle job completion
useEffect(() => {
if (jobStatus?.status === 'COMPLETED') {
toast.success(
`AI Assignment complete: ${jobStatus.suggestionsCount} suggestions generated${jobStatus.fallbackUsed ? ' (using fallback algorithm)' : ''}`
)
setActiveJobId(null)
refetchLatestJob()
refetchAI()
} else if (jobStatus?.status === 'FAILED') {
toast.error(`AI Assignment failed: ${jobStatus.errorMessage || 'Unknown error'}`)
setActiveJobId(null)
refetchLatestJob()
}
}, [jobStatus?.status, jobStatus?.suggestionsCount, jobStatus?.fallbackUsed, jobStatus?.errorMessage, refetchLatestJob, refetchAI])
const handleStartAIJob = async () => {
try {
const result = await startAIJob.mutateAsync({ roundId })
setActiveJobId(result.jobId)
toast.info('AI Assignment job started. Progress will update automatically.')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to start AI assignment'
)
}
}
// Normalize AI suggestions to match algorithmic format
const aiSuggestions = aiSuggestionsRaw?.suggestions?.map((s) => ({
userId: s.jurorId,
@@ -113,7 +172,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
// Use the appropriate suggestions based on mode
const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? [])
const loadingSuggestions = useAI ? loadingAI : loadingAlgorithmic
const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic
// Get available jurors for manual assignment
@@ -483,31 +542,92 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
variant={useAI ? 'default' : 'outline'}
size="sm"
onClick={() => {
setUseAI(!useAI)
setSelectedSuggestions(new Set())
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}
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>
<Button
variant="outline"
size="sm"
onClick={() => refetchSuggestions()}
disabled={loadingSuggestions}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
/>
Refresh
</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>
<CardContent>
{loadingSuggestions ? (
{/* AI Job Progress Indicator */}
{isAIJobRunning && jobStatus && (
<div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
AI Assignment Analysis in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
</p>
</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>
)}
{loadingSuggestions && !isAIJobRunning ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>