feat: enhance project search to include all criteria, add AI tag generation button

- ProjectStatesTable local search now covers country, institution, competitionCategory, geographicZone
- project-pool.ts DB search extended to institution, country, geographicZone, team member names
- AwardShortlist eligibility table gains a search input filtering by title, team, country, institution, category
- IndividualAssignmentsTable project filter extended to include country and institution
- Add "Generate AI Tags" dropdown item per row in ProjectStatesTable using tag.tagProject mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:42:37 +01:00
parent dd004baf79
commit 9b3a9f6cbf
4 changed files with 68 additions and 8 deletions

View File

@@ -171,7 +171,9 @@ export function IndividualAssignmentsTable({
return items.filter((ps: any) => return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) || ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) || ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q) ps.project?.competitionCategory?.toLowerCase().includes(q) ||
(ps.project?.country || '').toLowerCase().includes(q) ||
(ps.project?.institution || '').toLowerCase().includes(q)
) )
}, [projectStates, projectSearch]) }, [projectStates, projectSearch])

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -32,6 +33,7 @@ import {
Play, Play,
Trophy, Trophy,
AlertTriangle, AlertTriangle,
Search,
} from 'lucide-react' } from 'lucide-react'
type AwardShortlistProps = { type AwardShortlistProps = {
@@ -58,6 +60,7 @@ export function AwardShortlist({
jobDone, jobDone,
}: AwardShortlistProps) { }: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [eligibilitySearch, setEligibilitySearch] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING' const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
@@ -124,6 +127,19 @@ export function AwardShortlist({
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100) ? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
: 0 : 0
const filteredEligibilities = useMemo(() => {
if (!shortlist) return []
if (!eligibilitySearch.trim()) return shortlist.eligibilities
const q = eligibilitySearch.toLowerCase()
return shortlist.eligibilities.filter((e: any) =>
(e.project?.title || '').toLowerCase().includes(q) ||
(e.project?.teamName || '').toLowerCase().includes(q) ||
(e.project?.country || '').toLowerCase().includes(q) ||
(e.project?.institution || '').toLowerCase().includes(q) ||
(e.project?.competitionCategory || '').toLowerCase().includes(q)
)
}, [shortlist, eligibilitySearch])
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0 const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted) const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted const someShortlisted = shortlistedCount > 0 && !allShortlisted
@@ -266,6 +282,18 @@ export function AwardShortlist({
)} )}
</div> </div>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={eligibilitySearch}
onChange={(e) => setEligibilitySearch(e.target.value)}
className="pl-9 h-9 max-w-sm"
/>
</div>
</div>
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
@@ -288,7 +316,7 @@ export function AwardShortlist({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shortlist.eligibilities.map((e, i) => { {filteredEligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize const isTop5 = i < shortlistSize
return ( return (

View File

@@ -58,6 +58,7 @@ import {
Plus, Plus,
Search, Search,
ExternalLink, ExternalLink,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -136,6 +137,14 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const tagProject = trpc.tag.tagProject.useMutation({
onSuccess: () => {
toast.success('AI tags generated')
utils.roundEngine.getProjectStates.invalidate({ roundId })
},
onError: (err: any) => toast.error(`Tag generation failed: ${err.message}`),
})
const handleTransition = (projectId: string, newState: ProjectState) => { const handleTransition = (projectId: string, newState: ProjectState) => {
transitionMutation.mutate({ projectId, roundId, newState }) transitionMutation.mutate({ projectId, roundId, newState })
} }
@@ -165,10 +174,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
} }
if (searchQuery.trim()) { if (searchQuery.trim()) {
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase()
result = result.filter((ps: any) => result = result.filter((ps: any) => {
(ps.project?.title || '').toLowerCase().includes(q) || const p = ps.project
(ps.project?.teamName || '').toLowerCase().includes(q) return (
(p?.title || '').toLowerCase().includes(q) ||
(p?.teamName || '').toLowerCase().includes(q) ||
(p?.country || '').toLowerCase().includes(q) ||
(p?.institution || '').toLowerCase().includes(q) ||
(p?.competitionCategory || '').toLowerCase().includes(q) ||
(p?.geographicZone || '').toLowerCase().includes(q)
) )
})
} }
return result return result
}, [projectStates, stateFilter, searchQuery]) }, [projectStates, stateFilter, searchQuery])
@@ -411,6 +427,16 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
View Project View Project
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
tagProject.mutate({ projectId: ps.projectId })
}}
disabled={tagProject.isPending}
>
<Sparkles className="mr-2 h-4 w-4" />
Generate AI Tags
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => { {PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
const sCfg = stateConfig[state] const sCfg = stateConfig[state]

View File

@@ -123,12 +123,16 @@ export const projectPoolRouter = router({
where.competitionCategory = competitionCategory where.competitionCategory = competitionCategory
} }
// Search in title, teamName, description // Search in title, teamName, description, institution, country, geographicZone, team member names
if (search) { if (search) {
where.OR = [ where.OR = [
{ title: { contains: search, mode: 'insensitive' } }, { title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } },
{ institution: { contains: search, mode: 'insensitive' } },
{ country: { contains: search, mode: 'insensitive' } },
{ geographicZone: { contains: search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
] ]
} }