2026-01-30 13:41:32 +01:00
|
|
|
'use client'
|
|
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
import { Suspense, use, useState, useEffect, useCallback } 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'
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
import { Label } from '@/components/ui/label'
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from '@/components/ui/table'
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog'
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select'
|
2026-02-02 19:37:54 +01:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
} from '@/components/ui/alert-dialog'
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from '@/components/ui/tooltip'
|
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,
|
2026-02-11 13:20:52 +01:00
|
|
|
FileSearch,
|
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,
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
ShieldCheck,
|
|
|
|
|
Download,
|
|
|
|
|
RotateCcw,
|
2026-02-11 13:20:52 +01:00
|
|
|
Zap,
|
|
|
|
|
QrCode,
|
|
|
|
|
ExternalLink,
|
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-11 13:20:52 +01:00
|
|
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
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'
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
import { Pagination } from '@/components/shared/pagination'
|
|
|
|
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
2026-02-03 19:48:41 +01:00
|
|
|
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
const OUTCOME_BADGES: Record<
|
|
|
|
|
string,
|
|
|
|
|
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
|
|
|
|
|
> = {
|
|
|
|
|
PASSED: {
|
|
|
|
|
variant: 'default',
|
|
|
|
|
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
|
|
|
|
|
label: 'Passed',
|
|
|
|
|
},
|
|
|
|
|
FILTERED_OUT: {
|
|
|
|
|
variant: 'destructive',
|
|
|
|
|
icon: <XCircle className="mr-1 h-3 w-3" />,
|
|
|
|
|
label: 'Filtered Out',
|
|
|
|
|
},
|
|
|
|
|
FLAGGED: {
|
|
|
|
|
variant: 'secondary',
|
|
|
|
|
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
|
|
|
|
|
label: 'Flagged',
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
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-02-11 13:20:52 +01:00
|
|
|
const [jobPollInterval, setJobPollInterval] = useState(2000)
|
2026-01-30 13:41:32 +01:00
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
// Inline filtering results state
|
|
|
|
|
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
|
|
|
|
const [resultsPage, setResultsPage] = useState(1)
|
|
|
|
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
|
|
|
|
const [overrideDialog, setOverrideDialog] = useState<{
|
|
|
|
|
id: string
|
|
|
|
|
currentOutcome: string
|
|
|
|
|
} | null>(null)
|
|
|
|
|
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
|
|
|
|
|
const [overrideReason, setOverrideReason] = useState('')
|
|
|
|
|
const [showExportDialog, setShowExportDialog] = useState(false)
|
|
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
2026-02-11 13:20:52 +01:00
|
|
|
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
|
|
|
|
|
const progress = round?.progress
|
2026-01-30 13:41:32 +01:00
|
|
|
|
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)
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
2026-02-03 10:33:34 +01:00
|
|
|
trpc.filtering.getResultStats.useQuery(
|
|
|
|
|
{ roundId },
|
2026-02-11 13:20:52 +01:00
|
|
|
{ enabled: isFilteringRound, staleTime: 30_000 }
|
2026-02-03 10:33:34 +01:00
|
|
|
)
|
|
|
|
|
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 },
|
2026-02-11 13:20:52 +01:00
|
|
|
{ enabled: isFilteringRound, staleTime: 30_000 }
|
2026-02-03 19:48:41 +01:00
|
|
|
)
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
// Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap)
|
2026-02-03 19:48:41 +01:00
|
|
|
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
|
|
|
|
{ jobId: activeJobId! },
|
|
|
|
|
{
|
|
|
|
|
enabled: !!activeJobId,
|
2026-02-11 13:20:52 +01:00
|
|
|
refetchInterval: activeJobId ? jobPollInterval : false,
|
|
|
|
|
refetchIntervalInBackground: false,
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
staleTime: 0,
|
2026-02-03 19:48:41 +01:00
|
|
|
}
|
|
|
|
|
)
|
2026-02-03 10:33:34 +01:00
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
// Increase polling interval over time (exponential backoff)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeJobId) {
|
|
|
|
|
setJobPollInterval(2000)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
setJobPollInterval((prev) => Math.min(prev * 2, 15000))
|
|
|
|
|
}, jobPollInterval)
|
|
|
|
|
return () => clearTimeout(timer)
|
|
|
|
|
}, [activeJobId, jobPollInterval])
|
|
|
|
|
|
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-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.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 })
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-02-03 10:33:34 +01:00
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
// Inline filtering results
|
|
|
|
|
const resultsPerPage = 20
|
|
|
|
|
const { data: filteringResults, refetch: refetchResults } =
|
|
|
|
|
trpc.filtering.getResults.useQuery(
|
|
|
|
|
{
|
|
|
|
|
roundId,
|
|
|
|
|
outcome: outcomeFilter
|
|
|
|
|
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
|
|
|
|
|
: undefined,
|
|
|
|
|
page: resultsPage,
|
|
|
|
|
perPage: resultsPerPage,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
|
2026-02-11 13:20:52 +01:00
|
|
|
staleTime: 30_000,
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
|
|
|
|
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
|
|
|
|
const exportResults = trpc.export.filteringResults.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ enabled: false }
|
|
|
|
|
)
|
|
|
|
|
|
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()
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
refetchResults()
|
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()
|
|
|
|
|
}
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchResults, refetchLatestJob])
|
2026-02-03 19:48:41 +01:00
|
|
|
|
|
|
|
|
const handleStartFiltering = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await startJob.mutateAsync({ roundId })
|
2026-02-11 13:20:52 +01:00
|
|
|
setJobPollInterval(2000)
|
2026-02-03 19:48:41 +01:00
|
|
|
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()
|
2026-02-03 22:15:22 +01:00
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
// Inline results handlers
|
|
|
|
|
const toggleResultRow = (id: string) => {
|
|
|
|
|
const next = new Set(expandedRows)
|
|
|
|
|
if (next.has(id)) next.delete(id)
|
|
|
|
|
else next.add(id)
|
|
|
|
|
setExpandedRows(next)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleOverride = async () => {
|
|
|
|
|
if (!overrideDialog || !overrideReason.trim()) return
|
|
|
|
|
try {
|
|
|
|
|
await overrideResult.mutateAsync({
|
|
|
|
|
id: overrideDialog.id,
|
|
|
|
|
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
|
|
|
|
|
reason: overrideReason.trim(),
|
|
|
|
|
})
|
|
|
|
|
toast.success('Result overridden')
|
|
|
|
|
setOverrideDialog(null)
|
|
|
|
|
setOverrideReason('')
|
|
|
|
|
refetchResults()
|
|
|
|
|
refetchFilteringStats()
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error('Failed to override result')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleReinstate = async (projectId: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await reinstateProject.mutateAsync({ roundId, projectId })
|
|
|
|
|
toast.success('Project reinstated')
|
|
|
|
|
refetchResults()
|
|
|
|
|
refetchFilteringStats()
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error('Failed to reinstate project')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRequestExportData = useCallback(async () => {
|
|
|
|
|
const result = await exportResults.refetch()
|
|
|
|
|
return result.data ?? undefined
|
|
|
|
|
}, [exportResults])
|
|
|
|
|
|
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 */}
|
2026-02-11 13:20:52 +01:00
|
|
|
<AnimatedCard index={0}>
|
2026-01-30 13:41:32 +01:00
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
2026-02-11 13:20:52 +01:00
|
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
2026-01-30 13:41:32 +01:00
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
2026-02-11 13:20:52 +01:00
|
|
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
|
|
|
|
<FileText className="h-4 w-4 text-emerald-500" />
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
2026-01-30 13:41:32 +01:00
|
|
|
<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-02-11 13:20:52 +01:00
|
|
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
|
|
|
|
<Users className="h-4 w-4 text-violet-500" />
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
2026-01-30 13:41:32 +01:00
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
2026-02-11 13:20:52 +01:00
|
|
|
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
|
|
|
|
<BarChart3 className="h-4 w-4 text-blue-500" />
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{round.requiredReviews}</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">per project</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
2026-01-30 13:41:32 +01:00
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
2026-02-11 13:20:52 +01:00
|
|
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
2026-02-11 13:20:52 +01:00
|
|
|
</AnimatedCard>
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
{/* Progress */}
|
|
|
|
|
{progress && progress.totalAssignments > 0 && (
|
2026-02-11 13:20:52 +01:00
|
|
|
<AnimatedCard index={1}>
|
2026-01-30 13:41:32 +01:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-02-11 13:20:52 +01:00
|
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
|
|
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
|
|
|
|
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
|
|
|
|
</div>
|
|
|
|
|
Evaluation Progress
|
|
|
|
|
</CardTitle>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
2026-02-11 13:20:52 +01:00
|
|
|
<Progress value={progress.completionPercentage} gradient />
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
2026-02-11 13:20:52 +01:00
|
|
|
</AnimatedCard>
|
2026-01-30 13:41:32 +01:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Voting Window */}
|
2026-02-11 13:20:52 +01:00
|
|
|
<AnimatedCard index={2}>
|
2026-01-30 13:41:32 +01:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-02-11 13:20:52 +01:00
|
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
|
|
|
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
|
|
|
|
<Clock className="h-4 w-4 text-blue-500" />
|
|
|
|
|
</div>
|
|
|
|
|
Voting Window
|
|
|
|
|
</CardTitle>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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-11 13:20:52 +01:00
|
|
|
</AnimatedCard>
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
{/* Filtering Section (for FILTERING rounds) */}
|
|
|
|
|
{isFilteringRound && (
|
2026-02-11 13:20:52 +01:00
|
|
|
<AnimatedCard index={3}>
|
2026-02-03 10:33:34 +01:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
2026-02-11 13:20:52 +01:00
|
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
|
|
|
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
|
|
|
|
<Filter className="h-4 w-4 text-amber-500" />
|
|
|
|
|
</div>
|
2026-02-03 10:33:34 +01:00
|
|
|
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>
|
2026-02-11 13:20:52 +01:00
|
|
|
<Progress value={progressPercent} className="h-2" gradient />
|
2026-02-03 19:48:41 +01:00
|
|
|
</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 */}
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
{isLoadingFilteringStats && !isJobRunning ? (
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-4">
|
|
|
|
|
{[1, 2, 3, 4].map((i) => (
|
|
|
|
|
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
|
|
|
|
<Skeleton className="h-10 w-10 rounded-full" />
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-7 w-12" />
|
|
|
|
|
<Skeleton className="h-4 w-16" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : filteringStats && filteringStats.total > 0 ? (
|
2026-02-03 10:33:34 +01:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
{/* Inline Filtering Results Table */}
|
|
|
|
|
{filteringStats && filteringStats.total > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* Outcome Filter Tabs */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={outcome || 'all'}
|
|
|
|
|
variant={outcomeFilter === outcome ? 'default' : 'outline'}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOutcomeFilter(outcome)
|
|
|
|
|
setResultsPage(1)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{outcome ? (
|
|
|
|
|
<>
|
|
|
|
|
{OUTCOME_BADGES[outcome].icon}
|
|
|
|
|
{OUTCOME_BADGES[outcome].label}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'All'
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowExportDialog(true)}
|
|
|
|
|
disabled={exportResults.isFetching}
|
|
|
|
|
>
|
|
|
|
|
{exportResults.isFetching ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Download className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
Export CSV
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Results Table */}
|
|
|
|
|
{filteringResults && filteringResults.results.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>Project</TableHead>
|
|
|
|
|
<TableHead>Category</TableHead>
|
|
|
|
|
<TableHead>Outcome</TableHead>
|
|
|
|
|
<TableHead className="w-[300px]">AI Reason</TableHead>
|
|
|
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{filteringResults.results.map((result) => {
|
|
|
|
|
const isExpanded = expandedRows.has(result.id)
|
|
|
|
|
const effectiveOutcome =
|
|
|
|
|
result.finalOutcome || result.outcome
|
|
|
|
|
const badge = OUTCOME_BADGES[effectiveOutcome]
|
|
|
|
|
|
|
|
|
|
const aiScreening = result.aiScreeningJson as Record<string, {
|
|
|
|
|
meetsCriteria?: boolean
|
|
|
|
|
confidence?: number
|
|
|
|
|
reasoning?: string
|
|
|
|
|
qualityScore?: number
|
|
|
|
|
spamRisk?: boolean
|
|
|
|
|
}> | null
|
|
|
|
|
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
|
|
|
|
|
const aiReasoning = firstAiResult?.reasoning
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<TableRow
|
|
|
|
|
key={result.id}
|
|
|
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
|
|
|
onClick={() => toggleResultRow(result.id)}
|
|
|
|
|
>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{result.project.title}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{result.project.teamName}
|
|
|
|
|
{result.project.country && ` · ${result.project.country}`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{result.project.competitionCategory ? (
|
|
|
|
|
<Badge variant="outline">
|
|
|
|
|
{result.project.competitionCategory.replace(
|
|
|
|
|
'_',
|
|
|
|
|
' '
|
|
|
|
|
)}
|
|
|
|
|
</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
'-'
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Badge variant={badge?.variant || 'secondary'}>
|
|
|
|
|
{badge?.icon}
|
|
|
|
|
{badge?.label || effectiveOutcome}
|
|
|
|
|
</Badge>
|
|
|
|
|
{result.overriddenByUser && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{aiReasoning ? (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<p className="text-sm line-clamp-2">
|
|
|
|
|
{aiReasoning}
|
|
|
|
|
</p>
|
|
|
|
|
{firstAiResult && (
|
|
|
|
|
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
|
|
|
{firstAiResult.confidence !== undefined && (
|
|
|
|
|
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
|
|
|
|
|
)}
|
|
|
|
|
{firstAiResult.qualityScore !== undefined && (
|
|
|
|
|
<span>Quality: {firstAiResult.qualityScore}/10</span>
|
|
|
|
|
)}
|
|
|
|
|
{firstAiResult.spamRisk && (
|
|
|
|
|
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-sm text-muted-foreground italic">
|
|
|
|
|
No AI screening
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
<div
|
|
|
|
|
className="flex justify-end gap-1"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOverrideOutcome('PASSED')
|
|
|
|
|
setOverrideDialog({
|
|
|
|
|
id: result.id,
|
|
|
|
|
currentOutcome: effectiveOutcome,
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
|
|
|
Override
|
|
|
|
|
</Button>
|
|
|
|
|
{effectiveOutcome === 'FILTERED_OUT' && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleReinstate(result.projectId)
|
|
|
|
|
}
|
|
|
|
|
disabled={reinstateProject.isPending}
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="mr-1 h-3 w-3" />
|
|
|
|
|
Reinstate
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<TableRow key={`${result.id}-detail`}>
|
|
|
|
|
<TableCell colSpan={5} className="bg-muted/30">
|
|
|
|
|
<div className="p-4 space-y-4">
|
|
|
|
|
{/* Rule Results */}
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium mb-2">
|
|
|
|
|
Rule Results
|
|
|
|
|
</p>
|
|
|
|
|
{result.ruleResultsJson &&
|
|
|
|
|
Array.isArray(result.ruleResultsJson) ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(
|
|
|
|
|
result.ruleResultsJson as Array<{
|
|
|
|
|
ruleName: string
|
|
|
|
|
ruleType: string
|
|
|
|
|
passed: boolean
|
|
|
|
|
action: string
|
|
|
|
|
reasoning?: string
|
|
|
|
|
}>
|
|
|
|
|
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="flex items-start gap-2 text-sm"
|
|
|
|
|
>
|
|
|
|
|
{rr.passed ? (
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{rr.ruleName}
|
|
|
|
|
</span>
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{rr.ruleType.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
{rr.reasoning && (
|
|
|
|
|
<p className="text-muted-foreground mt-0.5">
|
|
|
|
|
{rr.reasoning}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
No detailed rule results available
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* AI Screening Details */}
|
|
|
|
|
{aiScreening && Object.keys(aiScreening).length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium mb-2">
|
|
|
|
|
AI Screening Analysis
|
|
|
|
|
</p>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{Object.entries(aiScreening).map(([ruleId, screening]) => (
|
|
|
|
|
<div key={ruleId} className="p-3 bg-background rounded-lg border">
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
{screening.meetsCriteria ? (
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="h-4 w-4 text-red-600" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="font-medium text-sm">
|
|
|
|
|
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
|
|
|
|
|
</span>
|
|
|
|
|
{screening.spamRisk && (
|
|
|
|
|
<Badge variant="destructive" className="text-xs">
|
|
|
|
|
<AlertTriangle className="h-3 w-3 mr-1" />
|
|
|
|
|
Spam Risk
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{screening.reasoning && (
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-2">
|
|
|
|
|
{screening.reasoning}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
|
|
|
|
{screening.confidence !== undefined && (
|
|
|
|
|
<span>
|
|
|
|
|
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{screening.qualityScore !== undefined && (
|
|
|
|
|
<span>
|
|
|
|
|
Quality Score: <strong>{screening.qualityScore}/10</strong>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Override Info */}
|
|
|
|
|
{result.overriddenByUser && (
|
|
|
|
|
<div className="pt-3 border-t">
|
|
|
|
|
<p className="text-sm font-medium mb-1">Manual Override</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
|
|
|
|
{result.overriddenByUser.name || result.overriddenByUser.email}
|
|
|
|
|
</p>
|
|
|
|
|
{result.overrideReason && (
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
|
|
|
Reason: {result.overrideReason}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Pagination
|
|
|
|
|
page={filteringResults.page}
|
|
|
|
|
totalPages={filteringResults.totalPages}
|
|
|
|
|
total={filteringResults.total}
|
|
|
|
|
perPage={resultsPerPage}
|
|
|
|
|
onPageChange={setResultsPage}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
) : filteringResults ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
|
|
|
<CheckCircle2 className="h-8 w-8 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 text-sm font-medium">No results match this filter</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-03 10:33:34 +01:00
|
|
|
{/* 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>
|
|
|
|
|
{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-02-11 13:20:52 +01:00
|
|
|
</AnimatedCard>
|
2026-02-03 10:33:34 +01:00
|
|
|
)}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
{/* Quick Actions */}
|
2026-02-11 13:20:52 +01:00
|
|
|
<AnimatedCard index={4}>
|
2026-01-30 13:41:32 +01:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-02-11 13:20:52 +01:00
|
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
|
|
|
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
|
|
|
|
<Zap className="h-4 w-4 text-amber-500" />
|
|
|
|
|
</div>
|
|
|
|
|
Quick Actions
|
|
|
|
|
</CardTitle>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
2026-02-11 13:20:52 +01:00
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
|
|
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
|
|
|
Live Voting
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
<TooltipProvider>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<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" />
|
|
|
|
|
) : (
|
2026-02-11 13:20:52 +01:00
|
|
|
<FileSearch className="mr-2 h-4 w-4" />
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
)}
|
|
|
|
|
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" className="max-w-xs">
|
|
|
|
|
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</TooltipProvider>
|
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-11 13:20:52 +01:00
|
|
|
</AnimatedCard>
|
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 })}
|
|
|
|
|
/>
|
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
|
|
|
|
|
|
|
|
{/* Override Dialog */}
|
|
|
|
|
<Dialog
|
|
|
|
|
open={!!overrideDialog}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setOverrideDialog(null)
|
|
|
|
|
setOverrideReason('')
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Override Filtering Result</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
Change the outcome for this project. This will be logged in the
|
|
|
|
|
audit trail.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>New Outcome</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={overrideOutcome}
|
|
|
|
|
onValueChange={setOverrideOutcome}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="PASSED">Passed</SelectItem>
|
|
|
|
|
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
|
|
|
|
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>Reason</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={overrideReason}
|
|
|
|
|
onChange={(e) => setOverrideReason(e.target.value)}
|
|
|
|
|
placeholder="Explain why you're overriding..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setOverrideDialog(null)}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleOverride}
|
|
|
|
|
disabled={
|
|
|
|
|
overrideResult.isPending || !overrideReason.trim()
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{overrideResult.isPending && (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
)}
|
|
|
|
|
Override
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* CSV Export Dialog */}
|
|
|
|
|
<CsvExportDialog
|
|
|
|
|
open={showExportDialog}
|
|
|
|
|
onOpenChange={setShowExportDialog}
|
|
|
|
|
exportData={exportResults.data ?? undefined}
|
|
|
|
|
isLoading={exportResults.isFetching}
|
|
|
|
|
filename="filtering-results"
|
|
|
|
|
onRequestData={handleRequestExportData}
|
|
|
|
|
/>
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|