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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user