Compare commits
5 Commits
db728830d4
...
a72e815d3a
| Author | SHA1 | Date |
|---|---|---|
|
|
a72e815d3a | |
|
|
406ec46c81 | |
|
|
d068d9b6f6 | |
|
|
d45eccea47 | |
|
|
c063f5bba3 |
|
|
@ -0,0 +1,15 @@
|
|||
Notes: Filtering Round: Criteria- Older than 3 years (for all in the startup category), those who submit something random (like a spam project) (AI?)
|
||||
|
||||
-Add filters to the page (who sent documents, etc.)
|
||||
|
||||
-Partners section should be a semi-crm system to track possible sponsors and partners
|
||||
|
||||
-No translation into french (no localization)
|
||||
|
||||
-Ameliorate the user experience (make it more simple)
|
||||
|
||||
-Special Awards- Specific Jury Members (jury members will have to choose amongst projects that fit specific criteria (like the country they're based))- Spotlight on Africa, Coup de Coeur - Make a special award section and make a special case for judge invitation for special awards and allow us to make special awards and assign them to the selection of judges for the special awards specifically (And give them their own login space to see everything)
|
||||
-Invite jury member (with tag for special awards) --> Make special award (Criteria needed, Add a tag for special award (so for example, if a location is italy it will auto have the tag for COup de Coeur (since it's criteria is it only exists in certain countries))) - This is also a separate Jury Round - Use AI to sort through elligible projects based on the plain-language criteria (so the AI interprets the criteria and all projects and smart-assigns them to the round) - Make sure this round allows them to simply choose which project will win (since they have independent criteria) - Make a mix of voting for which project wins the project (or recommended project for the award), but also in the round they can simply assign a project to the award without any criteria requirements and such
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
|
|
@ -18,7 +18,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus, Settings } from 'lucide-react'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
|
|
@ -64,20 +64,12 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/settings`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function ProgramSettingsPage() {
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Program Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure settings for {program?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Advanced settings for this program
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
Program-specific settings will be available in a future update.
|
||||
<br />
|
||||
For now, manage rounds and projects through the program detail page.
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild>
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
Back to Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ import {
|
|||
Plus,
|
||||
MoreHorizontal,
|
||||
FolderKanban,
|
||||
Settings,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
|
|
@ -147,12 +146,6 @@ async function ProgramsContent() {
|
|||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/settings`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Plus, UserMinus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function ProjectAssignmentsPage() {
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id })
|
||||
const { data: assignments = [], isLoading: assignmentsLoading } = trpc.assignment.listByProject.useQuery({ projectId: id })
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const removeAssignment = trpc.assignment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Assignment removed')
|
||||
utils.assignment.listByProject.invalidate({ projectId: id })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to remove assignment')
|
||||
},
|
||||
})
|
||||
|
||||
// Remove handled via AlertDialog in JSX
|
||||
|
||||
const isLoading = projectLoading || assignmentsLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/projects/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Jury Assignments</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{project?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/${project?.roundProjects?.[0]?.round?.id}/assignments`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage in Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assigned Jury Members</CardTitle>
|
||||
<CardDescription>
|
||||
{assignments.length} jury member{assignments.length !== 1 ? 's' : ''} assigned to evaluate this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{assignments.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No jury members assigned yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Jury Member</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{assignment.user.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{assignment.user.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={assignment.evaluation?.status === 'SUBMITTED' ? 'success' : 'secondary'}>
|
||||
{assignment.evaluation?.status || 'Pending'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={removeAssignment.isPending}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Assignment</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Remove this jury member from the project? Their evaluation data will also be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeAssignment.mutate({ id: assignment.id })}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -391,14 +391,6 @@ export default function ProjectsPage() {
|
|||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}/assignments`}
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import {
|
|||
Plus,
|
||||
ArrowRightCircle,
|
||||
Minus,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
|
|
@ -64,9 +68,27 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
|
||||
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
|
||||
const isFilteringRound = roundType === 'FILTERING'
|
||||
|
||||
const { data: filteringStats, refetch: refetchFilteringStats } =
|
||||
trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
const { data: aiStatus } = trpc.filtering.checkAIStatus.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
|
|
@ -85,6 +107,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
},
|
||||
})
|
||||
|
||||
// Filtering mutations
|
||||
const executeRules = trpc.filtering.executeRules.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
const handleExecuteFiltering = async () => {
|
||||
try {
|
||||
const result = await executeRules.mutateAsync({ roundId })
|
||||
toast.success(
|
||||
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
|
||||
)
|
||||
refetchFilteringStats()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to execute filtering'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinalizeFiltering = async () => {
|
||||
try {
|
||||
const result = await finalizeResults.mutateAsync({ roundId })
|
||||
toast.success(
|
||||
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
|
||||
)
|
||||
refetchFilteringStats()
|
||||
refetchRound()
|
||||
utils.project.list.invalidate()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to finalize'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <RoundDetailSkeleton />
|
||||
}
|
||||
|
|
@ -403,49 +459,200 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filtering Section (for FILTERING rounds) */}
|
||||
{isFilteringRound && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Project Filtering
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Run automated screening rules on projects in this round
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExecuteFiltering}
|
||||
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
|
||||
>
|
||||
{executeRules.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Filtering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* AI Status Warning */}
|
||||
{aiStatus?.hasAIRules && !aiStatus?.configured && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-amber-700">AI Configuration Required</p>
|
||||
<p className="text-sm text-amber-600">
|
||||
{aiStatus.error || 'AI screening rules require OpenAI to be configured.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/settings">Configure AI</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{filteringStats && filteringStats.total > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<Filter className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{filteringStats.total}</p>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/20">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{filteringStats.passed}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Passed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/10">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/20">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{filteringStats.filteredOut}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Filtered Out</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{filteringStats.flagged}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Flagged</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure rules and run filtering to screen projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
|
||||
<ListChecks className="mr-2 h-4 w-4" />
|
||||
Configure Rules
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{filteringRules?.length || 0}
|
||||
</Badge>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering/results`}>
|
||||
<ClipboardCheck className="mr-2 h-4 w-4" />
|
||||
Review Results
|
||||
</Link>
|
||||
</Button>
|
||||
{filteringStats && filteringStats.total > 0 && (
|
||||
<Button
|
||||
onClick={handleFinalizeFiltering}
|
||||
disabled={finalizeResults.isPending}
|
||||
variant="default"
|
||||
>
|
||||
{finalizeResults.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Finalize Results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/projects/import?round=${round.id}`}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Manage Filtering
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/projects?round=${round.id}`}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
View Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAssignOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Assign Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
|
||||
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
||||
Advance Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
|
||||
<Minus className="mr-2 h-4 w-4" />
|
||||
Remove Projects
|
||||
</Button>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Management */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Project Management</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/projects/import?round=${round.id}`}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAssignOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Assign
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAdvanceOpen(true)}>
|
||||
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
||||
Advance
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setRemoveOpen(true)}>
|
||||
<Minus className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/projects?round=${round.id}`}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round Management */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
{!isFilteringRound && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtering
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function UserEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/members/${id}`)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function UserInvitePage() {
|
||||
redirect('/admin/members/invite')
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function UsersPage() {
|
||||
redirect('/admin/members')
|
||||
}
|
||||
|
|
@ -66,12 +66,12 @@ export async function isOpenAIConfigured(): Promise<boolean> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Test OpenAI connection
|
||||
* List available models from OpenAI API
|
||||
*/
|
||||
export async function testOpenAIConnection(): Promise<{
|
||||
export async function listAvailableModels(): Promise<{
|
||||
success: boolean
|
||||
models?: string[]
|
||||
error?: string
|
||||
model?: string
|
||||
}> {
|
||||
try {
|
||||
const client = await getOpenAI()
|
||||
|
|
@ -83,9 +83,90 @@ export async function testOpenAIConnection(): Promise<{
|
|||
}
|
||||
}
|
||||
|
||||
// Simple test request
|
||||
const response = await client.models.list()
|
||||
const chatModels = response.data
|
||||
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
|
||||
.map((m) => m.id)
|
||||
.sort()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
models: chatModels,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a specific model is available
|
||||
*/
|
||||
export async function validateModel(modelId: string): Promise<{
|
||||
valid: boolean
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const client = await getOpenAI()
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'OpenAI API key not configured',
|
||||
}
|
||||
}
|
||||
|
||||
// Try a minimal completion with the model
|
||||
await client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
max_tokens: 1,
|
||||
})
|
||||
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
// Check for specific model errors
|
||||
if (message.includes('does not exist') || message.includes('model_not_found')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Model "${modelId}" is not available with your API key`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test OpenAI connection with the configured model
|
||||
*/
|
||||
export async function testOpenAIConnection(): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
model?: string
|
||||
modelTested?: string
|
||||
}> {
|
||||
try {
|
||||
const client = await getOpenAI()
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'OpenAI API key not configured',
|
||||
}
|
||||
}
|
||||
|
||||
// Get the configured model
|
||||
const configuredModel = await getConfiguredModel()
|
||||
|
||||
// Test with the configured model
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
model: configuredModel,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
max_tokens: 5,
|
||||
})
|
||||
|
|
@ -93,11 +174,25 @@ export async function testOpenAIConnection(): Promise<{
|
|||
return {
|
||||
success: true,
|
||||
model: response.model,
|
||||
modelTested: configuredModel,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const configuredModel = await getConfiguredModel()
|
||||
|
||||
// Check for model-specific errors
|
||||
if (message.includes('does not exist') || message.includes('model_not_found')) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
|
||||
modelTested: configuredModel,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: message,
|
||||
modelTested: configuredModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,48 @@ import { Prisma } from '@prisma/client'
|
|||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { executeFilteringRules } from '../services/ai-filtering'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
|
||||
|
||||
export const filteringRouter = router({
|
||||
/**
|
||||
* Check if AI is configured and ready for filtering
|
||||
*/
|
||||
checkAIStatus: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if round has AI rules
|
||||
const aiRules = await ctx.prisma.filteringRule.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
ruleType: 'AI_SCREENING',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (aiRules === 0) {
|
||||
return { hasAIRules: false, configured: true, error: null }
|
||||
}
|
||||
|
||||
// Check if OpenAI is configured
|
||||
const configured = await isOpenAIConfigured()
|
||||
if (!configured) {
|
||||
return {
|
||||
hasAIRules: true,
|
||||
configured: false,
|
||||
error: 'OpenAI API key not configured',
|
||||
}
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
const testResult = await testOpenAIConnection()
|
||||
return {
|
||||
hasAIRules: true,
|
||||
configured: testResult.success,
|
||||
error: testResult.error || null,
|
||||
model: testResult.modelTested,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get filtering rules for a round
|
||||
*/
|
||||
|
|
@ -146,6 +186,30 @@ export const filteringRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Check if any AI_SCREENING rules exist
|
||||
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
|
||||
|
||||
if (hasAIRules) {
|
||||
// Verify OpenAI is configured before proceeding
|
||||
const aiConfigured = await isOpenAIConfigured()
|
||||
if (!aiConfigured) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message:
|
||||
'AI screening rules require OpenAI to be configured. Go to Settings → AI to configure your API key.',
|
||||
})
|
||||
}
|
||||
|
||||
// Also verify the model works
|
||||
const testResult = await testOpenAIConnection()
|
||||
if (!testResult.success) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `AI configuration error: ${testResult.error}. Go to Settings → AI to fix.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get projects in this round
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ export async function executeAIScreening(
|
|||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
// No OpenAI configured — flag all for manual review
|
||||
console.warn('[AI Filtering] OpenAI client not available - API key may not be configured')
|
||||
for (const p of projects) {
|
||||
results.set(p.id, {
|
||||
meetsCriteria: false,
|
||||
|
|
@ -287,6 +288,7 @@ export async function executeAIScreening(
|
|||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects`)
|
||||
|
||||
// Anonymize project data — use numeric IDs
|
||||
const anonymizedProjects = projects.map((p, i) => ({
|
||||
|
|
@ -319,6 +321,8 @@ ${JSON.stringify(
|
|||
|
||||
Return your evaluation as JSON.`
|
||||
|
||||
console.log(`[AI Filtering] Processing batch ${Math.floor(i / batchSize) + 1}, ${batch.length} projects`)
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
|
|
@ -330,6 +334,8 @@ Return your evaluation as JSON.`
|
|||
max_tokens: 4000,
|
||||
})
|
||||
|
||||
console.log(`[AI Filtering] Batch completed, usage: ${response.usage?.total_tokens} tokens`)
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (content) {
|
||||
try {
|
||||
|
|
@ -344,6 +350,8 @@ Return your evaluation as JSON.`
|
|||
}>
|
||||
}
|
||||
|
||||
console.log(`[AI Filtering] Parsed ${parsed.projects?.length || 0} results from response`)
|
||||
|
||||
for (const result of parsed.projects) {
|
||||
const anon = batch.find((b) => b.project_id === result.project_id)
|
||||
if (anon) {
|
||||
|
|
@ -356,8 +364,10 @@ Return your evaluation as JSON.`
|
|||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (parseError) {
|
||||
// Parse error — flag batch for manual review
|
||||
console.error('[AI Filtering] JSON parse error:', parseError)
|
||||
console.error('[AI Filtering] Raw response content:', content.slice(0, 500))
|
||||
for (const item of batch) {
|
||||
results.set(item.real_id, {
|
||||
meetsCriteria: false,
|
||||
|
|
@ -368,15 +378,45 @@ Return your evaluation as JSON.`
|
|||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('[AI Filtering] Empty response content from API')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OpenAI error — flag all for manual review
|
||||
} catch (error) {
|
||||
// OpenAI error — flag all for manual review with specific error info
|
||||
console.error('[AI Filtering] OpenAI API error:', error)
|
||||
|
||||
// Extract meaningful error message
|
||||
let errorType = 'unknown_error'
|
||||
let errorDetail = 'Unknown error occurred'
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase()
|
||||
if (message.includes('rate_limit') || message.includes('rate limit')) {
|
||||
errorType = 'rate_limit'
|
||||
errorDetail = 'OpenAI rate limit exceeded. Try again in a few minutes.'
|
||||
} else if (message.includes('model') && (message.includes('not found') || message.includes('does not exist'))) {
|
||||
errorType = 'model_not_found'
|
||||
errorDetail = 'The configured AI model is not available. Check Settings → AI.'
|
||||
} else if (message.includes('insufficient_quota') || message.includes('quota')) {
|
||||
errorType = 'quota_exceeded'
|
||||
errorDetail = 'OpenAI API quota exceeded. Check your billing settings.'
|
||||
} else if (message.includes('invalid_api_key') || message.includes('unauthorized')) {
|
||||
errorType = 'invalid_api_key'
|
||||
errorDetail = 'Invalid OpenAI API key. Check Settings → AI.'
|
||||
} else if (message.includes('context_length') || message.includes('token')) {
|
||||
errorType = 'context_length'
|
||||
errorDetail = 'Request too large. Try with fewer projects or shorter descriptions.'
|
||||
} else {
|
||||
errorDetail = error.message
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of projects) {
|
||||
results.set(p.id, {
|
||||
meetsCriteria: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI screening error — flagged for manual review',
|
||||
reasoning: `AI screening error (${errorType}): ${errorDetail}`,
|
||||
qualityScore: 5,
|
||||
spamRisk: false,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue