diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index b934224..aefdb96 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -35,7 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' import { RecentEvaluations } from '@/components/dashboard/recent-evaluations' -import { SemiFinalistTracker } from '@/components/dashboard/semi-finalist-tracker' +import { RoundUserTracker } from '@/components/dashboard/round-user-tracker' type DashboardContentProps = { editionId: string @@ -126,11 +126,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro { limit: 8 }, { enabled: !!editionId, refetchInterval: 30_000 } ) - const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery( - { editionId }, - { enabled: !!editionId, refetchInterval: 120_000 } - ) - const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery() + // Round User Tracker is self-contained — it fetches its own data if (isLoading) { return @@ -277,17 +273,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro - {semiFinalistStats && semiFinalistStats.byCategory.length > 0 && ( - - - - )} + + + diff --git a/src/components/dashboard/round-user-tracker.tsx b/src/components/dashboard/round-user-tracker.tsx new file mode 100644 index 0000000..c1526f0 --- /dev/null +++ b/src/components/dashboard/round-user-tracker.tsx @@ -0,0 +1,198 @@ +'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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Users, + Send, + CheckCircle2, + AlertCircle, + Loader2, +} from 'lucide-react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' + +const categoryLabels: Record = { + STARTUP: 'Startup', + BUSINESS_CONCEPT: 'Business Concept', + UNKNOWN: 'Unknown', +} + +type RoundUserTrackerProps = { + editionId: string +} + +export function RoundUserTracker({ editionId }: RoundUserTrackerProps) { + const [selectedRoundId, setSelectedRoundId] = useState(undefined) + + const { data, isLoading } = trpc.dashboard.getRoundUserStats.useQuery( + { editionId, roundId: selectedRoundId }, + { enabled: !!editionId, refetchInterval: 120_000 } + ) + + const utils = trpc.useUtils() + const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({ + onSuccess: (result) => { + toast.success(`Sent ${result.sent} reminder${result.sent !== 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`) + utils.dashboard.getRoundUserStats.invalidate() + }, + onError: (err) => { + toast.error(`Failed to send reminders: ${err.message}`) + }, + }) + const [sendingTarget, setSendingTarget] = useState(null) + + if (isLoading || !data) return null + + const { rounds, byCategory } = data + const effectiveRoundId = data.selectedRoundId + + // Don't render if no rounds or no data + if (!effectiveRoundId || rounds.length === 0) return 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 selectedRound = rounds.find(r => r.id === effectiveRoundId) + + const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => { + setSendingTarget(target) + try { + await sendReminders.mutateAsync({ + editionId, + roundId: effectiveRoundId, + category: opts.category, + }) + } finally { + setSendingTarget(null) + } + } + + return ( + + +
+ + + Round User Tracker + + + {totalActivated}/{totalProjects} activated + +
+ {/* Round selector */} + +
+ + {/* Subtitle showing round context */} +

+ Projects that passed {selectedRound?.name ?? 'this round'} — account activation status +

+ + {/* 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 ( +
+
+ + {categoryLabels[cat.category] || cat.category} + +
+ + {cat.accountsSet} of {cat.total} activated + + {allSet ? ( + + ) : ( + + )} +
+
+ + {cat.accountsNotSet > 0 && ( +

+ + {cat.accountsNotSet} pending account setup +

+ )} +
+ ) + })} + + {/* Summary */} +
+
+ + Total: {totalActivated} of {totalProjects} activated + + {totalPending > 0 && ( + + )} +
+
+
+
+ ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 281a833..d962b86 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -390,11 +390,21 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash } - // Update last login time on actual sign-in + // Update last login time on actual sign-in, and activate INVITED users on login if (user.email) { + const loginUser = await prisma.user.findUnique({ + where: { email: user.email }, + select: { status: true }, + }) await prisma.user.update({ where: { email: user.email }, - data: { lastLoginAt: new Date() }, + data: { + lastLoginAt: new Date(), + // If user is still INVITED but successfully logged in, activate them + ...(loginUser && loginUser.status === 'INVITED' + ? { status: 'ACTIVE' } + : {}), + }, }).catch(() => { // Ignore errors from updating last login }) diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index ee0dd08..c6754ad 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -703,6 +703,117 @@ export const dashboardRouter = router({ return { byCategory, byAward, unactivatedProjects } }), + /** + * Round User Tracker: for a given round, find projects that PASSED that round + * and show team member account activation stats grouped by category. + * If no roundId is provided, uses the latest active or most recently closed round. + */ + getRoundUserStats: adminProcedure + .input(z.object({ editionId: z.string(), roundId: z.string().optional() })) + .query(async ({ ctx, input }) => { + const { editionId, roundId } = input + + // Get all rounds for this edition to power the round selector + const allRounds = await ctx.prisma.round.findMany({ + where: { competition: { programId: editionId } }, + select: { id: true, name: true, sortOrder: true, status: true, roundType: true }, + orderBy: { sortOrder: 'asc' }, + }) + + // Determine which round to show + let selectedRoundId = roundId + if (!selectedRoundId) { + // Pick latest active round, or if none, the most recently closed + const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE') + if (activeRound) { + selectedRoundId = activeRound.id + } else { + const closedRounds = allRounds.filter(r => r.status === 'ROUND_CLOSED' || r.status === 'ROUND_ARCHIVED') + if (closedRounds.length > 0) { + selectedRoundId = closedRounds[closedRounds.length - 1].id + } + } + } + + if (!selectedRoundId) { + return { rounds: allRounds, selectedRoundId: null, byCategory: [], unactivatedProjects: [] } + } + + const selectedRound = allRounds.find(r => r.id === selectedRoundId) + + // Find projects that PASSED this specific round + const passedStates = await ctx.prisma.projectRoundState.findMany({ + where: { + roundId: selectedRoundId, + state: 'PASSED', + }, + select: { + projectId: true, + project: { + select: { + id: true, + title: true, + competitionCategory: true, + teamMembers: { + select: { + user: { + select: { + id: true, + email: true, + name: true, + status: true, + passwordHash: true, + }, + }, + }, + }, + }, + }, + }, + }) + + // Group by category + const catMap = new Map() + for (const ps of passedStates) { + const cat = ps.project.competitionCategory || 'UNKNOWN' + if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 }) + const entry = catMap.get(cat)! + entry.total++ + const hasActivated = ps.project.teamMembers.some( + (tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE' + ) + if (hasActivated) entry.accountsSet++ + else entry.accountsNotSet++ + } + + const byCategory = Array.from(catMap.entries()).map(([category, counts]) => ({ + category, + ...counts, + })) + + // Unactivated projects + const unactivatedProjects = passedStates + .filter((ps) => !ps.project.teamMembers.some( + (tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE' + )) + .map((ps) => ({ + projectId: ps.projectId, + projectTitle: ps.project.title, + category: ps.project.competitionCategory, + teamEmails: ps.project.teamMembers + .filter((tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE') + .map((tm) => tm.user.email), + roundName: selectedRound?.name ?? '', + })) + + return { + rounds: allRounds, + selectedRoundId, + byCategory, + unactivatedProjects, + } + }), + /** * Get detailed semi-finalist list for the "See All" page. * Returns every project whose latest terminal state is PASSED, with team and round info. @@ -800,10 +911,11 @@ export const dashboardRouter = router({ projectIds: z.array(z.string()).optional(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), awardId: z.string().optional(), + roundId: z.string().optional(), editionId: z.string(), })) .mutation(async ({ ctx, input }) => { - const { editionId, projectIds, category, awardId } = input + const { editionId, projectIds, category, awardId, roundId } = input // Build filter for projects let targetProjectIds: string[] = projectIds ?? [] @@ -812,7 +924,13 @@ export const dashboardRouter = router({ // Find PASSED projects matching filters const passedWhere: Record = { state: 'PASSED' as const, - round: { competition: { programId: editionId } }, + } + + // If roundId is provided, scope to that specific round; otherwise edition-wide + if (roundId) { + passedWhere.roundId = roundId + } else { + passedWhere.round = { competition: { programId: editionId } } } if (category) {