Redesign AI Tagging dialog and add edition-wide tagging

- Redesign AI Tagging dialog with scope selection (Round vs Edition)
- Add visual progress indicator during AI processing
- Display result stats (tagged/skipped/failed) after completion
- Add batchTagProgramProjects endpoint for edition-wide tagging
- Fix getFilterOptions to include program.id for filtering
- Improve error handling with toast notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 10:27:52 +01:00
parent 7f95f681d6
commit 05862f1e55
4 changed files with 469 additions and 104 deletions

View File

@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
@@ -41,6 +42,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Plus,
MoreHorizontal,
@@ -53,6 +61,12 @@ import {
Trash2,
Loader2,
Sparkles,
Tags,
Clock,
CheckCircle2,
AlertCircle,
Layers,
FolderOpen,
} from 'lucide-react'
import {
Select,
@@ -240,37 +254,98 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [taggingInProgress, setTaggingInProgress] = useState(false)
const [taggingResult, setTaggingResult] = useState<{
processed: number
skipped: number
failed: number
errors: string[]
} | null>(null)
// Fetch rounds for the AI tagging dialog
// Fetch programs and rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
const { data: allRounds } = trpc.round.list.useQuery(
{ programId: programs?.[0]?.id ?? '' },
{ enabled: !!programs?.[0]?.id }
)
// AI batch tagging mutation
// AI batch tagging mutations
const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
onSuccess: (result) => {
setTaggingInProgress(false)
setTaggingResult(result)
if (result.errors && result.errors.length > 0) {
// Show first error if there are any
toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) {
toast.info('No projects to tag - all projects in this round already have tags')
toast.info('No projects to tag - all projects already have tags')
} else {
toast.success(
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} skipped, ${result.failed} failed`
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} already tagged, ${result.failed} failed`
)
}
setAiTagDialogOpen(false)
setSelectedRoundForTagging('')
utils.project.list.invalidate()
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
},
})
const batchTagProgramProjects = trpc.tag.batchTagProgramProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
onSuccess: (result) => {
setTaggingInProgress(false)
setTaggingResult(result)
if (result.errors && result.errors.length > 0) {
toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) {
toast.info('No projects to tag - all projects already have tags')
} else {
toast.success(
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} already tagged, ${result.failed} failed`
)
}
utils.project.list.invalidate()
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
},
})
const handleStartTagging = () => {
if (taggingScope === 'round' && selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
} else if (taggingScope === 'program' && selectedProgramForTagging) {
batchTagProgramProjects.mutate({ programId: selectedProgramForTagging })
}
}
const handleCloseTaggingDialog = () => {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setTaggingResult(null)
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's rounds
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? []
// Calculate stats for display
const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging)
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@@ -593,61 +668,229 @@ export default function ProjectsPage() {
</AlertDialog>
{/* AI Tagging Dialog */}
<AlertDialog open={aiTagDialogOpen} onOpenChange={setAiTagDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Generate AI Tags
</AlertDialogTitle>
<AlertDialogDescription>
Use AI to automatically generate expertise tags for all projects in a round
that don&apos;t have tags yet. This helps with jury matching and filtering.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{filterOptions?.rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.program?.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-sm text-muted-foreground">
Only projects without existing tags will be processed. Existing tags are preserved.
</p>
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<span>AI Tag Generator</span>
<p className="text-sm font-normal text-muted-foreground">
Automatically categorize projects with expertise tags
</p>
</div>
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Progress Indicator (when running) */}
{taggingInProgress && (
<div className="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 Tagging in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Analyzing projects and assigning expertise tags...
</p>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
Processing projects...
</span>
</div>
<Progress value={undefined} className="h-2 animate-pulse" />
</div>
</div>
</div>
)}
{/* Result Display */}
{taggingResult && !taggingInProgress && (
<div className={`p-4 rounded-lg border ${
taggingResult.failed > 0
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
: taggingResult.processed > 0
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
: 'bg-muted border-border'
}`}>
<div className="flex items-center gap-3 mb-3">
{taggingResult.processed > 0 ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : taggingResult.errors.length > 0 ? (
<AlertCircle className="h-5 w-5 text-amber-600" />
) : (
<Tags className="h-5 w-5 text-muted-foreground" />
)}
<span className="font-medium">
{taggingResult.processed > 0
? 'Tagging Complete'
: taggingResult.errors.length > 0
? 'Tagging Issue'
: 'No Projects to Tag'}
</span>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-green-600">{taggingResult.processed}</p>
<p className="text-xs text-muted-foreground">Tagged</p>
</div>
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-muted-foreground">{taggingResult.skipped}</p>
<p className="text-xs text-muted-foreground">Already Tagged</p>
</div>
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-red-600">{taggingResult.failed}</p>
<p className="text-xs text-muted-foreground">Failed</p>
</div>
</div>
{taggingResult.errors.length > 0 && (
<p className="mt-3 text-sm text-amber-700 dark:text-amber-300">
{taggingResult.errors[0]}
</p>
)}
</div>
)}
{/* Scope Selection */}
{!taggingInProgress && !taggingResult && (
<>
<div className="space-y-3">
<Label className="text-sm font-medium">Tagging Scope</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('round')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'round'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific round
</span>
</button>
<button
type="button"
onClick={() => setTaggingScope('program')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'program'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<Layers className={`h-6 w-6 ${taggingScope === 'program' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'program' ? 'text-primary' : ''}`}>
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all rounds
</span>
</button>
</div>
</div>
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'round' ? (
<>
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{filterOptions?.rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.program?.name})
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : (
<>
<Label htmlFor="program-select">Select Edition</Label>
<Select
value={selectedProgramForTagging}
onValueChange={setSelectedProgramForTagging}
>
<SelectTrigger id="program-select">
<SelectValue placeholder="Choose an edition..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} ({program.year})
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{/* Info Note */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50">
<Tags className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Only projects without existing tags will be processed. AI will analyze project descriptions and assign relevant expertise tags for jury matching.
</p>
</div>
</>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
}
}}
disabled={!selectedRoundForTagging || batchTagProjects.isPending}
>
{batchTagProjects.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Footer */}
<div className="flex justify-end gap-3 pt-2 border-t">
{taggingResult ? (
<Button onClick={handleCloseTaggingDialog}>
Done
</Button>
) : (
<>
<Button
variant="outline"
onClick={handleCloseTaggingDialog}
disabled={taggingInProgress}
>
Cancel
</Button>
<Button
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{taggingInProgress ? 'Processing...' : 'Generate Tags'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}