Add special awards management features and fix voting/assignment issues
Build and Push Docker Image / build (push) Successful in 10m4s
Details
Build and Push Docker Image / build (push) Successful in 10m4s
Details
Special Awards: - Add delete button with confirmation dialog to award detail page - Add voting window dates (start/end) to award edit page - Add manual project eligibility management (add/remove projects) - Show eligibility method (Auto/Manual) in eligibility table - Auto-set votingStartAt when opening voting if date is in future Assignment Suggestions: - Replace toggle with proper tabs UI (Algorithm vs AI Powered) - Persist AI suggestions when navigating away (stored in database) - Show suggestion counts on tab badges - Independent refresh/start buttons per tab Round Voting: - Auto-update votingStartAt to now when activating round if date is in future - Fixes issue where round was opened but voting dates were in future Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e01d741f01
commit
13de30775e
|
|
@ -44,6 +44,16 @@ export default function EditAwardPage({
|
||||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||||
|
const [votingStartAt, setVotingStartAt] = useState('')
|
||||||
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
|
|
||||||
|
// Helper to format date for datetime-local input
|
||||||
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
|
if (!date) return ''
|
||||||
|
const d = new Date(date)
|
||||||
|
// Format: YYYY-MM-DDTHH:mm
|
||||||
|
return d.toISOString().slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing values when award data arrives
|
// Load existing values when award data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -54,6 +64,8 @@ export default function EditAwardPage({
|
||||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||||
setUseAiEligibility(award.useAiEligibility)
|
setUseAiEligibility(award.useAiEligibility)
|
||||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||||
|
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||||
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
|
|
@ -68,6 +80,8 @@ export default function EditAwardPage({
|
||||||
useAiEligibility,
|
useAiEligibility,
|
||||||
scoringMode,
|
scoringMode,
|
||||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||||
|
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||||
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
|
|
@ -211,6 +225,45 @@ export default function EditAwardPage({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Voting Window Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Voting Window</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Set the time period during which jurors can submit their votes
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||||
|
<Input
|
||||||
|
id="votingStart"
|
||||||
|
type="datetime-local"
|
||||||
|
value={votingStartAt}
|
||||||
|
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When jurors can start voting (leave empty to set when opening voting)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||||
|
<Input
|
||||||
|
id="votingEnd"
|
||||||
|
type="datetime-local"
|
||||||
|
value={votingEndAt}
|
||||||
|
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Deadline for juror votes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { use, useState } from 'react'
|
import { use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,7 +31,28 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -48,6 +70,9 @@ import {
|
||||||
Play,
|
Play,
|
||||||
Lock,
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
|
@ -64,6 +89,7 @@ export default function AwardDetailPage({
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id: awardId } = use(params)
|
const { id: awardId } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const { data: award, isLoading, refetch } =
|
const { data: award, isLoading, refetch } =
|
||||||
trpc.specialAward.get.useQuery({ id: awardId })
|
trpc.specialAward.get.useQuery({ id: awardId })
|
||||||
|
|
@ -71,7 +97,7 @@ export default function AwardDetailPage({
|
||||||
trpc.specialAward.listEligible.useQuery({
|
trpc.specialAward.listEligible.useQuery({
|
||||||
awardId,
|
awardId,
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 50,
|
perPage: 500,
|
||||||
})
|
})
|
||||||
const { data: jurors, refetch: refetchJurors } =
|
const { data: jurors, refetch: refetchJurors } =
|
||||||
trpc.specialAward.listJurors.useQuery({ awardId })
|
trpc.specialAward.listJurors.useQuery({ awardId })
|
||||||
|
|
@ -79,15 +105,24 @@ export default function AwardDetailPage({
|
||||||
trpc.specialAward.getVoteResults.useQuery({ awardId })
|
trpc.specialAward.getVoteResults.useQuery({ awardId })
|
||||||
const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 })
|
const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 })
|
||||||
|
|
||||||
|
// Fetch all projects in the program for manual eligibility addition
|
||||||
|
const { data: allProjects } = trpc.project.list.useQuery(
|
||||||
|
{ programId: award?.programId ?? '', perPage: 500 },
|
||||||
|
{ enabled: !!award?.programId }
|
||||||
|
)
|
||||||
|
|
||||||
const updateStatus = trpc.specialAward.updateStatus.useMutation()
|
const updateStatus = trpc.specialAward.updateStatus.useMutation()
|
||||||
const runEligibility = trpc.specialAward.runEligibility.useMutation()
|
const runEligibility = trpc.specialAward.runEligibility.useMutation()
|
||||||
const setEligibility = trpc.specialAward.setEligibility.useMutation()
|
const setEligibility = trpc.specialAward.setEligibility.useMutation()
|
||||||
const addJuror = trpc.specialAward.addJuror.useMutation()
|
const addJuror = trpc.specialAward.addJuror.useMutation()
|
||||||
const removeJuror = trpc.specialAward.removeJuror.useMutation()
|
const removeJuror = trpc.specialAward.removeJuror.useMutation()
|
||||||
const setWinner = trpc.specialAward.setWinner.useMutation()
|
const setWinner = trpc.specialAward.setWinner.useMutation()
|
||||||
|
const deleteAward = trpc.specialAward.delete.useMutation()
|
||||||
|
|
||||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||||
|
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||||
|
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||||
|
|
||||||
const handleStatusChange = async (
|
const handleStatusChange = async (
|
||||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||||
|
|
@ -165,6 +200,53 @@ export default function AwardDetailPage({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteAward = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAward.mutateAsync({ id: awardId })
|
||||||
|
toast.success('Award deleted')
|
||||||
|
router.push('/admin/awards')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to delete award'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddProjectToEligibility = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
await setEligibility.mutateAsync({ awardId, projectId, eligible: true })
|
||||||
|
toast.success('Project added to eligibility list')
|
||||||
|
refetchEligibility()
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to add project')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFromEligibility = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
await setEligibility.mutateAsync({ awardId, projectId, eligible: false })
|
||||||
|
toast.success('Project removed from eligibility')
|
||||||
|
refetchEligibility()
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove project')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects that aren't already in the eligibility list
|
||||||
|
const eligibleProjectIds = new Set(
|
||||||
|
eligibilityData?.eligibilities.map((e) => e.projectId) || []
|
||||||
|
)
|
||||||
|
const availableProjects = allProjects?.projects.filter(
|
||||||
|
(p) => !eligibleProjectIds.has(p.id)
|
||||||
|
) || []
|
||||||
|
const filteredAvailableProjects = availableProjects.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title.toLowerCase().includes(projectSearchQuery.toLowerCase()) ||
|
||||||
|
p.teamName?.toLowerCase().includes(projectSearchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -205,6 +287,11 @@ export default function AwardDetailPage({
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{award.program.year} Edition
|
{award.program.year} Edition
|
||||||
</span>
|
</span>
|
||||||
|
{award.votingStartAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Voting: {new Date(award.votingStartAt).toLocaleDateString()} - {award.votingEndAt ? new Date(award.votingEndAt).toLocaleDateString() : 'No end date'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -243,6 +330,37 @@ export default function AwardDetailPage({
|
||||||
Close Voting
|
Close Voting
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Award?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{award.name}" and all associated
|
||||||
|
eligibility data, juror assignments, and votes. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteAward}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteAward.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Delete Award
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -312,6 +430,101 @@ export default function AwardDetailPage({
|
||||||
Load All Projects
|
Load All Projects
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Dialog open={addProjectDialogOpen} onOpenChange={setAddProjectDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Project
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Project to Eligibility List</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manually add a project that wasn't included by AI or rule-based filtering
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={projectSearchQuery}
|
||||||
|
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto rounded-md border">
|
||||||
|
{filteredAvailableProjects.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredAvailableProjects.slice(0, 50).map((project) => (
|
||||||
|
<TableRow key={project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{project.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.teamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{project.competitionCategory ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.competitionCategory.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{project.country || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleAddProjectToEligibility(project.id)
|
||||||
|
}}
|
||||||
|
disabled={setEligibility.isPending}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{projectSearchQuery
|
||||||
|
? 'No projects match your search'
|
||||||
|
: 'All projects are already in the eligibility list'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredAvailableProjects.length > 50 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Showing first 50 of {filteredAvailableProjects.length} projects. Use search to filter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddProjectDialogOpen(false)}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!award.useAiEligibility && (
|
{!award.useAiEligibility && (
|
||||||
|
|
@ -328,12 +541,14 @@ export default function AwardDetailPage({
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Country</TableHead>
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead>Method</TableHead>
|
||||||
<TableHead>Eligible</TableHead>
|
<TableHead>Eligible</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{eligibilityData.eligibilities.map((e) => (
|
{eligibilityData.eligibilities.map((e) => (
|
||||||
<TableRow key={e.id}>
|
<TableRow key={e.id} className={!e.eligible ? 'opacity-50' : ''}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{e.project.title}</p>
|
<p className="font-medium">{e.project.title}</p>
|
||||||
|
|
@ -352,6 +567,11 @@ export default function AwardDetailPage({
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{e.project.country || '-'}</TableCell>
|
<TableCell>{e.project.country || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
|
||||||
|
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
checked={e.eligible}
|
checked={e.eligible}
|
||||||
|
|
@ -360,6 +580,16 @@ export default function AwardDetailPage({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveFromEligibility(e.projectId)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -371,7 +601,7 @@ export default function AwardDetailPage({
|
||||||
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||||
<p className="mt-2 font-medium">No eligibility data</p>
|
<p className="mt-2 font-medium">No eligibility data</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Run AI eligibility to evaluate projects against criteria
|
Run AI eligibility to evaluate projects or manually add projects
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -64,9 +70,125 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Cpu,
|
||||||
|
Brain,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Suggestion type for both algorithm and AI suggestions
|
||||||
|
interface Suggestion {
|
||||||
|
userId: string
|
||||||
|
jurorName: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
score: number
|
||||||
|
reasoning: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable table component for displaying suggestions
|
||||||
|
function SuggestionsTable({
|
||||||
|
suggestions,
|
||||||
|
selectedSuggestions,
|
||||||
|
onToggle,
|
||||||
|
onSelectAll,
|
||||||
|
onApply,
|
||||||
|
isApplying,
|
||||||
|
}: {
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
selectedSuggestions: Set<string>
|
||||||
|
onToggle: (key: string) => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onApply: () => void
|
||||||
|
isApplying: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedSuggestions.size === suggestions.length && suggestions.length > 0}
|
||||||
|
onCheckedChange={onSelectAll}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedSuggestions.size} of {suggestions.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={selectedSuggestions.size === 0 || isApplying}
|
||||||
|
>
|
||||||
|
{isApplying ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Apply Selected ({selectedSuggestions.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12"></TableHead>
|
||||||
|
<TableHead>Juror</TableHead>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Reasoning</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{suggestions.map((suggestion) => {
|
||||||
|
const key = `${suggestion.userId}-${suggestion.projectId}`
|
||||||
|
const isSelected = selectedSuggestions.has(key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={key}
|
||||||
|
className={isSelected ? 'bg-muted/50' : ''}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => onToggle(key)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{suggestion.jurorName}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{suggestion.projectTitle}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
suggestion.score >= 60
|
||||||
|
? 'default'
|
||||||
|
: suggestion.score >= 40
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{suggestion.score.toFixed(0)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
<ul className="text-xs text-muted-foreground">
|
||||||
|
{suggestion.reasoning.map((r, i) => (
|
||||||
|
<li key={i}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +198,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
const [manualDialogOpen, setManualDialogOpen] = useState(false)
|
const [manualDialogOpen, setManualDialogOpen] = useState(false)
|
||||||
const [selectedJuror, setSelectedJuror] = useState<string>('')
|
const [selectedJuror, setSelectedJuror] = useState<string>('')
|
||||||
const [selectedProject, setSelectedProject] = useState<string>('')
|
const [selectedProject, setSelectedProject] = useState<string>('')
|
||||||
const [useAI, setUseAI] = useState(false)
|
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
|
|
@ -84,10 +206,9 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
|
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
|
||||||
const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery()
|
const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery()
|
||||||
|
|
||||||
// AI Assignment job queries
|
// Always fetch latest AI job to check for existing results
|
||||||
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
|
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
|
||||||
{ roundId },
|
{ roundId }
|
||||||
{ enabled: useAI }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poll for job status when there's an active job
|
// Poll for job status when there's an active job
|
||||||
|
|
@ -107,21 +228,23 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
// Algorithmic suggestions (default)
|
// Check if there's a completed AI job with stored suggestions
|
||||||
|
const hasStoredAISuggestions = latestJob?.status === 'COMPLETED' && latestJob?.suggestionsCount > 0
|
||||||
|
|
||||||
|
// Algorithmic suggestions (always fetch for algorithm tab)
|
||||||
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
|
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: !!round && !useAI }
|
{ enabled: !!round }
|
||||||
)
|
)
|
||||||
|
|
||||||
// AI-powered suggestions (expensive - only used after job completes)
|
// AI-powered suggestions - fetch if there are stored results OR if AI tab is active
|
||||||
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
|
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
|
||||||
{ roundId, useAI: true },
|
{ roundId, useAI: true },
|
||||||
{
|
{
|
||||||
enabled: !!round && useAI && !isAIJobRunning,
|
enabled: !!round && (hasStoredAISuggestions || activeTab === 'ai') && !isAIJobRunning,
|
||||||
staleTime: Infinity, // Never consider stale (only refetch manually)
|
staleTime: Infinity, // Never consider stale (only refetch manually)
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -129,6 +252,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||||
setActiveJobId(latestJob.id)
|
setActiveJobId(latestJob.id)
|
||||||
|
setActiveTab('ai') // Switch to AI tab if a job is running
|
||||||
}
|
}
|
||||||
}, [latestJob])
|
}, [latestJob])
|
||||||
|
|
||||||
|
|
@ -150,6 +274,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
const handleStartAIJob = async () => {
|
const handleStartAIJob = async () => {
|
||||||
try {
|
try {
|
||||||
|
setActiveTab('ai') // Switch to AI tab when starting
|
||||||
const result = await startAIJob.mutateAsync({ roundId })
|
const result = await startAIJob.mutateAsync({ roundId })
|
||||||
setActiveJobId(result.jobId)
|
setActiveJobId(result.jobId)
|
||||||
toast.info('AI Assignment job started. Progress will update automatically.')
|
toast.info('AI Assignment job started. Progress will update automatically.')
|
||||||
|
|
@ -170,10 +295,9 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
reasoning: [s.reasoning],
|
reasoning: [s.reasoning],
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
// Use the appropriate suggestions based on mode
|
// Use the appropriate suggestions based on active tab
|
||||||
const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? [])
|
const currentSuggestions = activeTab === 'ai' ? aiSuggestions : (algorithmicSuggestions ?? [])
|
||||||
const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
|
const isLoadingCurrentSuggestions = activeTab === 'ai' ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
|
||||||
const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic
|
|
||||||
|
|
||||||
// Get available jurors for manual assignment
|
// Get available jurors for manual assignment
|
||||||
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
|
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
|
||||||
|
|
@ -264,21 +388,21 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectAllSuggestions = () => {
|
const handleSelectAllSuggestions = () => {
|
||||||
if (suggestions) {
|
if (currentSuggestions) {
|
||||||
if (selectedSuggestions.size === suggestions.length) {
|
if (selectedSuggestions.size === currentSuggestions.length) {
|
||||||
setSelectedSuggestions(new Set())
|
setSelectedSuggestions(new Set())
|
||||||
} else {
|
} else {
|
||||||
setSelectedSuggestions(
|
setSelectedSuggestions(
|
||||||
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`))
|
new Set(currentSuggestions.map((s) => `${s.userId}-${s.projectId}`))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplySelected = async () => {
|
const handleApplySelected = async () => {
|
||||||
if (!suggestions) return
|
if (!currentSuggestions) return
|
||||||
|
|
||||||
const selected = suggestions.filter((s) =>
|
const selected = currentSuggestions.filter((s) =>
|
||||||
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
|
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -522,212 +646,192 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Smart Suggestions */}
|
{/* Smart Suggestions with Tabs */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<div>
|
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
Smart Assignment Suggestions
|
||||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
</CardTitle>
|
||||||
{useAI ? 'AI Assignment Suggestions' : 'Smart Assignment Suggestions'}
|
<CardDescription>
|
||||||
</CardTitle>
|
Get assignment recommendations using algorithmic matching or AI-powered analysis
|
||||||
<CardDescription>
|
</CardDescription>
|
||||||
{useAI
|
|
||||||
? 'GPT-powered recommendations analyzing project descriptions and judge expertise'
|
|
||||||
: 'Algorithmic recommendations based on tag matching and workload balance'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant={useAI ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (!useAI) {
|
|
||||||
setUseAI(true)
|
|
||||||
setSelectedSuggestions(new Set())
|
|
||||||
// Start AI job if no suggestions yet
|
|
||||||
if (!aiSuggestionsRaw?.suggestions?.length && !isAIJobRunning) {
|
|
||||||
handleStartAIJob()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setUseAI(false)
|
|
||||||
setSelectedSuggestions(new Set())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={(!isAIAvailable && !useAI) || isAIJobRunning}
|
|
||||||
title={!isAIAvailable ? 'OpenAI API key not configured' : undefined}
|
|
||||||
>
|
|
||||||
<Sparkles className={`mr-2 h-4 w-4 ${useAI ? 'text-amber-300' : ''}`} />
|
|
||||||
{useAI ? 'AI Mode' : 'Use AI'}
|
|
||||||
</Button>
|
|
||||||
{useAI && !isAIJobRunning && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleStartAIJob}
|
|
||||||
disabled={startAIJob.isPending}
|
|
||||||
title="Run AI analysis again"
|
|
||||||
>
|
|
||||||
{startAIJob.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Re-analyze
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!useAI && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetchSuggestions()}
|
|
||||||
disabled={loadingSuggestions}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* AI Job Progress Indicator */}
|
<Tabs value={activeTab} onValueChange={(v) => {
|
||||||
{isAIJobRunning && jobStatus && (
|
setActiveTab(v as 'algorithm' | 'ai')
|
||||||
<div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
setSelectedSuggestions(new Set())
|
||||||
<div className="space-y-3">
|
}}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
<TabsList>
|
||||||
<div className="flex-1">
|
<TabsTrigger value="algorithm" className="gap-2">
|
||||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
<Cpu className="h-4 w-4" />
|
||||||
AI Assignment Analysis in Progress
|
Algorithm
|
||||||
</p>
|
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
<Badge variant="secondary" className="ml-1 text-xs">
|
||||||
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
|
{algorithmicSuggestions.length}
|
||||||
</p>
|
</Badge>
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="border-blue-300 text-blue-700">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-blue-700 dark:text-blue-300">
|
|
||||||
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
|
||||||
{aiJobProgressPercent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={aiJobProgressPercent} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAIJobRunning ? (
|
|
||||||
// Don't show suggestions section while AI job is running - progress is shown above
|
|
||||||
null
|
|
||||||
) : loadingSuggestions ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : suggestions && suggestions.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedSuggestions.size === suggestions.length}
|
|
||||||
onCheckedChange={handleSelectAllSuggestions}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{selectedSuggestions.size} of {suggestions.length} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleApplySelected}
|
|
||||||
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
|
|
||||||
>
|
|
||||||
{applySuggestions.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
)}
|
||||||
Apply Selected ({selectedSuggestions.size})
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
|
||||||
|
<Brain className="h-4 w-4" />
|
||||||
|
AI Powered
|
||||||
|
{aiSuggestions.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs">
|
||||||
|
{aiSuggestions.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isAIJobRunning && (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin ml-1" />
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Tab-specific actions */}
|
||||||
|
{activeTab === 'algorithm' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetchAlgorithmic()}
|
||||||
|
disabled={loadingAlgorithmic}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loadingAlgorithmic ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isAIJobRunning && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleStartAIJob}
|
||||||
|
disabled={startAIJob.isPending || !isAIAvailable}
|
||||||
|
title={!isAIAvailable ? 'OpenAI API key not configured' : hasStoredAISuggestions ? 'Run AI analysis again' : 'Start AI analysis'}
|
||||||
|
>
|
||||||
|
{startAIJob.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{hasStoredAISuggestions ? 'Re-analyze' : 'Start Analysis'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Algorithm Tab Content */}
|
||||||
|
<TabsContent value="algorithm" className="mt-0">
|
||||||
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
|
Algorithmic recommendations based on tag matching and workload balance
|
||||||
|
</div>
|
||||||
|
{loadingAlgorithmic ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : algorithmicSuggestions && algorithmicSuggestions.length > 0 ? (
|
||||||
|
<SuggestionsTable
|
||||||
|
suggestions={algorithmicSuggestions}
|
||||||
|
selectedSuggestions={selectedSuggestions}
|
||||||
|
onToggle={handleToggleSuggestion}
|
||||||
|
onSelectAll={handleSelectAllSuggestions}
|
||||||
|
onApply={handleApplySelected}
|
||||||
|
isApplying={applySuggestions.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
|
||||||
|
<p className="mt-2 font-medium">All projects are covered!</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No additional assignments are needed at this time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* AI Tab Content */}
|
||||||
|
<TabsContent value="ai" className="mt-0">
|
||||||
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
|
GPT-powered recommendations analyzing project descriptions and judge expertise
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
|
{/* AI Job Progress Indicator */}
|
||||||
<Table>
|
{isAIJobRunning && jobStatus && (
|
||||||
<TableHeader>
|
<div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||||
<TableRow>
|
<div className="space-y-3">
|
||||||
<TableHead className="w-12"></TableHead>
|
<div className="flex items-center gap-3">
|
||||||
<TableHead>Juror</TableHead>
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||||
<TableHead>Project</TableHead>
|
<div className="flex-1">
|
||||||
<TableHead>Score</TableHead>
|
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
<TableHead>Reasoning</TableHead>
|
AI Assignment Analysis in Progress
|
||||||
</TableRow>
|
</p>
|
||||||
</TableHeader>
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
<TableBody>
|
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
|
||||||
{suggestions.map((suggestion) => {
|
</p>
|
||||||
const key = `${suggestion.userId}-${suggestion.projectId}`
|
</div>
|
||||||
const isSelected = selectedSuggestions.has(key)
|
<Badge variant="outline" className="border-blue-300 text-blue-700">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-blue-700 dark:text-blue-300">
|
||||||
|
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
{aiJobProgressPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={aiJobProgressPercent} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{isAIJobRunning ? null : loadingAI ? (
|
||||||
<TableRow
|
<div className="flex items-center justify-center py-8">
|
||||||
key={key}
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
className={isSelected ? 'bg-muted/50' : ''}
|
</div>
|
||||||
>
|
) : aiSuggestions.length > 0 ? (
|
||||||
<TableCell>
|
<SuggestionsTable
|
||||||
<Checkbox
|
suggestions={aiSuggestions}
|
||||||
checked={isSelected}
|
selectedSuggestions={selectedSuggestions}
|
||||||
onCheckedChange={() => handleToggleSuggestion(key)}
|
onToggle={handleToggleSuggestion}
|
||||||
/>
|
onSelectAll={handleSelectAllSuggestions}
|
||||||
</TableCell>
|
onApply={handleApplySelected}
|
||||||
<TableCell className="font-medium">
|
isApplying={applySuggestions.isPending}
|
||||||
{suggestion.jurorName}
|
/>
|
||||||
</TableCell>
|
) : !hasStoredAISuggestions ? (
|
||||||
<TableCell>
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
{suggestion.projectTitle}
|
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||||
</TableCell>
|
<p className="mt-2 font-medium">No AI analysis yet</p>
|
||||||
<TableCell>
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
<Badge
|
Click "Start Analysis" to generate AI-powered suggestions
|
||||||
variant={
|
</p>
|
||||||
suggestion.score >= 60
|
<Button
|
||||||
? 'default'
|
onClick={handleStartAIJob}
|
||||||
: suggestion.score >= 40
|
disabled={startAIJob.isPending || !isAIAvailable}
|
||||||
? 'secondary'
|
>
|
||||||
: 'outline'
|
{startAIJob.isPending ? (
|
||||||
}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
>
|
) : (
|
||||||
{suggestion.score.toFixed(0)}
|
<Brain className="mr-2 h-4 w-4" />
|
||||||
</Badge>
|
)}
|
||||||
</TableCell>
|
Start AI Analysis
|
||||||
<TableCell className="max-w-xs">
|
</Button>
|
||||||
<ul className="text-xs text-muted-foreground">
|
</div>
|
||||||
{suggestion.reasoning.map((r, i) => (
|
) : (
|
||||||
<li key={i}>{r}</li>
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
))}
|
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
|
||||||
</ul>
|
<p className="mt-2 font-medium">All projects are covered!</p>
|
||||||
</TableCell>
|
<p className="text-sm text-muted-foreground">
|
||||||
</TableRow>
|
No additional assignments are needed at this time
|
||||||
)
|
</p>
|
||||||
})}
|
</div>
|
||||||
</TableBody>
|
)}
|
||||||
</Table>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
|
|
||||||
<p className="mt-2 font-medium">All projects are covered!</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No additional assignments are needed at this time
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,15 +251,31 @@ export const roundRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Get previous status for audit
|
// Get previous status and voting dates for audit
|
||||||
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
|
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
select: { status: true },
|
select: { status: true, votingStartAt: true, votingEndAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// When activating a round, if votingStartAt is in the future, update it to now
|
||||||
|
// This ensures voting actually starts when the admin opens the round
|
||||||
|
let votingStartAtUpdated = false
|
||||||
|
const updateData: Parameters<typeof ctx.prisma.round.update>[0]['data'] = {
|
||||||
|
status: input.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') {
|
||||||
|
if (previousRound.votingStartAt && previousRound.votingStartAt > now) {
|
||||||
|
updateData.votingStartAt = now
|
||||||
|
votingStartAtUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const round = await ctx.prisma.round.update({
|
const round = await ctx.prisma.round.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: input.status },
|
data: updateData,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map status to specific action name
|
// Map status to specific action name
|
||||||
|
|
@ -277,7 +293,15 @@ export const roundRouter = router({
|
||||||
action,
|
action,
|
||||||
entityType: 'Round',
|
entityType: 'Round',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
detailsJson: { status: input.status, previousStatus: previousRound.status },
|
detailsJson: {
|
||||||
|
status: input.status,
|
||||||
|
previousStatus: previousRound.status,
|
||||||
|
...(votingStartAtUpdated && {
|
||||||
|
votingStartAtUpdated: true,
|
||||||
|
previousVotingStartAt: previousRound.votingStartAt,
|
||||||
|
newVotingStartAt: now,
|
||||||
|
}),
|
||||||
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -195,12 +195,28 @@ export const specialAwardRouter = router({
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const current = await ctx.prisma.specialAward.findUniqueOrThrow({
|
const current = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
select: { status: true },
|
select: { status: true, votingStartAt: true, votingEndAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// When opening voting, auto-set votingStartAt to now if it's in the future or not set
|
||||||
|
let votingStartAtUpdated = false
|
||||||
|
const updateData: Parameters<typeof ctx.prisma.specialAward.update>[0]['data'] = {
|
||||||
|
status: input.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') {
|
||||||
|
// If no voting start date, or if it's in the future, set it to now
|
||||||
|
if (!current.votingStartAt || current.votingStartAt > now) {
|
||||||
|
updateData.votingStartAt = now
|
||||||
|
votingStartAtUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const award = await ctx.prisma.specialAward.update({
|
const award = await ctx.prisma.specialAward.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: input.status },
|
data: updateData,
|
||||||
})
|
})
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|
@ -211,6 +227,11 @@ export const specialAwardRouter = router({
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
previousStatus: current.status,
|
previousStatus: current.status,
|
||||||
newStatus: input.status,
|
newStatus: input.status,
|
||||||
|
...(votingStartAtUpdated && {
|
||||||
|
votingStartAtUpdated: true,
|
||||||
|
previousVotingStartAt: current.votingStartAt,
|
||||||
|
newVotingStartAt: now,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue