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>
This commit is contained in:
2026-02-10 23:07:38 +01:00
parent 5cae78fe0c
commit 5c8d22ac11
9 changed files with 1257 additions and 197 deletions

View File

@@ -74,7 +74,7 @@ export default function MemberDetailPage() {
const [name, setName] = useState('')
const [role, setRole] = useState<string>('JURY_MEMBER')
const [status, setStatus] = useState<string>('INVITED')
const [status, setStatus] = useState<string>('NONE')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
@@ -96,7 +96,7 @@ export default function MemberDetailPage() {
id: userId,
name: name || null,
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED',
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
@@ -180,11 +180,11 @@ export default function MemberDetailPage() {
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{user.email}</p>
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
{user.status}
{user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
</div>
</div>
{user.status === 'INVITED' && (
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
onClick={handleSendInvitation}
@@ -235,6 +235,7 @@ export default function MemberDetailPage() {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
@@ -243,7 +244,9 @@ export default function MemberDetailPage() {
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
)}
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
)}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
@@ -257,6 +260,7 @@ export default function MemberDetailPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
@@ -379,6 +383,16 @@ export default function MemberDetailPage() {
<UserActivityLog userId={userId} />
{/* Status Alert */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added to the platform via project import but hasn&apos;t been
invited yet. Send them an invitation using the button above.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />

View File

@@ -82,10 +82,17 @@ import {
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
import { StatusBadge } from '@/components/shared/status-badge'
import { Pagination } from '@/components/shared/pagination'
import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries'
import {
ProjectFiltersBar,
type ProjectFilters,
@@ -375,6 +382,7 @@ export default function ProjectsPage() {
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
@@ -382,10 +390,52 @@ export default function ProjectsPage() {
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
// Query for fetching all matching IDs (used for "select all across pages")
const allIdsQuery = trpc.project.listAllIds.useQuery(
{
search: filters.search || undefined,
statuses:
filters.statuses.length > 0
? (filters.statuses as Array<
| 'SUBMITTED'
| 'ELIGIBLE'
| 'ASSIGNED'
| 'SEMIFINALIST'
| 'FINALIST'
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
oceanIssue: filters.oceanIssue
? (filters.oceanIssue as
| 'POLLUTION_REDUCTION'
| 'CLIMATE_MITIGATION'
| 'TECHNOLOGY_INNOVATION'
| 'SUSTAINABLE_SHIPPING'
| 'BLUE_CARBON'
| 'HABITAT_RESTORATION'
| 'COMMUNITY_CAPACITY'
| 'SUSTAINABLE_FISHING'
| 'CONSUMER_AWARENESS'
| 'OCEAN_ACIDIFICATION'
| 'OTHER')
: undefined,
country: filters.country || undefined,
wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments,
},
{ enabled: false } // Only fetch on demand
)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkStatus('')
setBulkConfirmOpen(false)
utils.project.list.invalidate()
@@ -399,6 +449,7 @@ export default function ProjectsPage() {
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
@@ -412,6 +463,7 @@ export default function ProjectsPage() {
onSuccess: (result) => {
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkDeleteConfirmOpen(false)
utils.project.list.invalidate()
},
@@ -421,6 +473,7 @@ export default function ProjectsPage() {
})
const handleToggleSelect = (id: string) => {
setAllMatchingSelected(false)
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
@@ -434,6 +487,7 @@ export default function ProjectsPage() {
const handleSelectAll = () => {
if (!data) return
setAllMatchingSelected(false)
const allVisible = data.projects.map((p) => p.id)
const allSelected = allVisible.every((id) => selectedIds.has(id))
if (allSelected) {
@@ -451,6 +505,20 @@ export default function ProjectsPage() {
}
}
const handleSelectAllMatching = async () => {
const result = await allIdsQuery.refetch()
if (result.data) {
setSelectedIds(new Set(result.data.ids))
setAllMatchingSelected(true)
}
}
const handleClearSelection = () => {
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkStatus('')
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkConfirmOpen(true)
@@ -620,6 +688,47 @@ export default function ProjectsPage() {
</div>
)}
{/* Select All Banner */}
{data && allVisibleSelected && data.total > data.projects.length && !allMatchingSelected && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
<span>
All <strong>{data.projects.length}</strong> projects on this page are selected.
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0 font-semibold"
onClick={handleSelectAllMatching}
disabled={allIdsQuery.isFetching}
>
{allIdsQuery.isFetching ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Loading...
</>
) : (
`Select all ${data.total} matching projects`
)}
</Button>
</div>
)}
{allMatchingSelected && data && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
<CheckCircle2 className="h-4 w-4 text-primary" />
<span>
All <strong>{selectedIds.size}</strong> matching projects are selected.
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={handleClearSelection}
>
Clear selection
</Button>
</div>
)}
{/* Content */}
{isLoading ? (
<Card>
@@ -728,9 +837,23 @@ export default function ProjectsPage() {
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
{project.country && (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)
})()}
</p>
</div>
</Link>
@@ -983,9 +1106,23 @@ export default function ProjectsPage() {
</div>
<CardDescription className="mt-0.5">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
{project.country && (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)
})()}
</CardDescription>
</div>
</div>
@@ -1118,10 +1255,7 @@ export default function ProjectsPage() {
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
onClick={handleClearSelection}
className="shrink-0"
>
<X className="mr-1 h-4 w-4" />

View File

@@ -1,6 +1,6 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
@@ -16,6 +16,31 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
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'
import {
AlertDialog,
AlertDialogAction,
@@ -27,6 +52,12 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ArrowLeft,
Edit,
@@ -52,13 +83,39 @@ import {
ClipboardCheck,
Sparkles,
LayoutTemplate,
ShieldCheck,
Download,
RotateCcw,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
import { Pagination } from '@/components/shared/pagination'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { format, formatDistanceToNow, isFuture } from 'date-fns'
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',
},
}
interface PageProps {
params: Promise<{ id: string }>
}
@@ -70,6 +127,18 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [removeOpen, setRemoveOpen] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
// 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)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
@@ -77,10 +146,10 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const isFilteringRound = round?.roundType === 'FILTERING'
// Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, refetch: refetchFilteringStats } =
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound }
{ enabled: isFilteringRound, staleTime: 0 }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
@@ -93,7 +162,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ enabled: isFilteringRound }
{ enabled: isFilteringRound, staleTime: 0 }
)
// Poll for job status when there's an active job
@@ -102,6 +171,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
staleTime: 0,
}
)
@@ -134,6 +204,30 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
},
})
// 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,
staleTime: 0,
}
)
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
// Save as template
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
onSuccess: (data) => {
@@ -180,13 +274,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)
setActiveJobId(null)
refetchFilteringStats()
refetchResults()
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])
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchResults, refetchLatestJob])
const handleStartFiltering = async () => {
try {
@@ -224,6 +319,50 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
}
}
// 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()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
}
const handleReinstate = async (projectId: string) => {
try {
await reinstateProject.mutateAsync({ roundId, projectId })
toast.success('Project reinstated')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
}
const handleRequestExportData = useCallback(async () => {
const result = await exportResults.refetch()
return result.data ?? undefined
}, [exportResults])
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
const progressPercent = jobStatus?.totalBatches
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
@@ -666,7 +805,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
{/* Stats */}
{filteringStats && filteringStats.total > 0 ? (
{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 ? (
<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">
@@ -721,6 +872,332 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
)}
{/* 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>
</>
)}
{/* Quick links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
@@ -732,12 +1209,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</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}
@@ -804,19 +1275,28 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<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>
<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" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{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>
<Button
variant="outline"
size="sm"
@@ -861,6 +1341,82 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
onOpenChange={setRemoveOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
{/* 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}
/>
</div>
)
}

View File

@@ -8,15 +8,22 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
async function SettingsLoader() {
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({
orderBy: [{ category: 'asc' }, { key: 'asc' }],
})
// Convert settings array to key-value map
// For secrets, pass a marker but not the actual value
// For non-super-admins, filter out infrastructure categories
const settingsMap: Record<string, string> = {}
settings.forEach((setting) => {
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
return
}
if (setting.isSecret && setting.value) {
// Pass marker for UI to show "existing" state
settingsMap[setting.key] = '********'
@@ -25,7 +32,7 @@ async function SettingsLoader() {
}
})
return <SettingsContent initialSettings={settingsMap} />
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
}
function SettingsSkeleton() {
@@ -52,11 +59,13 @@ function SettingsSkeleton() {
export default async function SettingsPage() {
const session = await auth()
// Only super admins can access settings
if (session?.user?.role !== 'SUPER_ADMIN') {
// Only admins (super admin + program admin) can access settings
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
redirect('/admin')
}
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
return (
<div className="space-y-6">
{/* Header */}
@@ -69,7 +78,7 @@ export default async function SettingsPage() {
{/* Content */}
<Suspense fallback={<SettingsSkeleton />}>
<SettingsLoader />
<SettingsLoader isSuperAdmin={isSuperAdmin} />
</Suspense>
</div>
)