2026-01-30 13:41:32 +01:00
|
|
|
'use client'
|
|
|
|
|
|
2026-02-03 19:48:41 +01:00
|
|
|
import { Suspense, use, useState, useEffect } from 'react'
|
2026-01-30 13:41:32 +01:00
|
|
|
import Link from 'next/link'
|
|
|
|
|
import { useRouter } from 'next/navigation'
|
|
|
|
|
import { trpc } from '@/lib/trpc/client'
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from '@/components/ui/card'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
|
import { Progress } from '@/components/ui/progress'
|
|
|
|
|
import { Separator } from '@/components/ui/separator'
|
2026-02-02 19:37:54 +01:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
} from '@/components/ui/alert-dialog'
|
2026-01-30 13:41:32 +01:00
|
|
|
import {
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
Edit,
|
|
|
|
|
Users,
|
|
|
|
|
FileText,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Clock,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
Archive,
|
|
|
|
|
Play,
|
|
|
|
|
Pause,
|
|
|
|
|
BarChart3,
|
|
|
|
|
Upload,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
Filter,
|
2026-02-02 19:37:54 +01:00
|
|
|
Trash2,
|
|
|
|
|
Loader2,
|
2026-02-02 22:33:55 +01:00
|
|
|
Plus,
|
|
|
|
|
ArrowRightCircle,
|
|
|
|
|
Minus,
|
2026-02-03 10:33:34 +01:00
|
|
|
XCircle,
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
ListChecks,
|
|
|
|
|
ClipboardCheck,
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
Sparkles,
|
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
|
|
|
LayoutTemplate,
|
2026-01-30 13:41:32 +01:00
|
|
|
} from 'lucide-react'
|
2026-02-02 19:37:54 +01:00
|
|
|
import { toast } from 'sonner'
|
2026-02-02 22:33:55 +01:00
|
|
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
|
|
|
|
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
|
|
|
|
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
2026-02-03 19:48:41 +01:00
|
|
|
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
interface PageProps {
|
|
|
|
|
params: Promise<{ id: string }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RoundDetailContent({ roundId }: { roundId: string }) {
|
|
|
|
|
const router = useRouter()
|
2026-02-02 22:33:55 +01:00
|
|
|
const [assignOpen, setAssignOpen] = useState(false)
|
|
|
|
|
const [advanceOpen, setAdvanceOpen] = useState(false)
|
|
|
|
|
const [removeOpen, setRemoveOpen] = useState(false)
|
2026-02-03 19:48:41 +01:00
|
|
|
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
2026-01-30 13:41:32 +01:00
|
|
|
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
|
|
|
|
|
2026-02-03 19:48:41 +01:00
|
|
|
// Check if this is a filtering round - roundType is stored directly on the round
|
|
|
|
|
const isFilteringRound = round?.roundType === 'FILTERING'
|
2026-02-03 10:33:34 +01:00
|
|
|
|
2026-02-03 19:48:41 +01:00
|
|
|
// Filtering queries (only fetch for FILTERING rounds)
|
2026-02-03 10:33:34 +01:00
|
|
|
const { data: filteringStats, refetch: refetchFilteringStats } =
|
|
|
|
|
trpc.filtering.getResultStats.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ enabled: isFilteringRound }
|
|
|
|
|
)
|
|
|
|
|
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ enabled: isFilteringRound }
|
|
|
|
|
)
|
2026-02-03 10:46:38 +01:00
|
|
|
const { data: aiStatus } = trpc.filtering.checkAIStatus.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ enabled: isFilteringRound }
|
|
|
|
|
)
|
2026-02-03 19:48:41 +01:00
|
|
|
const { data: latestJob, refetch: refetchLatestJob } =
|
|
|
|
|
trpc.filtering.getLatestJob.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ enabled: isFilteringRound }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Poll for job status when there's an active job
|
|
|
|
|
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
|
|
|
|
{ jobId: activeJobId! },
|
|
|
|
|
{
|
|
|
|
|
enabled: !!activeJobId,
|
|
|
|
|
refetchInterval: activeJobId ? 2000 : false,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-02-03 10:33:34 +01:00
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
const utils = trpc.useUtils()
|
|
|
|
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
utils.round.get.invalidate({ id: roundId })
|
2026-02-10 21:21:54 +01:00
|
|
|
utils.round.list.invalidate()
|
|
|
|
|
utils.program.list.invalidate({ includeRounds: true })
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
2026-02-02 19:37:54 +01:00
|
|
|
const deleteRound = trpc.round.delete.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast.success('Round deleted')
|
2026-02-02 23:36:46 +01:00
|
|
|
utils.program.list.invalidate()
|
|
|
|
|
utils.round.list.invalidate()
|
2026-02-02 19:37:54 +01:00
|
|
|
router.push('/admin/rounds')
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast.error('Failed to delete round')
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
// Filtering mutations
|
2026-02-03 19:48:41 +01:00
|
|
|
const startJob = trpc.filtering.startJob.useMutation()
|
2026-02-10 21:21:54 +01:00
|
|
|
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
utils.round.get.invalidate({ id: roundId })
|
|
|
|
|
utils.project.list.invalidate()
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-02-03 10:33:34 +01:00
|
|
|
|
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
|
|
|
// Save as template
|
|
|
|
|
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
toast.success('Saved as template', {
|
|
|
|
|
action: {
|
|
|
|
|
label: 'View',
|
|
|
|
|
onClick: () => router.push(`/admin/round-templates/${data.id}`),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => {
|
|
|
|
|
toast.error(err.message)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
// AI summary bulk generation
|
|
|
|
|
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
if (data.errors.length > 0) {
|
|
|
|
|
toast.warning(
|
|
|
|
|
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
toast.success(`Generated ${data.generated} AI summaries successfully`)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => {
|
|
|
|
|
toast.error(error.message || 'Failed to generate AI summaries')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-03 19:48:41 +01:00
|
|
|
// Set active job from latest job on load
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
|
|
|
|
setActiveJobId(latestJob.id)
|
|
|
|
|
}
|
|
|
|
|
}, [latestJob])
|
|
|
|
|
|
|
|
|
|
// Handle job completion
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (jobStatus?.status === 'COMPLETED') {
|
2026-02-03 10:33:34 +01:00
|
|
|
toast.success(
|
2026-02-03 19:48:41 +01:00
|
|
|
`Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered out, ${jobStatus.flaggedCount} flagged`
|
2026-02-03 10:33:34 +01:00
|
|
|
)
|
2026-02-03 19:48:41 +01:00
|
|
|
setActiveJobId(null)
|
2026-02-03 10:33:34 +01:00
|
|
|
refetchFilteringStats()
|
2026-02-03 19:48:41 +01:00
|
|
|
refetchLatestJob()
|
|
|
|
|
} else if (jobStatus?.status === 'FAILED') {
|
|
|
|
|
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
|
|
|
|
setActiveJobId(null)
|
|
|
|
|
refetchLatestJob()
|
|
|
|
|
}
|
|
|
|
|
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
|
|
|
|
|
|
|
|
|
|
const handleStartFiltering = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await startJob.mutateAsync({ roundId })
|
|
|
|
|
setActiveJobId(result.jobId)
|
|
|
|
|
toast.info('Filtering job started. Progress will update automatically.')
|
2026-02-03 10:33:34 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(
|
2026-02-03 19:48:41 +01:00
|
|
|
error instanceof Error ? error.message : 'Failed to start filtering'
|
2026-02-03 10:33:34 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleFinalizeFiltering = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await finalizeResults.mutateAsync({ roundId })
|
2026-02-03 22:15:22 +01:00
|
|
|
if (result.advancedToRoundName) {
|
|
|
|
|
toast.success(
|
|
|
|
|
`Finalized: ${result.passed} projects advanced to "${result.advancedToRoundName}", ${result.filteredOut} filtered out`
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
toast.success(
|
|
|
|
|
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out. No next round to advance to.`
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-03 10:33:34 +01:00
|
|
|
refetchFilteringStats()
|
|
|
|
|
refetchRound()
|
|
|
|
|
utils.project.list.invalidate()
|
2026-02-03 22:15:22 +01:00
|
|
|
utils.program.list.invalidate({ includeRounds: true })
|
|
|
|
|
utils.round.get.invalidate({ id: roundId })
|
2026-02-03 10:33:34 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(
|
|
|
|
|
error instanceof Error ? error.message : 'Failed to finalize'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:48:41 +01:00
|
|
|
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
|
|
|
|
const progressPercent = jobStatus?.totalBatches
|
|
|
|
|
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
|
|
|
|
: 0
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
if (isLoading) {
|
|
|
|
|
return <RoundDetailSkeleton />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
|
|
|
|
<p className="mt-2 font-medium">Round Not Found</p>
|
|
|
|
|
<Button asChild className="mt-4">
|
|
|
|
|
<Link href="/admin/rounds">Back to Rounds</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const isVotingOpen =
|
|
|
|
|
round.status === 'ACTIVE' &&
|
|
|
|
|
round.votingStartAt &&
|
|
|
|
|
round.votingEndAt &&
|
|
|
|
|
new Date(round.votingStartAt) <= now &&
|
|
|
|
|
new Date(round.votingEndAt) >= now
|
|
|
|
|
|
|
|
|
|
const getStatusBadge = () => {
|
|
|
|
|
if (round.status === 'ACTIVE' && isVotingOpen) {
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="default" className="bg-green-600">
|
|
|
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
|
|
|
Voting Open
|
|
|
|
|
</Badge>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (round.status) {
|
|
|
|
|
case 'DRAFT':
|
|
|
|
|
return <Badge variant="secondary">Draft</Badge>
|
|
|
|
|
case 'ACTIVE':
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="default">
|
|
|
|
|
<Clock className="mr-1 h-3 w-3" />
|
|
|
|
|
Active
|
|
|
|
|
</Badge>
|
|
|
|
|
)
|
|
|
|
|
case 'CLOSED':
|
|
|
|
|
return <Badge variant="outline">Closed</Badge>
|
|
|
|
|
case 'ARCHIVED':
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="outline">
|
|
|
|
|
<Archive className="mr-1 h-3 w-3" />
|
|
|
|
|
Archived
|
|
|
|
|
</Badge>
|
|
|
|
|
)
|
|
|
|
|
default:
|
|
|
|
|
return <Badge variant="secondary">{round.status}</Badge>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<Button variant="ghost" asChild className="-ml-4">
|
|
|
|
|
<Link href="/admin/rounds">
|
|
|
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
|
|
|
Back to Rounds
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
2026-02-02 19:52:52 +01:00
|
|
|
{round.program.year} Edition
|
2026-01-30 13:41:32 +01:00
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
|
|
|
|
|
{getStatusBadge()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<Button variant="outline" asChild>
|
|
|
|
|
<Link href={`/admin/rounds/${round.id}/edit`}>
|
|
|
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
|
|
|
Edit
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
{round.status === 'DRAFT' && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
|
|
|
|
|
disabled={updateStatus.isPending}
|
|
|
|
|
>
|
|
|
|
|
<Play className="mr-2 h-4 w-4" />
|
|
|
|
|
Activate
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{round.status === 'ACTIVE' && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={() => updateStatus.mutate({ id: round.id, status: 'CLOSED' })}
|
|
|
|
|
disabled={updateStatus.isPending}
|
|
|
|
|
>
|
|
|
|
|
<Pause className="mr-2 h-4 w-4" />
|
|
|
|
|
Close Round
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-02-05 16:51:02 +01:00
|
|
|
{round.status === 'CLOSED' && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
|
|
|
|
|
disabled={updateStatus.isPending}
|
|
|
|
|
>
|
|
|
|
|
<Play className="mr-2 h-4 w-4" />
|
|
|
|
|
Reopen Round
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-02-03 22:15:22 +01:00
|
|
|
<AlertDialog>
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
<Button variant="destructive">
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
|
|
|
{round.status === 'ACTIVE' && (
|
|
|
|
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
|
|
|
)}
|
|
|
|
|
Delete Round
|
|
|
|
|
</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription asChild>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{round.status === 'ACTIVE' && (
|
|
|
|
|
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm font-medium">
|
|
|
|
|
Warning: This round is currently ACTIVE. Deleting it will immediately end all ongoing evaluations.
|
|
|
|
|
</div>
|
2026-02-02 19:37:54 +01:00
|
|
|
)}
|
2026-02-03 22:15:22 +01:00
|
|
|
<p>
|
|
|
|
|
This will permanently delete “{round.name}” and all
|
|
|
|
|
associated data:
|
|
|
|
|
</p>
|
|
|
|
|
<ul className="list-disc list-inside text-sm space-y-1">
|
|
|
|
|
<li>{progress?.totalProjects || 0} projects in this round</li>
|
|
|
|
|
<li>{progress?.totalAssignments || 0} jury assignments</li>
|
|
|
|
|
<li>{progress?.completedAssignments || 0} submitted evaluations</li>
|
|
|
|
|
</ul>
|
|
|
|
|
<p className="font-medium">This action cannot be undone.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={() => deleteRound.mutate({ id: round.id })}
|
|
|
|
|
disabled={deleteRound.isPending}
|
|
|
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
|
|
|
>
|
|
|
|
|
{deleteRound.isPending ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
Deleting...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'Delete Round'
|
|
|
|
|
)}
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
2026-01-30 13:41:32 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* Stats Grid */}
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
|
|
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2026-02-04 14:15:06 +01:00
|
|
|
<div className="text-2xl font-bold">{round._count.projects}</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
<Button variant="link" size="sm" className="px-0" asChild>
|
|
|
|
|
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
2026-02-04 15:15:10 +01:00
|
|
|
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
|
2026-01-30 13:41:32 +01:00
|
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{round._count.assignments}</div>
|
|
|
|
|
<Button variant="link" size="sm" className="px-0" asChild>
|
|
|
|
|
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
2026-02-04 15:15:10 +01:00
|
|
|
Manage judge assignments
|
2026-01-30 13:41:32 +01:00
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
|
|
|
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{round.requiredReviews}</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">per project</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">
|
|
|
|
|
{progress?.completionPercentage || 0}%
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Progress */}
|
|
|
|
|
{progress && progress.totalAssignments > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
|
|
|
<span>Overall Completion</span>
|
|
|
|
|
<span>{progress.completionPercentage}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Progress value={progress.completionPercentage} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-4">
|
|
|
|
|
{Object.entries(progress.evaluationsByStatus).map(([status, count]) => (
|
|
|
|
|
<div key={status} className="text-center p-3 rounded-lg bg-muted">
|
|
|
|
|
<p className="text-2xl font-bold">{count}</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground capitalize">
|
|
|
|
|
{status.toLowerCase().replace('_', ' ')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Voting Window */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-lg">Voting Window</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">Start Date</p>
|
|
|
|
|
{round.votingStartAt ? (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{format(new Date(round.votingStartAt), 'PPP')}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{format(new Date(round.votingStartAt), 'p')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-muted-foreground italic">Not set</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">End Date</p>
|
|
|
|
|
{round.votingEndAt ? (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{format(new Date(round.votingEndAt), 'PPP')}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{format(new Date(round.votingEndAt), 'p')}
|
|
|
|
|
</p>
|
|
|
|
|
{isFuture(new Date(round.votingEndAt)) && (
|
|
|
|
|
<p className="text-sm text-amber-600 mt-1">
|
|
|
|
|
Ends {formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-muted-foreground italic">Not set</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Voting status */}
|
|
|
|
|
{round.votingStartAt && round.votingEndAt && (
|
|
|
|
|
<div
|
|
|
|
|
className={`p-4 rounded-lg ${
|
|
|
|
|
isVotingOpen
|
|
|
|
|
? 'bg-green-500/10 text-green-700'
|
|
|
|
|
: isFuture(new Date(round.votingStartAt))
|
|
|
|
|
? 'bg-amber-500/10 text-amber-700'
|
2026-02-03 23:19:45 +01:00
|
|
|
: isFuture(new Date(round.votingEndAt))
|
|
|
|
|
? 'bg-blue-500/10 text-blue-700'
|
|
|
|
|
: 'bg-muted text-muted-foreground'
|
2026-01-30 13:41:32 +01:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{isVotingOpen ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
|
|
|
<span className="font-medium">Voting is currently open</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : isFuture(new Date(round.votingStartAt)) ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Clock className="h-5 w-5" />
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-02-03 23:19:45 +01:00
|
|
|
) : isFuture(new Date(round.votingEndAt)) ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Clock className="h-5 w-5" />
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{round.status === 'DRAFT'
|
|
|
|
|
? 'Voting window configured (round not yet active)'
|
|
|
|
|
: `Voting ends ${formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}`
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<AlertCircle className="h-5 w-5" />
|
|
|
|
|
<span className="font-medium">Voting period has ended</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
{/* 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
|
2026-02-03 19:48:41 +01:00
|
|
|
onClick={handleStartFiltering}
|
|
|
|
|
disabled={startJob.isPending || isJobRunning || !filteringRules || filteringRules.length === 0}
|
2026-02-03 10:33:34 +01:00
|
|
|
>
|
2026-02-03 19:48:41 +01:00
|
|
|
{startJob.isPending || isJobRunning ? (
|
2026-02-03 10:33:34 +01:00
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Play className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
2026-02-03 19:48:41 +01:00
|
|
|
{isJobRunning ? 'Running...' : 'Run Filtering'}
|
2026-02-03 10:33:34 +01:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
2026-02-03 19:48:41 +01:00
|
|
|
{/* Progress Card (when job is running) */}
|
|
|
|
|
{isJobRunning && jobStatus && (
|
|
|
|
|
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<p className="font-medium text-blue-900 dark:text-blue-100">
|
|
|
|
|
AI Filtering in Progress
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
|
|
|
|
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
|
|
|
|
|
</p>
|
|
|
|
|
</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">
|
|
|
|
|
{progressPercent}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Progress value={progressPercent} className="h-2" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-03 10:46:38 +01:00
|
|
|
{/* 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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
{/* 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>
|
2026-02-03 19:48:41 +01:00
|
|
|
) : !isJobRunning && (
|
2026-02-03 10:33:34 +01:00
|
|
|
<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}
|
2026-02-03 19:48:41 +01:00
|
|
|
disabled={finalizeResults.isPending || isJobRunning}
|
2026-02-03 10:33:34 +01:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
{/* Quick Actions */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
|
|
|
|
</CardHeader>
|
2026-02-03 10:50:47 +01:00
|
|
|
<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
|
2026-02-03 10:33:34 +01:00
|
|
|
</Link>
|
|
|
|
|
</Button>
|
2026-02-03 10:50:47 +01:00
|
|
|
<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>
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
|
|
|
|
disabled={bulkSummaries.isPending}
|
|
|
|
|
>
|
|
|
|
|
{bulkSummaries.isPending ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Sparkles className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
|
|
|
|
</Button>
|
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
saveAsTemplate.mutate({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
name: `${round.name} Template`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
disabled={saveAsTemplate.isPending}
|
|
|
|
|
>
|
|
|
|
|
{saveAsTemplate.isPending ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<LayoutTemplate className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
Save as Template
|
|
|
|
|
</Button>
|
2026-02-03 10:50:47 +01:00
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2026-02-02 22:33:55 +01:00
|
|
|
|
|
|
|
|
{/* Dialogs */}
|
|
|
|
|
<AssignProjectsDialog
|
|
|
|
|
roundId={roundId}
|
|
|
|
|
programId={round.program.id}
|
|
|
|
|
open={assignOpen}
|
|
|
|
|
onOpenChange={setAssignOpen}
|
|
|
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
|
|
|
/>
|
|
|
|
|
<AdvanceProjectsDialog
|
|
|
|
|
roundId={roundId}
|
|
|
|
|
programId={round.program.id}
|
|
|
|
|
open={advanceOpen}
|
|
|
|
|
onOpenChange={setAdvanceOpen}
|
|
|
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
|
|
|
/>
|
|
|
|
|
<RemoveProjectsDialog
|
|
|
|
|
roundId={roundId}
|
|
|
|
|
open={removeOpen}
|
|
|
|
|
onOpenChange={setRemoveOpen}
|
|
|
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
|
|
|
/>
|
2026-01-30 13:41:32 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RoundDetailSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<Skeleton className="h-9 w-36" />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Skeleton className="h-4 w-32" />
|
|
|
|
|
<Skeleton className="h-8 w-64" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Skeleton className="h-10 w-24" />
|
|
|
|
|
<Skeleton className="h-10 w-28" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Skeleton className="h-px w-full" />
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
{[1, 2, 3, 4].map((i) => (
|
|
|
|
|
<Card key={i}>
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<Skeleton className="h-4 w-24" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<Skeleton className="h-8 w-16" />
|
|
|
|
|
<Skeleton className="mt-1 h-4 w-20" />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<Skeleton className="h-5 w-40" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<Skeleton className="h-3 w-full" />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function RoundDetailPage({ params }: PageProps) {
|
|
|
|
|
const { id } = use(params)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Suspense fallback={<RoundDetailSkeleton />}>
|
|
|
|
|
<RoundDetailContent roundId={id} />
|
|
|
|
|
</Suspense>
|
|
|
|
|
)
|
|
|
|
|
}
|