Add special awards management features and fix voting/assignment issues
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:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -30,7 +31,28 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { Input } from '@/components/ui/input'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { toast } from 'sonner'
|
||||
@@ -48,6 +70,9 @@ import {
|
||||
Play,
|
||||
Lock,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -64,6 +89,7 @@ export default function AwardDetailPage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const { data: award, isLoading, refetch } =
|
||||
trpc.specialAward.get.useQuery({ id: awardId })
|
||||
@@ -71,7 +97,7 @@ export default function AwardDetailPage({
|
||||
trpc.specialAward.listEligible.useQuery({
|
||||
awardId,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
perPage: 500,
|
||||
})
|
||||
const { data: jurors, refetch: refetchJurors } =
|
||||
trpc.specialAward.listJurors.useQuery({ awardId })
|
||||
@@ -79,15 +105,24 @@ export default function AwardDetailPage({
|
||||
trpc.specialAward.getVoteResults.useQuery({ awardId })
|
||||
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 runEligibility = trpc.specialAward.runEligibility.useMutation()
|
||||
const setEligibility = trpc.specialAward.setEligibility.useMutation()
|
||||
const addJuror = trpc.specialAward.addJuror.useMutation()
|
||||
const removeJuror = trpc.specialAward.removeJuror.useMutation()
|
||||
const setWinner = trpc.specialAward.setWinner.useMutation()
|
||||
const deleteAward = trpc.specialAward.delete.useMutation()
|
||||
|
||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||
|
||||
const handleStatusChange = async (
|
||||
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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -205,6 +287,11 @@ export default function AwardDetailPage({
|
||||
<span className="text-muted-foreground">
|
||||
{award.program.year} Edition
|
||||
</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 className="flex gap-2">
|
||||
@@ -243,6 +330,37 @@ export default function AwardDetailPage({
|
||||
Close Voting
|
||||
</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>
|
||||
|
||||
@@ -312,6 +430,101 @@ export default function AwardDetailPage({
|
||||
Load All Projects
|
||||
</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>
|
||||
{!award.useAiEligibility && (
|
||||
@@ -328,12 +541,14 @@ export default function AwardDetailPage({
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Eligible</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eligibilityData.eligibilities.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableRow key={e.id} className={!e.eligible ? 'opacity-50' : ''}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
@@ -352,6 +567,11 @@ export default function AwardDetailPage({
|
||||
)}
|
||||
</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>
|
||||
<Switch
|
||||
checked={e.eligible}
|
||||
@@ -360,6 +580,16 @@ export default function AwardDetailPage({
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -371,7 +601,7 @@ export default function AwardDetailPage({
|
||||
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No eligibility data</p>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user