Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes

- Round detail: add skeleton loading for filtering stats, inline results table
  with expandable rows, pagination, override/reinstate, CSV export, and tooltip
  on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
  flags with tooltip instead of country codes (table + card views), add listAllIds
  backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
  tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
  role hierarchy (only super admins can modify admin/super-admin roles) in both
  backend and UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:07:38 +01:00
parent 5cae78fe0c
commit 5c8d22ac11
9 changed files with 1257 additions and 197 deletions

View File

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