Files
MOPC-Portal/src/components/layouts/admin-sidebar.tsx
Matt e2782b2b19 Add background filtering jobs, improved date picker, AI reasoning display
- Implement background job system for AI filtering to avoid HTTP timeouts
- Add FilteringJob model to track progress of long-running filtering operations
- Add real-time progress polling for filtering operations on round details page
- Create custom DateTimePicker component with calendar popup (no year picker hassle)
- Fix round date persistence bug (refetchOnWindowFocus was resetting form state)
- Integrate filtering controls into round details page for filtering rounds
- Display AI reasoning for flagged/filtered projects in results table
- Add onboarding system scaffolding (schema, routes, basic UI)
- Allow setting round dates in the past for manual overrides

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:48:41 +01:00

313 lines
9.5 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
FolderKanban,
Users,
ClipboardList,
Settings,
FileSpreadsheet,
Menu,
X,
LogOut,
ChevronRight,
BookOpen,
Handshake,
FileText,
CircleDot,
History,
Trophy,
User,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps {
user: {
name?: string | null
email?: string | null
role?: string
}
}
// Main navigation - scoped to selected edition
const navigation = [
{
name: 'Dashboard',
href: '/admin' as const,
icon: LayoutDashboard,
},
{
name: 'Rounds',
href: '/admin/rounds' as const,
icon: CircleDot,
},
{
name: 'Awards',
href: '/admin/awards' as const,
icon: Trophy,
},
{
name: 'Projects',
href: '/admin/projects' as const,
icon: ClipboardList,
},
{
name: 'Members',
href: '/admin/members' as const,
icon: Users,
},
{
name: 'Reports',
href: '/admin/reports' as const,
icon: FileSpreadsheet,
},
{
name: 'Learning Hub',
href: '/admin/learning' as const,
icon: BookOpen,
},
{
name: 'Partners',
href: '/admin/partners' as const,
icon: Handshake,
},
{
name: 'Onboarding',
href: '/admin/onboarding' as const,
icon: FileText,
},
]
// Admin-only navigation
const adminNavigation = [
{
name: 'Manage Editions',
href: '/admin/programs' as const,
icon: FolderKanban,
},
{
name: 'Audit Log',
href: '/admin/audit' as const,
icon: History,
},
{
name: 'Settings',
href: '/admin/settings' as const,
icon: Settings,
},
]
// Role display labels
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
<Logo showText textSuffix="Admin" />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
{/* Mobile menu overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-card transition-transform duration-300 lg:translate-x-0',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<Logo showText textSuffix="Admin" />
</div>
{/* Edition Selector */}
<div className="border-b px-4 py-4">
<EditionSelector />
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
{isSuperAdmin && (
<>
<Separator className="my-4" />
<div className="space-y-1">
<p className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Administration
</p>
{adminNavigation.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
</>
)}
</nav>
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{/* Avatar */}
<div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* Online indicator */}
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* User info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{roleLabel}
</p>
</div>
{/* Chevron */}
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-slate-600" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={8}
className="w-56 p-1.5"
>
{/* User info header */}
<div className="px-2 py-2.5">
<p className="text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem asChild>
<Link
href={"/settings/profile" as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<User className="h-4 w-4 text-muted-foreground" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<LogOut className="h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
)
}