diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx index 0bfc5b7..079644a 100644 --- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx @@ -44,6 +44,16 @@ export default function EditAwardPage({ const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER') const [useAiEligibility, setUseAiEligibility] = useState(true) 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 useEffect(() => { @@ -54,6 +64,8 @@ export default function EditAwardPage({ setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED') setUseAiEligibility(award.useAiEligibility) setMaxRankedPicks(String(award.maxRankedPicks || 3)) + setVotingStartAt(formatDateForInput(award.votingStartAt)) + setVotingEndAt(formatDateForInput(award.votingEndAt)) } }, [award]) @@ -68,6 +80,8 @@ export default function EditAwardPage({ useAiEligibility, scoringMode, maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined, + votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined, + votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined, }) toast.success('Award updated') router.push(`/admin/awards/${awardId}`) @@ -211,6 +225,45 @@ export default function EditAwardPage({ + {/* Voting Window Card */} + + + Voting Window + + Set the time period during which jurors can submit their votes + + + +
+
+ + setVotingStartAt(e.target.value)} + /> +

+ When jurors can start voting (leave empty to set when opening voting) +

+
+ +
+ + setVotingEndAt(e.target.value)} + /> +

+ Deadline for juror votes +

+
+
+
+
+
@@ -243,6 +330,37 @@ export default function AwardDetailPage({ Close Voting )} + + + + + + + Delete Award? + + This will permanently delete "{award.name}" and all associated + eligibility data, juror assignments, and votes. This action cannot be undone. + + + + Cancel + + {deleteAward.isPending ? ( + + ) : ( + + )} + Delete Award + + + +
@@ -312,6 +430,101 @@ export default function AwardDetailPage({ Load All Projects )} + + + + + + + Add Project to Eligibility List + + Manually add a project that wasn't included by AI or rule-based filtering + + +
+
+ + setProjectSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ {filteredAvailableProjects.length > 0 ? ( + + + + Project + Category + Country + Action + + + + {filteredAvailableProjects.slice(0, 50).map((project) => ( + + +
+

{project.title}

+

+ {project.teamName} +

+
+
+ + {project.competitionCategory ? ( + + {project.competitionCategory.replace('_', ' ')} + + ) : ( + '-' + )} + + {project.country || '-'} + + + +
+ ))} +
+
+ ) : ( +
+

+ {projectSearchQuery + ? 'No projects match your search' + : 'All projects are already in the eligibility list'} +

+
+ )} +
+ {filteredAvailableProjects.length > 50 && ( +

+ Showing first 50 of {filteredAvailableProjects.length} projects. Use search to filter. +

+ )} +
+ + + +
+
{!award.useAiEligibility && ( @@ -328,12 +541,14 @@ export default function AwardDetailPage({ Project Category Country + Method Eligible + Actions {eligibilityData.eligibilities.map((e) => ( - +

{e.project.title}

@@ -352,6 +567,11 @@ export default function AwardDetailPage({ )} {e.project.country || '-'} + + + {e.method === 'MANUAL' ? 'Manual' : 'Auto'} + + + + + ))} @@ -371,7 +601,7 @@ export default function AwardDetailPage({

No eligibility data

- Run AI eligibility to evaluate projects against criteria + Run AI eligibility to evaluate projects or manually add projects

diff --git a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx index 0987ad9..8826beb 100644 --- a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx @@ -16,6 +16,12 @@ import { Skeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' import { Table, TableBody, @@ -64,9 +70,125 @@ import { Trash2, RefreshCw, UserPlus, + Cpu, + Brain, } from 'lucide-react' 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 + onToggle: (key: string) => void + onSelectAll: () => void + onApply: () => void + isApplying: boolean +}) { + return ( +
+
+
+ 0} + onCheckedChange={onSelectAll} + /> + + {selectedSuggestions.size} of {suggestions.length} selected + +
+ +
+ +
+ + + + + Juror + Project + Score + Reasoning + + + + {suggestions.map((suggestion) => { + const key = `${suggestion.userId}-${suggestion.projectId}` + const isSelected = selectedSuggestions.has(key) + + return ( + + + onToggle(key)} + /> + + + {suggestion.jurorName} + + + {suggestion.projectTitle} + + + = 60 + ? 'default' + : suggestion.score >= 40 + ? 'secondary' + : 'outline' + } + > + {suggestion.score.toFixed(0)} + + + +
    + {suggestion.reasoning.map((r, i) => ( +
  • {r}
  • + ))} +
+
+
+ ) + })} +
+
+
+
+ ) +} + interface PageProps { params: Promise<{ id: string }> } @@ -76,7 +198,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { const [manualDialogOpen, setManualDialogOpen] = useState(false) const [selectedJuror, setSelectedJuror] = useState('') const [selectedProject, setSelectedProject] = useState('') - const [useAI, setUseAI] = useState(false) + const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm') const [activeJobId, setActiveJobId] = useState(null) 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: 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( - { roundId }, - { enabled: useAI } + { roundId } ) // 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) : 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( { 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( { roundId, useAI: true }, { - enabled: !!round && useAI && !isAIJobRunning, + enabled: !!round && (hasStoredAISuggestions || activeTab === 'ai') && !isAIJobRunning, staleTime: Infinity, // Never consider stale (only refetch manually) refetchOnWindowFocus: false, refetchOnReconnect: false, - refetchOnMount: false, } ) @@ -129,6 +252,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { useEffect(() => { if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) { setActiveJobId(latestJob.id) + setActiveTab('ai') // Switch to AI tab if a job is running } }, [latestJob]) @@ -150,6 +274,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { const handleStartAIJob = async () => { try { + setActiveTab('ai') // Switch to AI tab when starting const result = await startAIJob.mutateAsync({ roundId }) setActiveJobId(result.jobId) toast.info('AI Assignment job started. Progress will update automatically.') @@ -170,10 +295,9 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { reasoning: [s.reasoning], })) ?? [] - // Use the appropriate suggestions based on mode - const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? []) - const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic - const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic + // Use the appropriate suggestions based on active tab + const currentSuggestions = activeTab === 'ai' ? aiSuggestions : (algorithmicSuggestions ?? []) + const isLoadingCurrentSuggestions = activeTab === 'ai' ? (loadingAI || isAIJobRunning) : loadingAlgorithmic // Get available jurors for manual assignment const { data: availableJurors } = trpc.user.getJuryMembers.useQuery( @@ -264,21 +388,21 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { } const handleSelectAllSuggestions = () => { - if (suggestions) { - if (selectedSuggestions.size === suggestions.length) { + if (currentSuggestions) { + if (selectedSuggestions.size === currentSuggestions.length) { setSelectedSuggestions(new Set()) } else { setSelectedSuggestions( - new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`)) + new Set(currentSuggestions.map((s) => `${s.userId}-${s.projectId}`)) ) } } } 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}`) ) @@ -522,212 +646,192 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { )} - {/* Smart Suggestions */} + {/* Smart Suggestions with Tabs */} -
-
- - - {useAI ? 'AI Assignment Suggestions' : 'Smart Assignment Suggestions'} - - - {useAI - ? 'GPT-powered recommendations analyzing project descriptions and judge expertise' - : 'Algorithmic recommendations based on tag matching and workload balance'} - -
-
- - {useAI && !isAIJobRunning && ( - - )} - {!useAI && ( - - )} -
-
+ + + Smart Assignment Suggestions + + + Get assignment recommendations using algorithmic matching or AI-powered analysis +
- {/* AI Job Progress Indicator */} - {isAIJobRunning && jobStatus && ( -
-
-
- -
-

- AI Assignment Analysis in Progress -

-

- Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches -

-
- - - Batch {jobStatus.currentBatch} of {jobStatus.totalBatches} - -
-
-
- - {jobStatus.processedCount} of {jobStatus.totalProjects} projects processed - - - {aiJobProgressPercent}% - -
- -
-
-
- )} - - {isAIJobRunning ? ( - // Don't show suggestions section while AI job is running - progress is shown above - null - ) : loadingSuggestions ? ( -
- -
- ) : suggestions && suggestions.length > 0 ? ( -
-
-
- - - {selectedSuggestions.size} of {suggestions.length} selected - -
- + ) : ( +
+ {!isAIJobRunning && ( + + )} +
+ )} +
+ + {/* Algorithm Tab Content */} + +
+ Algorithmic recommendations based on tag matching and workload balance +
+ {loadingAlgorithmic ? ( +
+ +
+ ) : algorithmicSuggestions && algorithmicSuggestions.length > 0 ? ( + + ) : ( +
+ +

All projects are covered!

+

+ No additional assignments are needed at this time +

+
+ )} +
+ + {/* AI Tab Content */} + +
+ GPT-powered recommendations analyzing project descriptions and judge expertise
-
- - - - - Juror - Project - Score - Reasoning - - - - {suggestions.map((suggestion) => { - const key = `${suggestion.userId}-${suggestion.projectId}` - const isSelected = selectedSuggestions.has(key) + {/* AI Job Progress Indicator */} + {isAIJobRunning && jobStatus && ( +
+
+
+ +
+

+ AI Assignment Analysis in Progress +

+

+ Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches +

+
+ + + Batch {jobStatus.currentBatch} of {jobStatus.totalBatches} + +
+
+
+ + {jobStatus.processedCount} of {jobStatus.totalProjects} projects processed + + + {aiJobProgressPercent}% + +
+ +
+
+
+ )} - return ( - - - handleToggleSuggestion(key)} - /> - - - {suggestion.jurorName} - - - {suggestion.projectTitle} - - - = 60 - ? 'default' - : suggestion.score >= 40 - ? 'secondary' - : 'outline' - } - > - {suggestion.score.toFixed(0)} - - - -
    - {suggestion.reasoning.map((r, i) => ( -
  • {r}
  • - ))} -
-
-
- ) - })} -
-
-
-
- ) : ( -
- -

All projects are covered!

-

- No additional assignments are needed at this time -

-
- )} + {isAIJobRunning ? null : loadingAI ? ( +
+ +
+ ) : aiSuggestions.length > 0 ? ( + + ) : !hasStoredAISuggestions ? ( +
+ +

No AI analysis yet

+

+ Click "Start Analysis" to generate AI-powered suggestions +

+ +
+ ) : ( +
+ +

All projects are covered!

+

+ No additional assignments are needed at this time +

+
+ )} + +
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 377bb68..06ad903 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -251,15 +251,31 @@ export const roundRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Get previous status for audit + // Get previous status and voting dates for audit const previousRound = await ctx.prisma.round.findUniqueOrThrow({ 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[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({ where: { id: input.id }, - data: { status: input.status }, + data: updateData, }) // Map status to specific action name @@ -277,7 +293,15 @@ export const roundRouter = router({ action, entityType: 'Round', 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, userAgent: ctx.userAgent, }, diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index a9d357e..c2d7665 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -195,12 +195,28 @@ export const specialAwardRouter = router({ .mutation(async ({ ctx, input }) => { const current = await ctx.prisma.specialAward.findUniqueOrThrow({ 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[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({ where: { id: input.id }, - data: { status: input.status }, + data: updateData, }) await logAudit({ @@ -211,6 +227,11 @@ export const specialAwardRouter = router({ detailsJson: { previousStatus: current.status, newStatus: input.status, + ...(votingStartAtUpdated && { + votingStartAtUpdated: true, + previousVotingStartAt: current.votingStartAt, + newVotingStartAt: now, + }), }, })