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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user