From 05862f1e554d81da7e709615b084c1f419d1e24e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Feb 2026 10:27:52 +0100 Subject: [PATCH] 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 --- src/app/(admin)/admin/projects/page.tsx | 373 +++++++++++++++++++----- src/server/routers/project.ts | 2 +- src/server/routers/tag.ts | 29 ++ src/server/services/ai-tagging.ts | 169 ++++++++--- 4 files changed, 469 insertions(+), 104 deletions(-) diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index da9ac08..f09c138 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -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('') + const [selectedProgramForTagging, setSelectedProgramForTagging] = useState('') + 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() { {/* AI Tagging Dialog */} - - - - - - Generate AI Tags - - - Use AI to automatically generate expertise tags for all projects in a round - that don't have tags yet. This helps with jury matching and filtering. - - -
-
- - -
-

- Only projects without existing tags will be processed. Existing tags are preserved. -

+ + + + +
+ +
+
+ AI Tag Generator +

+ Automatically categorize projects with expertise tags +

+
+
+
+ +
+ {/* Progress Indicator (when running) */} + {taggingInProgress && ( +
+
+
+ +
+

+ AI Tagging in Progress +

+

+ Analyzing projects and assigning expertise tags... +

+
+
+
+
+ + Processing projects... + +
+ +
+
+
+ )} + + {/* Result Display */} + {taggingResult && !taggingInProgress && ( +
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' + }`}> +
+ {taggingResult.processed > 0 ? ( + + ) : taggingResult.errors.length > 0 ? ( + + ) : ( + + )} + + {taggingResult.processed > 0 + ? 'Tagging Complete' + : taggingResult.errors.length > 0 + ? 'Tagging Issue' + : 'No Projects to Tag'} + +
+
+
+

{taggingResult.processed}

+

Tagged

+
+
+

{taggingResult.skipped}

+

Already Tagged

+
+
+

{taggingResult.failed}

+

Failed

+
+
+ {taggingResult.errors.length > 0 && ( +

+ {taggingResult.errors[0]} +

+ )} +
+ )} + + {/* Scope Selection */} + {!taggingInProgress && !taggingResult && ( + <> +
+ +
+ + +
+
+ + {/* Selection */} +
+ {taggingScope === 'round' ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + {/* Info Note */} +
+ +

+ Only projects without existing tags will be processed. AI will analyze project descriptions and assign relevant expertise tags for jury matching. +

+
+ + )}
- - Cancel - { - if (selectedRoundForTagging) { - batchTagProjects.mutate({ roundId: selectedRoundForTagging }) - } - }} - disabled={!selectedRoundForTagging || batchTagProjects.isPending} - > - {batchTagProjects.isPending ? ( - - ) : ( - - )} - {batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'} - - - - + + {/* Footer */} +
+ {taggingResult ? ( + + ) : ( + <> + + + + )} +
+
+
) } diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 1976988..d11e3f4 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -161,7 +161,7 @@ export const projectRouter = router({ .query(async ({ ctx }) => { const [rounds, countries, categories, issues] = await Promise.all([ 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' }], }), ctx.prisma.project.findMany({ diff --git a/src/server/routers/tag.ts b/src/server/routers/tag.ts index c077912..8416544 100644 --- a/src/server/routers/tag.ts +++ b/src/server/routers/tag.ts @@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc' import { tagProject, batchTagProjects, + batchTagProgramProjects, getTagSuggestions, addProjectTag, removeProjectTag, @@ -494,6 +495,34 @@ export const tagRouter = router({ 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 */ diff --git a/src/server/services/ai-tagging.ts b/src/server/services/ai-tagging.ts index 63c08c0..477251b 100644 --- a/src/server/services/ai-tagging.ts +++ b/src/server/services/ai-tagging.ts @@ -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 * @@ -416,42 +458,13 @@ export async function batchTagProjects( userId?: string, onProgress?: (processed: number, total: number) => void ): Promise { - const settings = await getTaggingSettings() - console.log('[AI Tagging] Settings:', settings) - - if (!settings.enabled) { - console.log('[AI Tagging] AI tagging is disabled in settings') + const validation = await validateBatchTagging() + if (!validation.valid) { return { processed: 0, failed: 0, skipped: 0, - errors: ['AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.'], - 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.'], + errors: [validation.error!], results: [], } } @@ -462,16 +475,13 @@ export async function batchTagProjects( include: { files: { select: { fileType: true } }, _count: { select: { teamMembers: true, files: true } }, - projectTags: { select: { tagId: true } }, }, }) 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) - const untaggedProjects = allProjects.filter(p => - (p.tags.length === 0) && (p.projectTags.length === 0) - ) + // 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`) @@ -513,7 +523,90 @@ export async function batchTagProjects( return { processed, 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 { + 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, results, }