Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
- Add Rounds tab to award detail page with create/list/delete functionality - Add "Entry point" badge on first award round (confirmShortlist routes here) - Fix round detail back-link to navigate to parent award when specialAwardId set - Filter award rounds out of competition round list - Add specialAwardId to competition getById round select - Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode) - Remove auto-tag rules from award config, edit page, router, and AI service - Fix competitionId not passed when creating awards from competition context - Add AUTO_FILTER quality threshold to AI filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,15 +25,7 @@ import {
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
type AutoTagRule = {
|
|
||||||
id: string
|
|
||||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
|
||||||
operator: 'equals' | 'contains' | 'in'
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditAwardPage({
|
export default function EditAwardPage({
|
||||||
params,
|
params,
|
||||||
@@ -70,7 +62,6 @@ export default function EditAwardPage({
|
|||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
|
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -93,14 +84,6 @@ export default function EditAwardPage({
|
|||||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||||
|
|
||||||
// Parse autoTagRulesJson
|
|
||||||
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
|
|
||||||
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
|
|
||||||
setAutoTagRules(rules.rules || [])
|
|
||||||
} else {
|
|
||||||
setAutoTagRules([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
@@ -119,7 +102,6 @@ export default function EditAwardPage({
|
|||||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
evaluationRoundId: evaluationRoundId || undefined,
|
evaluationRoundId: evaluationRoundId || undefined,
|
||||||
eligibilityMode,
|
eligibilityMode,
|
||||||
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
|
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
@@ -130,28 +112,6 @@ export default function EditAwardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRule = () => {
|
|
||||||
setAutoTagRules([
|
|
||||||
...autoTagRules,
|
|
||||||
{
|
|
||||||
id: `rule-${Date.now()}`,
|
|
||||||
field: 'competitionCategory',
|
|
||||||
operator: 'equals',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRule = (id: string) => {
|
|
||||||
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
|
|
||||||
setAutoTagRules(
|
|
||||||
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -348,135 +308,6 @@ export default function EditAwardPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Auto-Tag Rules */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Auto-Tag Rules</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Deterministic eligibility rules based on project metadata
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={addRule}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Rule
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{autoTagRules.length === 0 ? (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
|
|
||||||
Rules work together with the source round setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{autoTagRules.map((rule, index) => (
|
|
||||||
<div
|
|
||||||
key={rule.id}
|
|
||||||
className="flex items-start gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Field</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.field}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
field: v as AutoTagRule['field'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="competitionCategory">
|
|
||||||
Competition Category
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="country">Country</SelectItem>
|
|
||||||
<SelectItem value="geographicZone">
|
|
||||||
Geographic Zone
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tags">Tags</SelectItem>
|
|
||||||
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.operator}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
operator: v as AutoTagRule['operator'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="equals">Equals</SelectItem>
|
|
||||||
<SelectItem value="contains">Contains</SelectItem>
|
|
||||||
<SelectItem value="in">In (comma-separated)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Value</Label>
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
value={rule.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRule(rule.id, { value: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
rule.operator === 'in'
|
|
||||||
? 'value1,value2,value3'
|
|
||||||
: 'Enter value...'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={() => removeRule(rule.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoTagRules.length > 0 && (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
|
||||||
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
<strong>How it works:</strong> Filter from{' '}
|
|
||||||
<Badge variant="outline" className="mx-1">
|
|
||||||
{evaluationRoundId
|
|
||||||
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
|
|
||||||
?.name || 'Selected Round'
|
|
||||||
: 'All Projects'}
|
|
||||||
</Badge>
|
|
||||||
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Voting Window Card */}
|
{/* Voting Window Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import {
|
|||||||
Vote,
|
Vote,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
|
|||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||||
const [activeTab, setActiveTab] = useState('eligibility')
|
const [activeTab, setActiveTab] = useState('eligibility')
|
||||||
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||||
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||||
|
|
||||||
// Pagination for eligibility list
|
// Pagination for eligibility list
|
||||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||||
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
|
|||||||
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
||||||
enabled: activeTab === 'results',
|
enabled: activeTab === 'results',
|
||||||
})
|
})
|
||||||
|
const { data: awardRounds, refetch: refetchRounds } =
|
||||||
|
trpc.specialAward.listRounds.useQuery({ awardId }, {
|
||||||
|
enabled: activeTab === 'rounds',
|
||||||
|
})
|
||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
|
|||||||
const deleteAward = trpc.specialAward.delete.useMutation({
|
const deleteAward = trpc.specialAward.delete.useMutation({
|
||||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||||
})
|
})
|
||||||
|
const createRound = trpc.specialAward.createRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
setAddRoundOpen(false)
|
||||||
|
setRoundForm({ name: '', roundType: 'EVALUATION' })
|
||||||
|
toast.success('Round created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const deleteRound = trpc.specialAward.deleteRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
toast.success('Round deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const handleStatusChange = async (
|
const handleStatusChange = async (
|
||||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||||
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
|
|||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
Jurors ({award._count.jurors})
|
Jurors ({award._count.jurors})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rounds">
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="results">
|
<TabsTrigger value="results">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
Results
|
Results
|
||||||
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Rounds Tab */}
|
||||||
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
|
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||||
|
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!award.competitionId && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Link this award to a competition first before creating rounds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
|
||||||
|
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" disabled={!award.competitionId}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Round
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Award Round</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new round to the "{award.name}" award evaluation track.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-name">Round Name</Label>
|
||||||
|
<Input
|
||||||
|
id="round-name"
|
||||||
|
placeholder="e.g. Award Evaluation"
|
||||||
|
value={roundForm.name}
|
||||||
|
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-type">Round Type</Label>
|
||||||
|
<Select
|
||||||
|
value={roundForm.roundType}
|
||||||
|
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="round-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EVALUATION">Evaluation</SelectItem>
|
||||||
|
<SelectItem value="FILTERING">Filtering</SelectItem>
|
||||||
|
<SelectItem value="SUBMISSION">Submission</SelectItem>
|
||||||
|
<SelectItem value="MENTORING">Mentoring</SelectItem>
|
||||||
|
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
|
||||||
|
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createRound.mutate({
|
||||||
|
awardId,
|
||||||
|
name: roundForm.name.trim(),
|
||||||
|
roundType: roundForm.roundType as any,
|
||||||
|
})}
|
||||||
|
disabled={!roundForm.name.trim() || createRound.isPending}
|
||||||
|
>
|
||||||
|
{createRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
|
||||||
|
) : 'Create Round'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!awardRounds ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : awardRounds.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No rounds yet. Create your first award round to build an evaluation track.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{awardRounds.map((round: any, index: number) => {
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
const roundTypeColors: Record<string, string> = {
|
||||||
|
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
||||||
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{index === 0 && (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||||
|
Entry point
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.status === 'ROUND_DRAFT' && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{round.name}". This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Results Tab */}
|
{/* Results Tab */}
|
||||||
<TabsContent value="results" className="space-y-4">
|
<TabsContent value="results" className="space-y-4">
|
||||||
{voteResults && voteResults.results.length > 0 ? (() => {
|
{voteResults && voteResults.results.length > 0 ? (() => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
|||||||
|
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
programId: competition.programId,
|
programId: competition.programId,
|
||||||
|
competitionId: params.competitionId,
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
description: formData.description.trim() || undefined,
|
description: formData.description.trim() || undefined,
|
||||||
scoringMode: formData.scoringMode,
|
scoringMode: formData.scoringMode,
|
||||||
|
|||||||
@@ -338,14 +338,14 @@ export default function CompetitionDetailPage() {
|
|||||||
{/* Rounds Tab */}
|
{/* Rounds Tab */}
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
|
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add Round
|
Add Round
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{competition.rounds.length === 0 ? (
|
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
No rounds configured. Add rounds to define the competition flow.
|
No rounds configured. Add rounds to define the competition flow.
|
||||||
@@ -353,7 +353,7 @@ export default function CompetitionDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{competition.rounds.map((round: any, index: number) => {
|
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
||||||
const projectCount = round._count?.projectRoundStates ?? 0
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
const assignmentCount = round._count?.assignments ?? 0
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
const statusLabel = round.status.replace('ROUND_', '')
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
|||||||
@@ -548,8 +548,8 @@ export default function RoundDetailPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<Link href={'/admin/rounds' as Route} className="mt-0.5 shrink-0">
|
<Link href={(round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds') as Route} className="mt-0.5 shrink-0">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label="Back to rounds">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1950,8 +1950,6 @@ export default function RoundDetailPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{roundAwards.map((award) => {
|
{roundAwards.map((award) => {
|
||||||
const eligibleCount = award._count?.eligibilities || 0
|
const eligibleCount = award._count?.eligibilities || 0
|
||||||
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
|
|
||||||
const ruleCount = autoTagRules?.rules?.length || 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -1983,10 +1981,6 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-medium text-foreground">{ruleCount}</div>
|
|
||||||
<div className="text-xs">{ruleCount === 1 ? 'rule' : 'rules'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-medium text-foreground">{eligibleCount}</div>
|
<div className="font-medium text-foreground">{eligibleCount}</div>
|
||||||
<div className="text-xs">eligible</div>
|
<div className="text-xs">eligible</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Star,
|
Star,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type AwardShortlistProps = {
|
type AwardShortlistProps = {
|
||||||
@@ -92,6 +93,12 @@ export function AwardShortlist({
|
|||||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
|
||||||
|
{ awardId },
|
||||||
|
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
|
||||||
|
)
|
||||||
|
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
|
||||||
|
|
||||||
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||||
@@ -210,11 +217,23 @@ export function AwardShortlist({
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
|
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
{eligibilityMode === 'SEPARATE_POOL'
|
{eligibilityMode === 'SEPARATE_POOL'
|
||||||
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
|
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
|
||||||
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
|
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
|
||||||
}
|
}
|
||||||
|
</p>
|
||||||
|
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -107,9 +107,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
|
|
||||||
// AI criteria state
|
// AI criteria state
|
||||||
const [criteriaText, setCriteriaText] = useState('')
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
|
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'>('FLAG')
|
||||||
const [batchSize, setBatchSize] = useState(20)
|
const [batchSize, setBatchSize] = useState(20)
|
||||||
const [parallelBatches, setParallelBatches] = useState(3)
|
const [parallelBatches, setParallelBatches] = useState(3)
|
||||||
|
const [autoFilterThreshold, setAutoFilterThreshold] = useState(4)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [criteriaDirty, setCriteriaDirty] = useState(false)
|
const [criteriaDirty, setCriteriaDirty] = useState(false)
|
||||||
const criteriaLoaded = useRef(false)
|
const criteriaLoaded = useRef(false)
|
||||||
@@ -168,9 +169,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
if (aiRule && !criteriaLoaded.current) {
|
if (aiRule && !criteriaLoaded.current) {
|
||||||
const config = (aiRule.configJson || {}) as Record<string, unknown>
|
const config = (aiRule.configJson || {}) as Record<string, unknown>
|
||||||
setCriteriaText((config.criteriaText as string) || '')
|
setCriteriaText((config.criteriaText as string) || '')
|
||||||
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG')
|
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER') || 'FLAG')
|
||||||
setBatchSize((config.batchSize as number) || 20)
|
setBatchSize((config.batchSize as number) || 20)
|
||||||
setParallelBatches((config.parallelBatches as number) || 3)
|
setParallelBatches((config.parallelBatches as number) || 3)
|
||||||
|
setAutoFilterThreshold((config.autoFilterThreshold as number) || 4)
|
||||||
criteriaLoaded.current = true
|
criteriaLoaded.current = true
|
||||||
}
|
}
|
||||||
}, [aiRule])
|
}, [aiRule])
|
||||||
@@ -269,6 +271,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
action: aiAction,
|
action: aiAction,
|
||||||
batchSize,
|
batchSize,
|
||||||
parallelBatches,
|
parallelBatches,
|
||||||
|
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -296,6 +299,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
action: aiAction,
|
action: aiAction,
|
||||||
batchSize,
|
batchSize,
|
||||||
parallelBatches,
|
parallelBatches,
|
||||||
|
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aiRule) {
|
if (aiRule) {
|
||||||
@@ -519,14 +523,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-3">
|
<CollapsibleContent className="mt-3">
|
||||||
<div className="grid grid-cols-3 gap-3 rounded-lg border p-3 bg-muted/20">
|
<div className="space-y-3 rounded-lg border p-3 bg-muted/20">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
|
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
|
||||||
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG'); setCriteriaDirty(true) }}>
|
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'); setCriteriaDirty(true) }}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="AUTO_FILTER">Smart auto-filter</SelectItem>
|
||||||
<SelectItem value="FLAG">Flag for review</SelectItem>
|
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||||
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
|
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
|
||||||
<SelectItem value="PASS">Auto-pass matches</SelectItem>
|
<SelectItem value="PASS">Auto-pass matches</SelectItem>
|
||||||
@@ -556,6 +562,49 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{aiAction === 'AUTO_FILTER' && (
|
||||||
|
<div className="rounded-md border border-purple-200 bg-purple-50/50 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">Quality threshold</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Projects scoring at or below this are auto-rejected as spam/junk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={9}
|
||||||
|
className="h-8 w-16 text-xs text-center"
|
||||||
|
value={autoFilterThreshold}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Math.min(9, Math.max(1, parseInt(e.target.value) || 4))
|
||||||
|
setAutoFilterThreshold(v)
|
||||||
|
setCriteriaDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 text-xs">
|
||||||
|
<div className="rounded bg-red-100 border border-red-200 px-2 py-1.5 text-center">
|
||||||
|
<span className="font-medium text-red-800">Auto-reject</span>
|
||||||
|
<p className="text-red-600 mt-0.5">Score 1-{autoFilterThreshold} or spam</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-amber-100 border border-amber-200 px-2 py-1.5 text-center">
|
||||||
|
<span className="font-medium text-amber-800">Flag for review</span>
|
||||||
|
<p className="text-amber-600 mt-0.5">Score {autoFilterThreshold + 1}+ but fails criteria</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-green-100 border border-green-200 px-2 py-1.5 text-center">
|
||||||
|
<span className="font-medium text-green-800">Auto-pass</span>
|
||||||
|
<p className="text-green-600 mt-0.5">Meets criteria</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const competitionRouter = router({
|
|||||||
sortOrder: true,
|
sortOrder: true,
|
||||||
windowOpenAt: true,
|
windowOpenAt: true,
|
||||||
windowCloseAt: true,
|
windowCloseAt: true,
|
||||||
|
specialAwardId: true,
|
||||||
juryGroup: {
|
juryGroup: {
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const specialAwardRouter = router({
|
|||||||
useAiEligibility: z.boolean().optional(),
|
useAiEligibility: z.boolean().optional(),
|
||||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
||||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
|
||||||
competitionId: z.string().optional(),
|
competitionId: z.string().optional(),
|
||||||
evaluationRoundId: z.string().optional(),
|
evaluationRoundId: z.string().optional(),
|
||||||
juryGroupId: z.string().optional(),
|
juryGroupId: z.string().optional(),
|
||||||
@@ -115,7 +114,6 @@ export const specialAwardRouter = router({
|
|||||||
useAiEligibility: input.useAiEligibility ?? true,
|
useAiEligibility: input.useAiEligibility ?? true,
|
||||||
scoringMode: input.scoringMode,
|
scoringMode: input.scoringMode,
|
||||||
maxRankedPicks: input.maxRankedPicks,
|
maxRankedPicks: input.maxRankedPicks,
|
||||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
|
||||||
competitionId: input.competitionId,
|
competitionId: input.competitionId,
|
||||||
evaluationRoundId: input.evaluationRoundId,
|
evaluationRoundId: input.evaluationRoundId,
|
||||||
juryGroupId: input.juryGroupId,
|
juryGroupId: input.juryGroupId,
|
||||||
@@ -152,7 +150,6 @@ export const specialAwardRouter = router({
|
|||||||
useAiEligibility: z.boolean().optional(),
|
useAiEligibility: z.boolean().optional(),
|
||||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
||||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
|
||||||
votingStartAt: z.date().optional(),
|
votingStartAt: z.date().optional(),
|
||||||
votingEndAt: z.date().optional(),
|
votingEndAt: z.date().optional(),
|
||||||
competitionId: z.string().nullable().optional(),
|
competitionId: z.string().nullable().optional(),
|
||||||
@@ -162,13 +159,10 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, autoTagRulesJson, ...rest } = input
|
const { id, ...rest } = input
|
||||||
const award = await ctx.prisma.specialAward.update({
|
const award = await ctx.prisma.specialAward.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: rest,
|
||||||
...rest,
|
|
||||||
...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* AI-Powered Award Eligibility Service
|
* AI-Powered Award Eligibility Service
|
||||||
*
|
*
|
||||||
* Determines project eligibility for special awards using:
|
* Determines project eligibility for special awards using
|
||||||
* - Deterministic field matching (tags, country, category)
|
* AI interpretation of plain-language criteria.
|
||||||
* - AI interpretation of plain-language criteria
|
|
||||||
*
|
*
|
||||||
* GDPR Compliance:
|
* GDPR Compliance:
|
||||||
* - All project data is anonymized before AI processing
|
* - All project data is anonymized before AI processing
|
||||||
@@ -70,12 +69,6 @@ quality_score is a 0-100 integer measuring how well the project fits the award c
|
|||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AutoTagRule = {
|
|
||||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
|
||||||
operator: 'equals' | 'contains' | 'in'
|
|
||||||
value: string | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EligibilityResult {
|
export interface EligibilityResult {
|
||||||
projectId: string
|
projectId: string
|
||||||
eligible: boolean
|
eligible: boolean
|
||||||
@@ -106,66 +99,6 @@ interface ProjectForEligibility {
|
|||||||
files?: Array<{ fileType: string | null }>
|
files?: Array<{ fileType: string | null }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auto Tag Rules ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function applyAutoTagRules(
|
|
||||||
rules: AutoTagRule[],
|
|
||||||
projects: ProjectForEligibility[]
|
|
||||||
): Map<string, boolean> {
|
|
||||||
const results = new Map<string, boolean>()
|
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
const matches = rules.every((rule) => {
|
|
||||||
const fieldValue = getFieldValue(project, rule.field)
|
|
||||||
|
|
||||||
switch (rule.operator) {
|
|
||||||
case 'equals':
|
|
||||||
return String(fieldValue).toLowerCase() === String(rule.value).toLowerCase()
|
|
||||||
case 'contains':
|
|
||||||
if (Array.isArray(fieldValue)) {
|
|
||||||
return fieldValue.some((v) =>
|
|
||||||
String(v).toLowerCase().includes(String(rule.value).toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return String(fieldValue || '').toLowerCase().includes(String(rule.value).toLowerCase())
|
|
||||||
case 'in':
|
|
||||||
if (Array.isArray(rule.value)) {
|
|
||||||
return rule.value.some((v) =>
|
|
||||||
String(v).toLowerCase() === String(fieldValue).toLowerCase()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
results.set(project.id, matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFieldValue(
|
|
||||||
project: ProjectForEligibility,
|
|
||||||
field: AutoTagRule['field']
|
|
||||||
): unknown {
|
|
||||||
switch (field) {
|
|
||||||
case 'competitionCategory':
|
|
||||||
return project.competitionCategory
|
|
||||||
case 'country':
|
|
||||||
return project.country
|
|
||||||
case 'geographicZone':
|
|
||||||
return project.geographicZone
|
|
||||||
case 'tags':
|
|
||||||
return project.tags
|
|
||||||
case 'oceanIssue':
|
|
||||||
return project.oceanIssue
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,10 +74,12 @@ export type DocumentCheckConfig = {
|
|||||||
|
|
||||||
export type AIScreeningConfig = {
|
export type AIScreeningConfig = {
|
||||||
criteriaText: string
|
criteriaText: string
|
||||||
action: 'PASS' | 'REJECT' | 'FLAG' // REJECT = auto-filter-out, FLAG = flag for human review
|
action: 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER' // AUTO_FILTER = reject spam/low-quality, flag borderline
|
||||||
// Performance settings
|
// Performance settings
|
||||||
batchSize?: number // Projects per API call (1-50, default 20)
|
batchSize?: number // Projects per API call (1-50, default 20)
|
||||||
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
||||||
|
// AUTO_FILTER settings
|
||||||
|
autoFilterThreshold?: number // Quality score cutoff (1-10, default 4). Scores at or below are auto-rejected.
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||||
@@ -794,8 +796,26 @@ export async function executeFilteringRules(
|
|||||||
await executeAIScreening(config, projects, userId, roundId, onProgress, async (batchAIResults) => {
|
await executeAIScreening(config, projects, userId, roundId, onProgress, async (batchAIResults) => {
|
||||||
const batchResults: ProjectFilteringResult[] = []
|
const batchResults: ProjectFilteringResult[] = []
|
||||||
for (const [projectId, aiResult] of batchAIResults) {
|
for (const [projectId, aiResult] of batchAIResults) {
|
||||||
const passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
let passed: boolean
|
||||||
const aiAction = config.action || 'FLAG'
|
let aiAction: string
|
||||||
|
if (config.action === 'AUTO_FILTER') {
|
||||||
|
const threshold = config.autoFilterThreshold ?? 4
|
||||||
|
if (aiResult.spamRisk || aiResult.qualityScore <= threshold) {
|
||||||
|
// Clear spam/junk — auto-reject
|
||||||
|
passed = false
|
||||||
|
aiAction = 'REJECT'
|
||||||
|
} else if (!aiResult.meetsCriteria) {
|
||||||
|
// Borderline — flag for human review
|
||||||
|
passed = false
|
||||||
|
aiAction = 'FLAG'
|
||||||
|
} else {
|
||||||
|
passed = true
|
||||||
|
aiAction = 'FLAG'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
||||||
|
aiAction = config.action || 'FLAG'
|
||||||
|
}
|
||||||
batchResults.push(
|
batchResults.push(
|
||||||
computeProjectResult(
|
computeProjectResult(
|
||||||
projectId,
|
projectId,
|
||||||
@@ -827,9 +847,25 @@ export async function executeFilteringRules(
|
|||||||
for (const aiRule of aiRules) {
|
for (const aiRule of aiRules) {
|
||||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||||
if (screening) {
|
if (screening) {
|
||||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
|
||||||
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
||||||
const aiAction = aiConfig?.action || 'FLAG'
|
let passed: boolean
|
||||||
|
let aiAction: string
|
||||||
|
if (aiConfig?.action === 'AUTO_FILTER') {
|
||||||
|
const threshold = aiConfig.autoFilterThreshold ?? 4
|
||||||
|
if (screening.spamRisk || screening.qualityScore <= threshold) {
|
||||||
|
passed = false
|
||||||
|
aiAction = 'REJECT'
|
||||||
|
} else if (!screening.meetsCriteria) {
|
||||||
|
passed = false
|
||||||
|
aiAction = 'FLAG'
|
||||||
|
} else {
|
||||||
|
passed = true
|
||||||
|
aiAction = 'FLAG'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
passed = screening.meetsCriteria && !screening.spamRisk
|
||||||
|
aiAction = aiConfig?.action || 'FLAG'
|
||||||
|
}
|
||||||
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
|
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
|
||||||
aiScreeningData[aiRule.id] = screening
|
aiScreeningData[aiRule.id] = screening
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import {
|
import { aiInterpretCriteria } from './ai-award-eligibility'
|
||||||
applyAutoTagRules,
|
|
||||||
aiInterpretCriteria,
|
|
||||||
type AutoTagRule,
|
|
||||||
} from './ai-award-eligibility'
|
|
||||||
|
|
||||||
const BATCH_SIZE = 20
|
const BATCH_SIZE = 20
|
||||||
|
|
||||||
@@ -118,14 +114,7 @@ export async function processEligibilityJob(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Phase 1: Auto-tag rules (deterministic, fast)
|
// AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
||||||
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
|
||||||
let autoResults: Map<string, boolean> | undefined
|
|
||||||
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
|
||||||
autoResults = applyAutoTagRules(autoTagRules, projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
|
||||||
// Process in batches to avoid timeouts
|
// Process in batches to avoid timeouts
|
||||||
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
|
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
|
||||||
|
|
||||||
@@ -161,14 +150,11 @@ export async function processEligibilityJob(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
// Combine results
|
||||||
const eligibilities = projects.map((project) => {
|
const eligibilities = projects.map((project) => {
|
||||||
const autoEligible = autoResults?.get(project.id) ?? true
|
|
||||||
const aiEval = aiResults?.get(project.id)
|
const aiEval = aiResults?.get(project.id)
|
||||||
const aiEligible = aiEval?.eligible ?? true
|
const eligible = aiEval?.eligible ?? true
|
||||||
|
const method = aiResults ? 'AUTO' : 'MANUAL'
|
||||||
const eligible = autoEligible && aiEligible
|
|
||||||
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
|
|||||||
@@ -317,22 +317,6 @@ export const AwardConfigSchema = z.object({
|
|||||||
useAiEligibility: z.boolean().default(true),
|
useAiEligibility: z.boolean().default(true),
|
||||||
/** Plain-language criteria for AI interpretation */
|
/** Plain-language criteria for AI interpretation */
|
||||||
criteriaText: z.string().optional(),
|
criteriaText: z.string().optional(),
|
||||||
/** Structured auto-tag rules for deterministic eligibility */
|
|
||||||
autoTagRules: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
field: z.enum([
|
|
||||||
'competitionCategory',
|
|
||||||
'country',
|
|
||||||
'geographicZone',
|
|
||||||
'tags',
|
|
||||||
'oceanIssue',
|
|
||||||
]),
|
|
||||||
operator: z.enum(['equals', 'contains', 'in']),
|
|
||||||
value: z.union([z.string(), z.array(z.string())]),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.default([]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AwardConfig = z.infer<typeof AwardConfigSchema>
|
export type AwardConfig = z.infer<typeof AwardConfigSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user