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 [name, setName] = useState('')
|
||||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
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 [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
|
||||||
|
|
@ -96,7 +96,7 @@ export default function MemberDetailPage() {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
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,
|
expertiseTags,
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
})
|
})
|
||||||
|
|
@ -180,11 +180,11 @@ export default function MemberDetailPage() {
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<p className="text-muted-foreground">{user.email}</p>
|
<p className="text-muted-foreground">{user.email}</p>
|
||||||
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
||||||
{user.status}
|
{user.status === 'NONE' ? 'Not Invited' : user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user.status === 'INVITED' && (
|
{(user.status === 'NONE' || user.status === 'INVITED') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSendInvitation}
|
onClick={handleSendInvitation}
|
||||||
|
|
@ -235,6 +235,7 @@ export default function MemberDetailPage() {
|
||||||
setRole(v)
|
setRole(v)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="role">
|
<SelectTrigger id="role">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -243,7 +244,9 @@ export default function MemberDetailPage() {
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
|
<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="JURY_MEMBER">Jury Member</SelectItem>
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
|
@ -257,6 +260,7 @@ export default function MemberDetailPage() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="NONE">Not Invited</SelectItem>
|
||||||
<SelectItem value="INVITED">Invited</SelectItem>
|
<SelectItem value="INVITED">Invited</SelectItem>
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
|
@ -379,6 +383,16 @@ export default function MemberDetailPage() {
|
||||||
<UserActivityLog userId={userId} />
|
<UserActivityLog userId={userId} />
|
||||||
|
|
||||||
{/* Status Alert */}
|
{/* 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' && (
|
{user.status === 'INVITED' && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,17 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries'
|
||||||
import {
|
import {
|
||||||
ProjectFiltersBar,
|
ProjectFiltersBar,
|
||||||
type ProjectFilters,
|
type ProjectFilters,
|
||||||
|
|
@ -375,6 +382,7 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
// Bulk selection state
|
// Bulk selection state
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
|
||||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||||
|
|
@ -382,10 +390,52 @@ export default function ProjectsPage() {
|
||||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = 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({
|
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
setAllMatchingSelected(false)
|
||||||
setBulkStatus('')
|
setBulkStatus('')
|
||||||
setBulkConfirmOpen(false)
|
setBulkConfirmOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
|
|
@ -399,6 +449,7 @@ export default function ProjectsPage() {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
setAllMatchingSelected(false)
|
||||||
setBulkAssignRoundId('')
|
setBulkAssignRoundId('')
|
||||||
setBulkAssignDialogOpen(false)
|
setBulkAssignDialogOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
|
|
@ -412,6 +463,7 @@ export default function ProjectsPage() {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
|
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
setAllMatchingSelected(false)
|
||||||
setBulkDeleteConfirmOpen(false)
|
setBulkDeleteConfirmOpen(false)
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
},
|
},
|
||||||
|
|
@ -421,6 +473,7 @@ export default function ProjectsPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleToggleSelect = (id: string) => {
|
const handleToggleSelect = (id: string) => {
|
||||||
|
setAllMatchingSelected(false)
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
|
|
@ -434,6 +487,7 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
setAllMatchingSelected(false)
|
||||||
const allVisible = data.projects.map((p) => p.id)
|
const allVisible = data.projects.map((p) => p.id)
|
||||||
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
||||||
if (allSelected) {
|
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 = () => {
|
const handleBulkApply = () => {
|
||||||
if (!bulkStatus || selectedIds.size === 0) return
|
if (!bulkStatus || selectedIds.size === 0) return
|
||||||
setBulkConfirmOpen(true)
|
setBulkConfirmOpen(true)
|
||||||
|
|
@ -620,6 +688,47 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -728,9 +837,23 @@ export default function ProjectsPage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.teamName}
|
{project.teamName}
|
||||||
{project.country && (
|
{project.country && (() => {
|
||||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -983,9 +1106,23 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="mt-0.5">
|
<CardDescription className="mt-0.5">
|
||||||
{project.teamName}
|
{project.teamName}
|
||||||
{project.country && (
|
{project.country && (() => {
|
||||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1118,10 +1255,7 @@ export default function ProjectsPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={handleClearSelection}
|
||||||
setSelectedIds(new Set())
|
|
||||||
setBulkStatus('')
|
|
||||||
}}
|
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-4 w-4" />
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState, useEffect } from 'react'
|
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
@ -16,6 +16,31 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Separator } from '@/components/ui/separator'
|
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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -27,6 +52,12 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
Edit,
|
||||||
|
|
@ -52,13 +83,39 @@ import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
|
ShieldCheck,
|
||||||
|
Download,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||||
import { RemoveProjectsDialog } from '@/components/admin/remove-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'
|
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 {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +127,18 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const [removeOpen, setRemoveOpen] = useState(false)
|
const [removeOpen, setRemoveOpen] = useState(false)
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
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: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
const { data: progress } = trpc.round.getProgress.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'
|
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||||
|
|
||||||
// Filtering queries (only fetch for FILTERING rounds)
|
// Filtering queries (only fetch for FILTERING rounds)
|
||||||
const { data: filteringStats, refetch: refetchFilteringStats } =
|
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
||||||
trpc.filtering.getResultStats.useQuery(
|
trpc.filtering.getResultStats.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: isFilteringRound }
|
{ enabled: isFilteringRound, staleTime: 0 }
|
||||||
)
|
)
|
||||||
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
|
|
@ -93,7 +162,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const { data: latestJob, refetch: refetchLatestJob } =
|
const { data: latestJob, refetch: refetchLatestJob } =
|
||||||
trpc.filtering.getLatestJob.useQuery(
|
trpc.filtering.getLatestJob.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: isFilteringRound }
|
{ enabled: isFilteringRound, staleTime: 0 }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poll for job status when there's an active job
|
// Poll for job status when there's an active job
|
||||||
|
|
@ -102,6 +171,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
{
|
{
|
||||||
enabled: !!activeJobId,
|
enabled: !!activeJobId,
|
||||||
refetchInterval: activeJobId ? 2000 : false,
|
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
|
// Save as template
|
||||||
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
|
@ -180,13 +274,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
)
|
)
|
||||||
setActiveJobId(null)
|
setActiveJobId(null)
|
||||||
refetchFilteringStats()
|
refetchFilteringStats()
|
||||||
|
refetchResults()
|
||||||
refetchLatestJob()
|
refetchLatestJob()
|
||||||
} else if (jobStatus?.status === 'FAILED') {
|
} else if (jobStatus?.status === 'FAILED') {
|
||||||
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
||||||
setActiveJobId(null)
|
setActiveJobId(null)
|
||||||
refetchLatestJob()
|
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 () => {
|
const handleStartFiltering = async () => {
|
||||||
try {
|
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 isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
||||||
const progressPercent = jobStatus?.totalBatches
|
const progressPercent = jobStatus?.totalBatches
|
||||||
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
||||||
|
|
@ -666,7 +805,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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="grid gap-4 sm:grid-cols-4">
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
<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">
|
<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>
|
</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 */}
|
{/* Quick links */}
|
||||||
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
|
|
@ -732,12 +1209,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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 && (
|
{filteringStats && filteringStats.total > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinalizeFiltering}
|
onClick={handleFinalizeFiltering}
|
||||||
|
|
@ -804,19 +1275,28 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Jury Assignments
|
Jury Assignments
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant="outline"
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
<Button
|
||||||
disabled={bulkSummaries.isPending}
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
{bulkSummaries.isPending ? (
|
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
disabled={bulkSummaries.isPending}
|
||||||
) : (
|
>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
{bulkSummaries.isPending ? (
|
||||||
)}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
) : (
|
||||||
</Button>
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -861,6 +1341,82 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
onOpenChange={setRemoveOpen}
|
onOpenChange={setRemoveOpen}
|
||||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,22 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { SettingsContent } from '@/components/settings/settings-content'
|
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({
|
const settings = await prisma.systemSettings.findMany({
|
||||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert settings array to key-value map
|
// Convert settings array to key-value map
|
||||||
// For secrets, pass a marker but not the actual value
|
// For secrets, pass a marker but not the actual value
|
||||||
|
// For non-super-admins, filter out infrastructure categories
|
||||||
const settingsMap: Record<string, string> = {}
|
const settingsMap: Record<string, string> = {}
|
||||||
settings.forEach((setting) => {
|
settings.forEach((setting) => {
|
||||||
|
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (setting.isSecret && setting.value) {
|
if (setting.isSecret && setting.value) {
|
||||||
// Pass marker for UI to show "existing" state
|
// Pass marker for UI to show "existing" state
|
||||||
settingsMap[setting.key] = '********'
|
settingsMap[setting.key] = '********'
|
||||||
|
|
@ -25,7 +32,7 @@ async function SettingsLoader() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return <SettingsContent initialSettings={settingsMap} />
|
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSkeleton() {
|
function SettingsSkeleton() {
|
||||||
|
|
@ -52,11 +59,13 @@ function SettingsSkeleton() {
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
// Only super admins can access settings
|
// Only admins (super admin + program admin) can access settings
|
||||||
if (session?.user?.role !== 'SUPER_ADMIN') {
|
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -69,7 +78,7 @@ export default async function SettingsPage() {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<SettingsSkeleton />}>
|
<Suspense fallback={<SettingsSkeleton />}>
|
||||||
<SettingsLoader />
|
<SettingsLoader isSuperAdmin={isSuperAdmin} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,16 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||||
|
NONE: 'secondary',
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
INVITED: 'secondary',
|
INVITED: 'secondary',
|
||||||
SUSPENDED: 'destructive',
|
SUSPENDED: 'destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
NONE: 'Not Invited',
|
||||||
|
}
|
||||||
|
|
||||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
JURY_MEMBER: 'default',
|
JURY_MEMBER: 'default',
|
||||||
MENTOR: 'secondary',
|
MENTOR: 'secondary',
|
||||||
|
|
@ -92,6 +97,9 @@ export function MembersContent() {
|
||||||
|
|
||||||
const roles = TAB_ROLES[tab]
|
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({
|
const { data, isLoading } = trpc.user.list.useQuery({
|
||||||
roles: roles,
|
roles: roles,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
|
@ -216,7 +224,7 @@ export function MembersContent() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
{user.status}
|
{statusLabels[user.status] || user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -233,6 +241,8 @@ export function MembersContent() {
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
|
userRole={user.role as RoleValue}
|
||||||
|
currentUserRole={currentUserRole}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -263,7 +273,7 @@ export function MembersContent() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||||
{user.status}
|
{statusLabels[user.status] || user.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -305,6 +315,8 @@ export function MembersContent() {
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
|
userRole={user.role as RoleValue}
|
||||||
|
currentUserRole={currentUserRole}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,15 +31,29 @@ import {
|
||||||
UserCog,
|
UserCog,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Shield,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} 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 {
|
interface UserActionsProps {
|
||||||
userId: string
|
userId: string
|
||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: 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 [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isSending, setIsSending] = 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 sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
const deleteUser = trpc.user.delete.useMutation({
|
const deleteUser = trpc.user.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate user list to refresh the members table
|
|
||||||
utils.user.list.invalidate()
|
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 () => {
|
const handleSendInvitation = async () => {
|
||||||
if (userStatus !== 'INVITED') {
|
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||||
toast.error('User has already accepted their invitation')
|
toast.error('User has already accepted their invitation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -98,9 +142,31 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
onClick={handleSendInvitation}
|
onClick={handleSendInvitation}
|
||||||
disabled={userStatus !== 'INVITED' || isSending}
|
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
||||||
>
|
>
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
{isSending ? 'Sending...' : 'Send Invite'}
|
{isSending ? 'Sending...' : 'Send Invite'}
|
||||||
|
|
@ -147,18 +213,35 @@ interface UserMobileActionsProps {
|
||||||
userId: string
|
userId: string
|
||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: string
|
userStatus: string
|
||||||
|
userRole: Role
|
||||||
|
currentUserRole?: Role
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMobileActions({
|
export function UserMobileActions({
|
||||||
userId,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
userStatus,
|
userStatus,
|
||||||
|
userRole,
|
||||||
|
currentUserRole,
|
||||||
}: UserMobileActionsProps) {
|
}: UserMobileActionsProps) {
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
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 () => {
|
const handleSendInvitation = async () => {
|
||||||
if (userStatus !== 'INVITED') {
|
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||||
toast.error('User has already accepted their invitation')
|
toast.error('User has already accepted their invitation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -175,27 +258,46 @@ export function UserMobileActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
<div className="flex gap-2">
|
||||||
<Link href={`/admin/members/${userId}`}>
|
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||||
<UserCog className="mr-2 h-4 w-4" />
|
<Link href={`/admin/members/${userId}`}>
|
||||||
Edit
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Edit
|
||||||
</Button>
|
</Link>
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="flex-1"
|
size="sm"
|
||||||
onClick={handleSendInvitation}
|
className="flex-1"
|
||||||
disabled={userStatus !== 'INVITED' || isSending}
|
onClick={handleSendInvitation}
|
||||||
>
|
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
|
||||||
{isSending ? (
|
>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
{isSending ? (
|
||||||
) : (
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
) : (
|
||||||
)}
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
Invite
|
)}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,10 @@ function SettingsSkeleton() {
|
||||||
|
|
||||||
interface SettingsContentProps {
|
interface SettingsContentProps {
|
||||||
initialSettings: Record<string, string>
|
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
|
// We use the initial settings passed from the server
|
||||||
// Forms will refetch on mutation success
|
// Forms will refetch on mutation success
|
||||||
|
|
||||||
|
|
@ -168,10 +169,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
Locale
|
Locale
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="email" className="gap-2 shrink-0">
|
{isSuperAdmin && (
|
||||||
<Mail className="h-4 w-4" />
|
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||||
Email
|
<Mail className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notif.
|
Notif.
|
||||||
|
|
@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
{isSuperAdmin && (
|
||||||
<Shield className="h-4 w-4" />
|
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||||
Security
|
<Shield className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
Audit
|
Audit
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
{isSuperAdmin && (
|
||||||
<Bot className="h-4 w-4" />
|
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
||||||
AI
|
<Bot className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
AI
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
||||||
<Tags className="h-4 w-4" />
|
<Tags className="h-4 w-4" />
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Analytics
|
Analytics
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
{isSuperAdmin && (
|
||||||
<HardDrive className="h-4 w-4" />
|
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
||||||
Storage
|
<HardDrive className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Storage
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
|
|
@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
|
<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">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
{isSuperAdmin && (
|
||||||
<Mail className="h-4 w-4" />
|
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
Email
|
<Mail className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<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" />
|
<Bell className="h-4 w-4" />
|
||||||
Notifications
|
Notifications
|
||||||
|
|
@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
|
<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">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
{isSuperAdmin && (
|
||||||
<Shield className="h-4 w-4" />
|
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
Security
|
<Shield className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<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" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
Audit
|
Audit
|
||||||
|
|
@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
|
<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">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
{isSuperAdmin && (
|
||||||
<Bot className="h-4 w-4" />
|
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
AI
|
<Bot className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
AI
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<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 className="h-4 w-4" />
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -274,35 +289,39 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{isSuperAdmin && (
|
||||||
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
<div>
|
||||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
||||||
<TabsTrigger value="storage" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
<HardDrive className="h-4 w-4" />
|
<TabsTrigger value="storage" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
Storage
|
<HardDrive className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Storage
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
||||||
<TabsContent value="ai" className="space-y-6">
|
{isSuperAdmin && (
|
||||||
<Card>
|
<TabsContent value="ai" className="space-y-6">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>AI Configuration</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>AI Configuration</CardTitle>
|
||||||
Configure AI-powered features like smart jury assignment
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure AI-powered features like smart jury assignment
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<AISettingsForm settings={aiSettings} />
|
<CardContent>
|
||||||
</CardContent>
|
<AISettingsForm settings={aiSettings} />
|
||||||
</Card>
|
</CardContent>
|
||||||
<AIUsageCard />
|
</Card>
|
||||||
</TabsContent>
|
<AIUsageCard />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="tags">
|
<TabsContent value="tags">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -350,19 +369,21 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="email">
|
{isSuperAdmin && (
|
||||||
<Card>
|
<TabsContent value="email">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Email Configuration</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>Email Configuration</CardTitle>
|
||||||
Configure email settings for notifications and magic links
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure email settings for notifications and magic links
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<EmailSettingsForm settings={emailSettings} />
|
<CardContent>
|
||||||
</CardContent>
|
<EmailSettingsForm settings={emailSettings} />
|
||||||
</Card>
|
</CardContent>
|
||||||
</TabsContent>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="notifications">
|
<TabsContent value="notifications">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -378,33 +399,37 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="storage">
|
{isSuperAdmin && (
|
||||||
<Card>
|
<TabsContent value="storage">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>File Storage</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>File Storage</CardTitle>
|
||||||
Configure file upload limits and allowed types
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure file upload limits and allowed types
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<StorageSettingsForm settings={storageSettings} />
|
<CardContent>
|
||||||
</CardContent>
|
<StorageSettingsForm settings={storageSettings} />
|
||||||
</Card>
|
</CardContent>
|
||||||
</TabsContent>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="security">
|
{isSuperAdmin && (
|
||||||
<Card>
|
<TabsContent value="security">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Security Settings</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>Security Settings</CardTitle>
|
||||||
Configure security and access control settings
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure security and access control settings
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<SecuritySettingsForm settings={securitySettings} />
|
<CardContent>
|
||||||
</CardContent>
|
<SecuritySettingsForm settings={securitySettings} />
|
||||||
</Card>
|
</CardContent>
|
||||||
</TabsContent>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="defaults">
|
<TabsContent value="defaults">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -502,26 +527,28 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{isSuperAdmin && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardHeader>
|
||||||
<Webhook className="h-4 w-4" />
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
Webhooks
|
<Webhook className="h-4 w-4" />
|
||||||
</CardTitle>
|
Webhooks
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Configure webhook endpoints for platform events
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure webhook endpoints for platform events
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<Button asChild>
|
<CardContent>
|
||||||
<Link href="/admin/settings/webhooks">
|
<Button asChild>
|
||||||
<Webhook className="mr-2 h-4 w-4" />
|
<Link href="/admin/settings/webhooks">
|
||||||
Manage Webhooks
|
<Webhook className="mr-2 h-4 w-4" />
|
||||||
<ExternalLink className="ml-2 h-3 w-3" />
|
Manage Webhooks
|
||||||
</Link>
|
<ExternalLink className="ml-2 h-3 w-3" />
|
||||||
</Button>
|
</Link>
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
@ -9,6 +10,9 @@ import {
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { logAudit } from '../utils/audit'
|
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
|
// Valid project status transitions
|
||||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
|
@ -81,17 +85,23 @@ export const projectRouter = router({
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
// Filter by program via round
|
// Filter by program
|
||||||
if (programId) where.round = { programId }
|
if (programId) where.programId = programId
|
||||||
|
|
||||||
// Filter by round
|
// Filter by round
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
where.roundId = roundId
|
where.roundId = roundId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude projects in a specific round
|
// Exclude projects in a specific round (include unassigned projects with roundId=null)
|
||||||
if (notInRoundId) {
|
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)
|
// 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)
|
* Get filter options for the project list (distinct values)
|
||||||
*/
|
*/
|
||||||
|
|
@ -318,12 +413,21 @@ export const projectRouter = router({
|
||||||
contactName: z.string().optional(),
|
contactName: z.string().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
metadataJson: z.record(z.unknown()).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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const {
|
const {
|
||||||
metadataJson,
|
metadataJson,
|
||||||
contactPhone, contactEmail, contactName, city,
|
contactPhone, contactEmail, contactName, city,
|
||||||
|
teamMembers: teamMembersInput,
|
||||||
...rest
|
...rest
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
|
|
@ -349,7 +453,7 @@ export const projectRouter = router({
|
||||||
? normalizeCountryToCode(input.country)
|
? normalizeCountryToCode(input.country)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.project.create({
|
const created = await tx.project.create({
|
||||||
data: {
|
data: {
|
||||||
programId: resolvedProgramId,
|
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({
|
await logAudit({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
entityType: 'Project',
|
entityType: 'Project',
|
||||||
entityId: created.id,
|
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,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
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
|
return project
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
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(),
|
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(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(100).default(20),
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
|
|
@ -340,7 +340,7 @@ export const userRouter = router({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
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(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
availabilityJson: z.any().optional(),
|
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
|
// Prevent non-super-admins from assigning super admin or admin role
|
||||||
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -708,18 +716,19 @@ export const userRouter = router({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (user.status !== 'INVITED') {
|
if (user.status !== 'NONE' && user.status !== 'INVITED') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'User has already accepted their invitation',
|
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()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
|
status: 'INVITED',
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||||
},
|
},
|
||||||
|
|
@ -766,7 +775,7 @@ export const userRouter = router({
|
||||||
const users = await ctx.prisma.user.findMany({
|
const users = await ctx.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: input.userIds },
|
id: { in: input.userIds },
|
||||||
status: 'INVITED',
|
status: { in: ['NONE', 'INVITED'] },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -780,11 +789,12 @@ export const userRouter = router({
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
try {
|
try {
|
||||||
// Generate invite token for each user
|
// Generate invite token for each user and set status to INVITED
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
|
status: 'INVITED',
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue