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:
235
src/components/dashboard/semi-finalist-tracker.tsx
Normal file
235
src/components/dashboard/semi-finalist-tracker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user