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:
parent
5cae78fe0c
commit
5c8d22ac11
|
|
@ -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>
|
||||
)}
|
||||
{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't been
|
||||
invited yet. Send them an invitation using the button above.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{user.status === 'INVITED' && (
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
{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 && (
|
||||
{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" />
|
||||
|
|
|
|||
|
|
@ -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,6 +1275,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -817,6 +1291,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,11 +42,16 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
|||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
NONE: 'secondary',
|
||||
ACTIVE: 'success',
|
||||
INVITED: 'secondary',
|
||||
SUSPENDED: 'destructive',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
NONE: 'Not Invited',
|
||||
}
|
||||
|
||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||
JURY_MEMBER: 'default',
|
||||
MENTOR: 'secondary',
|
||||
|
|
@ -92,6 +97,9 @@ export function MembersContent() {
|
|||
|
||||
const roles = TAB_ROLES[tab]
|
||||
|
||||
const { data: currentUser } = trpc.user.me.useQuery()
|
||||
const currentUserRole = currentUser?.role as RoleValue | undefined
|
||||
|
||||
const { data, isLoading } = trpc.user.list.useQuery({
|
||||
roles: roles,
|
||||
search: search || undefined,
|
||||
|
|
@ -216,7 +224,7 @@ export function MembersContent() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{user.status}
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -233,6 +241,8 @@ export function MembersContent() {
|
|||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
userStatus={user.status}
|
||||
userRole={user.role as RoleValue}
|
||||
currentUserRole={currentUserRole}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -263,7 +273,7 @@ export function MembersContent() {
|
|||
</div>
|
||||
</div>
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{user.status}
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -305,6 +315,8 @@ export function MembersContent() {
|
|||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
userStatus={user.status}
|
||||
userRole={user.role as RoleValue}
|
||||
currentUserRole={currentUserRole}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -28,15 +31,29 @@ import {
|
|||
UserCog,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Shield,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
}
|
||||
|
||||
interface UserActionsProps {
|
||||
userId: string
|
||||
userEmail: string
|
||||
userStatus: string
|
||||
userRole: Role
|
||||
currentUserRole?: Role
|
||||
}
|
||||
|
||||
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
|
||||
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
|
|
@ -44,13 +61,40 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
|
|||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const deleteUser = trpc.user.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate user list to refresh the members table
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
})
|
||||
const updateUser = trpc.user.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Role updated successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update role')
|
||||
},
|
||||
})
|
||||
|
||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
||||
|
||||
// Determine which roles can be assigned
|
||||
const getAvailableRoles = (): Role[] => {
|
||||
if (isSuperAdmin) {
|
||||
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
|
||||
}
|
||||
// Program admins can only assign lower roles
|
||||
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
|
||||
}
|
||||
|
||||
// Can this user's role be changed by the current user?
|
||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||
|
||||
const handleRoleChange = (newRole: Role) => {
|
||||
if (newRole === userRole) return
|
||||
updateUser.mutate({ id: userId, role: newRole })
|
||||
}
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
if (userStatus !== 'INVITED') {
|
||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||
toast.error('User has already accepted their invitation')
|
||||
return
|
||||
}
|
||||
|
|
@ -98,9 +142,31 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
|
|||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{canChangeRole && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{updateUser.isPending ? 'Updating...' : 'Change Role'}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{getAvailableRoles().map((role) => (
|
||||
<DropdownMenuItem
|
||||
key={role}
|
||||
onClick={() => handleRoleChange(role)}
|
||||
disabled={role === userRole}
|
||||
>
|
||||
{role === userRole && <Check className="mr-2 h-4 w-4" />}
|
||||
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
|
||||
{ROLE_LABELS[role]}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleSendInvitation}
|
||||
disabled={userStatus !== 'INVITED' || isSending}
|
||||
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
||||
>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
{isSending ? 'Sending...' : 'Send Invite'}
|
||||
|
|
@ -147,18 +213,35 @@ interface UserMobileActionsProps {
|
|||
userId: string
|
||||
userEmail: string
|
||||
userStatus: string
|
||||
userRole: Role
|
||||
currentUserRole?: Role
|
||||
}
|
||||
|
||||
export function UserMobileActions({
|
||||
userId,
|
||||
userEmail,
|
||||
userStatus,
|
||||
userRole,
|
||||
currentUserRole,
|
||||
}: UserMobileActionsProps) {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const updateUser = trpc.user.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Role updated successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update role')
|
||||
},
|
||||
})
|
||||
|
||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
if (userStatus !== 'INVITED') {
|
||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||
toast.error('User has already accepted their invitation')
|
||||
return
|
||||
}
|
||||
|
|
@ -175,7 +258,8 @@ export function UserMobileActions({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/members/${userId}`}>
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
|
|
@ -187,7 +271,7 @@ export function UserMobileActions({
|
|||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={handleSendInvitation}
|
||||
disabled={userStatus !== 'INVITED' || isSending}
|
||||
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
|
@ -197,5 +281,23 @@ export function UserMobileActions({
|
|||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
{canChangeRole && (
|
||||
<select
|
||||
value={userRole}
|
||||
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
|
||||
disabled={updateUser.isPending}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
{(isSuperAdmin
|
||||
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||
).map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{ROLE_LABELS[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,9 +61,10 @@ function SettingsSkeleton() {
|
|||
|
||||
interface SettingsContentProps {
|
||||
initialSettings: Record<string, string>
|
||||
isSuperAdmin?: boolean
|
||||
}
|
||||
|
||||
export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
export function SettingsContent({ initialSettings, isSuperAdmin = true }: SettingsContentProps) {
|
||||
// We use the initial settings passed from the server
|
||||
// Forms will refetch on mutation success
|
||||
|
||||
|
|
@ -168,10 +169,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<Globe className="h-4 w-4" />
|
||||
Locale
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notif.
|
||||
|
|
@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<Newspaper className="h-4 w-4" />
|
||||
Digest
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||
<Shield className="h-4 w-4" />
|
||||
Security
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Audit
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
|
|
@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="lg:flex lg:gap-8">
|
||||
|
|
@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<div>
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
|
|
@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<div>
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
Security
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Audit
|
||||
|
|
@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<div>
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
|
|
@ -274,6 +289,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<div>
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||
|
|
@ -283,12 +299,14 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="ai" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -303,6 +321,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</Card>
|
||||
<AIUsageCard />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="tags">
|
||||
<Card>
|
||||
|
|
@ -350,6 +369,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="email">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -363,6 +383,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
|
|
@ -378,6 +399,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="storage">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -391,7 +413,9 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="security">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -405,6 +429,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="defaults">
|
||||
<Card>
|
||||
|
|
@ -502,6 +527,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
|
|
@ -522,6 +548,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
|
@ -9,6 +10,9 @@ import {
|
|||
} from '../services/in-app-notification'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { sendInvitationEmail } from '@/lib/email'
|
||||
|
||||
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
// Valid project status transitions
|
||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||
|
|
@ -81,17 +85,23 @@ export const projectRouter = router({
|
|||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
// Filter by program via round
|
||||
if (programId) where.round = { programId }
|
||||
// Filter by program
|
||||
if (programId) where.programId = programId
|
||||
|
||||
// Filter by round
|
||||
if (roundId) {
|
||||
where.roundId = roundId
|
||||
}
|
||||
|
||||
// Exclude projects in a specific round
|
||||
// Exclude projects in a specific round (include unassigned projects with roundId=null)
|
||||
if (notInRoundId) {
|
||||
where.roundId = { not: notInRoundId }
|
||||
if (!where.AND) where.AND = []
|
||||
;(where.AND as unknown[]).push({
|
||||
OR: [
|
||||
{ roundId: null },
|
||||
{ roundId: { not: notInRoundId } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by unassigned (no round)
|
||||
|
|
@ -164,6 +174,91 @@ export const projectRouter = router({
|
|||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all project IDs matching filters (no pagination).
|
||||
* Used for "select all across pages" in bulk operations.
|
||||
*/
|
||||
listAllIds: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
notInRoundId: z.string().optional(),
|
||||
unassignedOnly: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
statuses: z.array(
|
||||
z.enum([
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
'ASSIGNED',
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
])
|
||||
).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
oceanIssue: z.enum([
|
||||
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
||||
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
||||
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
||||
'OCEAN_ACIDIFICATION', 'OTHER',
|
||||
]).optional(),
|
||||
country: z.string().optional(),
|
||||
wantsMentorship: z.boolean().optional(),
|
||||
hasFiles: z.boolean().optional(),
|
||||
hasAssignments: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
programId, roundId, notInRoundId, unassignedOnly,
|
||||
search, statuses, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
} = input
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (roundId) where.roundId = roundId
|
||||
if (notInRoundId) {
|
||||
if (!where.AND) where.AND = []
|
||||
;(where.AND as unknown[]).push({
|
||||
OR: [
|
||||
{ roundId: null },
|
||||
{ roundId: { not: notInRoundId } },
|
||||
],
|
||||
})
|
||||
}
|
||||
if (unassignedOnly) where.roundId = null
|
||||
if (statuses?.length) where.status = { in: statuses }
|
||||
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
||||
if (competitionCategory) where.competitionCategory = competitionCategory
|
||||
if (oceanIssue) where.oceanIssue = oceanIssue
|
||||
if (country) where.country = country
|
||||
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
|
||||
if (hasFiles === true) where.files = { some: {} }
|
||||
if (hasFiles === false) where.files = { none: {} }
|
||||
if (hasAssignments === true) where.assignments = { some: {} }
|
||||
if (hasAssignments === false) where.assignments = { none: {} }
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return { ids: projects.map((p) => p.id) }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get filter options for the project list (distinct values)
|
||||
*/
|
||||
|
|
@ -318,12 +413,21 @@ export const projectRouter = router({
|
|||
contactName: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
metadataJson: z.record(z.unknown()).optional(),
|
||||
teamMembers: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
|
||||
title: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
sendInvite: z.boolean().default(false),
|
||||
})).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const {
|
||||
metadataJson,
|
||||
contactPhone, contactEmail, contactName, city,
|
||||
teamMembers: teamMembersInput,
|
||||
...rest
|
||||
} = input
|
||||
|
||||
|
|
@ -349,7 +453,7 @@ export const projectRouter = router({
|
|||
? normalizeCountryToCode(input.country)
|
||||
: undefined
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
programId: resolvedProgramId,
|
||||
|
|
@ -369,20 +473,112 @@ export const projectRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Create team members if provided
|
||||
const inviteList: { userId: string; email: string; name: string }[] = []
|
||||
if (teamMembersInput && teamMembersInput.length > 0) {
|
||||
for (const member of teamMembersInput) {
|
||||
// Find or create user
|
||||
let user = await tx.user.findUnique({
|
||||
where: { email: member.email.toLowerCase() },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
user = await tx.user.create({
|
||||
data: {
|
||||
email: member.email.toLowerCase(),
|
||||
name: member.name,
|
||||
role: 'APPLICANT',
|
||||
status: 'NONE',
|
||||
phoneNumber: member.phone || null,
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Create TeamMember link (skip if already linked)
|
||||
await tx.teamMember.upsert({
|
||||
where: {
|
||||
projectId_userId: {
|
||||
projectId: created.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId: created.id,
|
||||
userId: user.id,
|
||||
role: member.role,
|
||||
title: member.title || null,
|
||||
},
|
||||
update: {
|
||||
role: member.role,
|
||||
title: member.title || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (member.sendInvite) {
|
||||
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: created.id,
|
||||
detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId },
|
||||
detailsJson: {
|
||||
title: input.title,
|
||||
roundId: input.roundId,
|
||||
programId: resolvedProgramId,
|
||||
teamMembersCount: teamMembersInput?.length || 0,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
return { project: created, membersToInvite: inviteList }
|
||||
})
|
||||
|
||||
// Send invite emails outside the transaction (never fail project creation)
|
||||
if (membersToInvite.length > 0) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
for (const member of membersToInvite) {
|
||||
try {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: member.userId },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/auth/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
|
||||
|
||||
// Log notification
|
||||
try {
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: member.userId,
|
||||
channel: 'EMAIL',
|
||||
type: 'JURY_INVITATION',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification logging
|
||||
}
|
||||
} catch {
|
||||
// Email sending failure should not break project creation
|
||||
console.error(`Failed to send invite to ${member.email}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return project
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export const userRouter = router({
|
|||
z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(20),
|
||||
|
|
@ -340,7 +340,7 @@ export const userRouter = router({
|
|||
id: z.string(),
|
||||
name: z.string().optional().nullable(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||
availabilityJson: z.any().optional(),
|
||||
|
|
@ -362,6 +362,14 @@ export const userRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from changing admin roles
|
||||
if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can change admin roles',
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from assigning super admin or admin role
|
||||
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
|
|
@ -708,18 +716,19 @@ export const userRouter = router({
|
|||
where: { id: input.userId },
|
||||
})
|
||||
|
||||
if (user.status !== 'INVITED') {
|
||||
if (user.status !== 'NONE' && user.status !== 'INVITED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User has already accepted their invitation',
|
||||
})
|
||||
}
|
||||
|
||||
// Generate invite token and store on user
|
||||
// Generate invite token, set status to INVITED, and store on user
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
|
|
@ -766,7 +775,7 @@ export const userRouter = router({
|
|||
const users = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: input.userIds },
|
||||
status: 'INVITED',
|
||||
status: { in: ['NONE', 'INVITED'] },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -780,11 +789,12 @@ export const userRouter = router({
|
|||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Generate invite token for each user
|
||||
// Generate invite token for each user and set status to INVITED
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue