feat: semi-finalist tracker dashboard, account reminders, search + UX fixes

- Add getSemiFinalistStats query with per-category/per-award breakdown
- Add sendAccountReminders mutation with invite token generation and dedup
- Add SemiFinalistTracker dashboard widget with progress bars and remind buttons
- Add ACCOUNT_REMINDER email template
- Extend project search to match team member name/email (7 locations)
- Fix Passed count deduplication: count distinct projects, not round-state rows
- Fix role switcher: visible pills above user section, auto-refresh session on mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 15:41:03 +01:00
parent af03c12ae5
commit 43e21c6c6e
10 changed files with 622 additions and 14 deletions

View File

@@ -0,0 +1,235 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Users,
Send,
CheckCircle2,
AlertCircle,
Trophy,
Loader2,
} from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type CategoryStat = {
category: string
total: number
accountsSet: number
accountsNotSet: number
}
type AwardStat = {
awardId: string
awardName: string
total: number
accountsSet: number
accountsNotSet: number
}
type UnactivatedProject = {
projectId: string
projectTitle: string
category: string | null
teamEmails: string[]
roundName: string
}
type SemiFinalistTrackerProps = {
byCategory: CategoryStat[]
byAward: AwardStat[]
unactivatedProjects: UnactivatedProject[]
editionId: string
}
const categoryLabels: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
UNKNOWN: 'Unknown',
}
export function SemiFinalistTracker({
byCategory,
byAward,
unactivatedProjects,
editionId,
}: SemiFinalistTrackerProps) {
const utils = trpc.useUtils()
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
onSuccess: (data) => {
toast.success(`Sent ${data.sent} reminder${data.sent !== 1 ? 's' : ''}${data.failed > 0 ? `, ${data.failed} failed` : ''}`)
utils.dashboard.getSemiFinalistStats.invalidate()
},
onError: (err) => {
toast.error(`Failed to send reminders: ${err.message}`)
},
})
const [sendingTarget, setSendingTarget] = useState<string | null>(null)
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
if (totalProjects === 0) return null
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT'; awardId?: string }) => {
setSendingTarget(target)
try {
await sendReminders.mutateAsync({
editionId,
category: opts.category,
awardId: opts.awardId,
})
} finally {
setSendingTarget(null)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-brand-blue" />
Semi-Finalist Tracker
</CardTitle>
<Badge variant="outline" className="text-xs">
{totalActivated}/{totalProjects} activated
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Per-category rows */}
{byCategory.map((cat) => {
const pct = cat.total > 0 ? Math.round((cat.accountsSet / cat.total) * 100) : 0
const allSet = cat.accountsNotSet === 0
return (
<div key={cat.category} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{categoryLabels[cat.category] || cat.category}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{cat.accountsSet} of {cat.total} activated
</span>
{allSet ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={sendReminders.isPending}
onClick={() =>
handleSendReminder(`cat-${cat.category}`, {
category: cat.category as 'STARTUP' | 'BUSINESS_CONCEPT',
})
}
>
{sendingTarget === `cat-${cat.category}` ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1 h-3 w-3" />
)}
Remind
</Button>
)}
</div>
</div>
<Progress value={pct} className="h-1.5" />
{cat.accountsNotSet > 0 && (
<p className="text-[11px] text-amber-600">
<AlertCircle className="mr-0.5 inline h-3 w-3" />
{cat.accountsNotSet} pending account setup
</p>
)}
</div>
)
})}
{/* Per-award rows */}
{byAward.length > 0 && (
<>
<div className="border-t pt-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Special Awards
</p>
</div>
{byAward.map((award) => {
const pct = award.total > 0 ? Math.round((award.accountsSet / award.total) * 100) : 0
const allSet = award.accountsNotSet === 0
return (
<div key={award.awardId} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1.5 font-medium">
<Trophy className="h-3.5 w-3.5 text-amber-500" />
{award.awardName}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{award.accountsSet} of {award.total}
</span>
{allSet ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={sendReminders.isPending}
onClick={() =>
handleSendReminder(`award-${award.awardId}`, {
awardId: award.awardId,
})
}
>
{sendingTarget === `award-${award.awardId}` ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1 h-3 w-3" />
)}
Remind
</Button>
)}
</div>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)
})}
</>
)}
{/* Summary */}
<div className="border-t pt-3">
<div className="flex items-center justify-between text-sm">
<span className="font-semibold">
Total: {totalActivated} of {totalProjects} team accounts activated
</span>
{totalPending > 0 && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={sendReminders.isPending}
onClick={() => handleSendReminder('all', {})}
>
{sendingTarget === 'all' ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1.5 h-3 w-3" />
)}
Remind All ({totalPending})
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
@@ -164,13 +164,21 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus } = useSession()
const { data: session, status: sessionStatus, update: updateSession } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { currentEdition } = useEdition()
// Auto-refresh session on mount to pick up role changes without requiring re-login
useEffect(() => {
if (isAuthenticated) {
updateSession()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated])
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
@@ -307,6 +315,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)}
</nav>
{/* Role Switcher — visible above user section */}
{switchableRoles.length > 0 && (
<div className="border-t px-3 py-2">
<p className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
<div className="flex flex-wrap gap-1.5">
{switchableRoles.map(([, opt]) => (
<Link key={opt.path} href={opt.path as Route} onClick={() => setIsMobileMenuOpen(false)}>
<Button size="sm" variant="outline" className="h-7 gap-1.5 px-2.5 text-xs">
<opt.icon className="h-3 w-3" />
{opt.label}
</Button>
</Link>
))}
</div>
</div>
)}
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>

View File

@@ -69,7 +69,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus } = useSession()
const { data: session, status: sessionStatus, update: updateSession } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
@@ -78,6 +78,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Auto-refresh session on mount to pick up role changes without requiring re-login
useEffect(() => {
if (isAuthenticated) {
updateSession()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated])
// Roles the user can switch to (excluding current view)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)