Redesign AI Tagging dialog and add edition-wide tagging
Build and Push Docker Image / build (push) Successful in 9m22s Details

- 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:
Matt 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { import {
Table, Table,
TableBody, TableBody,
@ -41,6 +42,13 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Plus, Plus,
MoreHorizontal, MoreHorizontal,
@ -53,6 +61,12 @@ import {
Trash2, Trash2,
Loader2, Loader2,
Sparkles, Sparkles,
Tags,
Clock,
CheckCircle2,
AlertCircle,
Layers,
FolderOpen,
} from 'lucide-react' } from 'lucide-react'
import { import {
Select, Select,
@ -240,37 +254,98 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null) const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false) const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('') 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: 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({ const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
onSuccess: (result) => { onSuccess: (result) => {
setTaggingInProgress(false)
setTaggingResult(result)
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
// Show first error if there are any
toast.error(`AI Tagging issue: ${result.errors[0]}`) toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 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 { } else {
toast.success( 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() utils.project.list.invalidate()
}, },
onError: (error) => { onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags') 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({ const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Project deleted successfully') toast.success('Project deleted successfully')
@ -593,61 +668,229 @@ export default function ProjectsPage() {
</AlertDialog> </AlertDialog>
{/* AI Tagging Dialog */} {/* AI Tagging Dialog */}
<AlertDialog open={aiTagDialogOpen} onOpenChange={setAiTagDialogOpen}> <Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<AlertDialogContent> <DialogContent className="sm:max-w-lg">
<AlertDialogHeader> <DialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" /> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
Generate AI Tags <Sparkles className="h-5 w-5 text-white" />
</AlertDialogTitle> </div>
<AlertDialogDescription> <div>
Use AI to automatically generate expertise tags for all projects in a round <span>AI Tag Generator</span>
that don&apos;t have tags yet. This helps with jury matching and filtering. <p className="text-sm font-normal text-muted-foreground">
</AlertDialogDescription> Automatically categorize projects with expertise tags
</AlertDialogHeader> </p>
<div className="space-y-4 py-4"> </div>
<div className="space-y-2"> </DialogTitle>
<Label htmlFor="round-select">Select Round</Label> </DialogHeader>
<Select
value={selectedRoundForTagging} <div className="space-y-6 py-4">
onValueChange={setSelectedRoundForTagging} {/* Progress Indicator (when running) */}
> {taggingInProgress && (
<SelectTrigger id="round-select"> <div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<SelectValue placeholder="Choose a round..." /> <div className="space-y-3">
</SelectTrigger> <div className="flex items-center gap-3">
<SelectContent> <Loader2 className="h-5 w-5 animate-spin text-blue-600" />
{filterOptions?.rounds?.map((round) => ( <div className="flex-1">
<SelectItem key={round.id} value={round.id}> <p className="font-medium text-blue-900 dark:text-blue-100">
{round.name} ({round.program?.name}) AI Tagging in Progress
</SelectItem> </p>
))} <p className="text-sm text-blue-700 dark:text-blue-300">
</SelectContent> Analyzing projects and assigning expertise tags...
</Select> </p>
</div> </div>
<p className="text-sm text-muted-foreground"> </div>
Only projects without existing tags will be processed. Existing tags are preserved. <div className="space-y-1">
</p> <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> </div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> {/* Footer */}
<AlertDialogAction <div className="flex justify-end gap-3 pt-2 border-t">
onClick={() => { {taggingResult ? (
if (selectedRoundForTagging) { <Button onClick={handleCloseTaggingDialog}>
batchTagProjects.mutate({ roundId: selectedRoundForTagging }) Done
} </Button>
}} ) : (
disabled={!selectedRoundForTagging || batchTagProjects.isPending} <>
> <Button
{batchTagProjects.isPending ? ( variant="outline"
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> onClick={handleCloseTaggingDialog}
) : ( disabled={taggingInProgress}
<Sparkles className="mr-2 h-4 w-4" /> >
)} Cancel
{batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'} </Button>
</AlertDialogAction> <Button
</AlertDialogFooter> onClick={handleStartTagging}
</AlertDialogContent> disabled={
</AlertDialog> 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> </div>
) )
} }

View File

@ -161,7 +161,7 @@ export const projectRouter = router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([ const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({ ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { name: true, year: true } } }, select: { id: true, name: true, program: { select: { id: true, name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }], orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}), }),
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({

View File

@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc'
import { import {
tagProject, tagProject,
batchTagProjects, batchTagProjects,
batchTagProgramProjects,
getTagSuggestions, getTagSuggestions,
addProjectTag, addProjectTag,
removeProjectTag, removeProjectTag,
@ -494,6 +495,34 @@ export const tagRouter = router({
return result return result
}), }),
/**
* Batch tag all untagged projects in an entire program (edition)
*/
batchTagProgramProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await batchTagProgramProjects(input.programId, ctx.user.id)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BATCH_AI_TAG',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
processed: result.processed,
failed: result.failed,
skipped: result.skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
}),
/** /**
* Manually add a tag to a project * Manually add a tag to a project
*/ */

View File

@ -406,6 +406,48 @@ export async function tagProject(
} }
} }
/**
* Common validation and setup for batch tagging
*/
async function validateBatchTagging(): Promise<{
valid: boolean
error?: string
availableTags?: AvailableTag[]
}> {
const settings = await getTaggingSettings()
console.log('[AI Tagging] Settings:', settings)
if (!settings.enabled) {
console.log('[AI Tagging] AI tagging is disabled in settings')
return {
valid: false,
error: 'AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.',
}
}
// Check if OpenAI is configured
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Tagging] OpenAI is not configured')
return {
valid: false,
error: 'OpenAI API is not configured. Add your API key in Settings > AI.',
}
}
// Check if there are any available tags
const availableTags = await getAvailableTags()
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
if (availableTags.length === 0) {
return {
valid: false,
error: 'No expertise tags defined. Create tags in Settings > Tags first.',
}
}
return { valid: true, availableTags }
}
/** /**
* Batch tag all untagged projects in a round * Batch tag all untagged projects in a round
* *
@ -416,42 +458,13 @@ export async function batchTagProjects(
userId?: string, userId?: string,
onProgress?: (processed: number, total: number) => void onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> { ): Promise<BatchTaggingResult> {
const settings = await getTaggingSettings() const validation = await validateBatchTagging()
console.log('[AI Tagging] Settings:', settings) if (!validation.valid) {
if (!settings.enabled) {
console.log('[AI Tagging] AI tagging is disabled in settings')
return { return {
processed: 0, processed: 0,
failed: 0, failed: 0,
skipped: 0, skipped: 0,
errors: ['AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.'], errors: [validation.error!],
results: [],
}
}
// Check if OpenAI is configured
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Tagging] OpenAI is not configured')
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['OpenAI API is not configured. Add your API key in Settings > AI.'],
results: [],
}
}
// Check if there are any available tags
const availableTags = await getAvailableTags()
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
if (availableTags.length === 0) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['No expertise tags defined. Create tags in Settings > Tags first.'],
results: [], results: [],
} }
} }
@ -462,16 +475,13 @@ export async function batchTagProjects(
include: { include: {
files: { select: { fileType: true } }, files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } }, _count: { select: { teamMembers: true, files: true } },
projectTags: { select: { tagId: true } },
}, },
}) })
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`) console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
// Filter to only projects that truly have no tags (empty tags array AND no projectTags) // Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
(p.tags.length === 0) && (p.projectTags.length === 0)
)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`) console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
@ -513,7 +523,90 @@ export async function batchTagProjects(
return { return {
processed, processed,
failed, failed,
skipped: 0, skipped: alreadyTaggedCount,
errors,
results,
}
}
/**
* Batch tag all untagged projects in an entire program (edition)
*
* Processes all projects across all rounds in the program.
*/
export async function batchTagProgramProjects(
programId: string,
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const validation = await validateBatchTagging()
if (!validation.valid) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [validation.error!],
results: [],
}
}
// Get ALL projects in the program (across all rounds)
const allProjects = await prisma.project.findMany({
where: {
round: { programId },
},
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
console.log(`[AI Tagging] Found ${allProjects.length} total projects in program`)
// Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
if (untaggedProjects.length === 0) {
return {
processed: 0,
failed: 0,
skipped: alreadyTaggedCount,
errors: alreadyTaggedCount > 0
? []
: ['No projects found in this program'],
results: [],
}
}
const results: TaggingResult[] = []
let processed = 0
let failed = 0
const errors: string[] = []
for (let i = 0; i < untaggedProjects.length; i++) {
const project = untaggedProjects[i]
try {
const result = await tagProject(project.id, userId)
results.push(result)
processed++
} catch (error) {
failed++
errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// Report progress
if (onProgress) {
onProgress(i + 1, untaggedProjects.length)
}
}
return {
processed,
failed,
skipped: alreadyTaggedCount,
errors, errors,
results, results,
} }