2026-03-05 14:00:19 +01:00
|
|
|
'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<string, string> = {
|
|
|
|
|
STARTUP: 'Startup',
|
|
|
|
|
BUSINESS_CONCEPT: 'Business Concept',
|
|
|
|
|
UNKNOWN: 'Unknown',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RoundUserTrackerProps = {
|
|
|
|
|
editionId: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
|
|
|
|
const [selectedRoundId, setSelectedRoundId] = useState<string | undefined>(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<string | null>(null)
|
|
|
|
|
|
|
|
|
|
if (isLoading || !data) return null
|
|
|
|
|
|
|
|
|
|
const { rounds, byCategory } = data
|
|
|
|
|
const effectiveRoundId = data.selectedRoundId
|
|
|
|
|
|
2026-03-05 17:30:11 +01:00
|
|
|
// Don't render if no rounds at all
|
|
|
|
|
if (rounds.length === 0) return null
|
2026-03-05 14:00:19 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-05 17:30:11 +01:00
|
|
|
const selectedRound = effectiveRoundId ? rounds.find(r => r.id === effectiveRoundId) : undefined
|
2026-03-05 14:00:19 +01:00
|
|
|
|
|
|
|
|
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
|
|
|
|
|
setSendingTarget(target)
|
|
|
|
|
try {
|
|
|
|
|
await sendReminders.mutateAsync({
|
|
|
|
|
editionId,
|
2026-03-05 17:30:11 +01:00
|
|
|
roundId: effectiveRoundId!,
|
2026-03-05 14:00:19 +01:00
|
|
|
category: opts.category,
|
|
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
setSendingTarget(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
|
|
|
<Users className="h-4 w-4 text-brand-blue" />
|
|
|
|
|
Round User Tracker
|
|
|
|
|
</CardTitle>
|
2026-03-05 17:30:11 +01:00
|
|
|
{totalProjects > 0 && (
|
|
|
|
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
|
|
|
{totalActivated}/{totalProjects} activated
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
2026-03-05 14:00:19 +01:00
|
|
|
</div>
|
|
|
|
|
{/* Round selector */}
|
|
|
|
|
<Select
|
2026-03-05 17:30:11 +01:00
|
|
|
value={effectiveRoundId ?? ''}
|
2026-03-05 14:00:19 +01:00
|
|
|
onValueChange={(val) => setSelectedRoundId(val)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs mt-2">
|
|
|
|
|
<SelectValue placeholder="Select round" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{rounds.map((r) => (
|
|
|
|
|
<SelectItem key={r.id} value={r.id} className="text-xs">
|
|
|
|
|
{r.name}
|
|
|
|
|
{r.status === 'ROUND_ACTIVE' && (
|
|
|
|
|
<span className="ml-1.5 text-emerald-600">(active)</span>
|
|
|
|
|
)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
2026-03-05 17:30:11 +01:00
|
|
|
{totalProjects === 0 ? (
|
|
|
|
|
<div className="text-center py-4">
|
|
|
|
|
<Users className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
No projects have passed {selectedRound?.name ?? 'this round'} yet
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-03-05 14:00:19 +01:00
|
|
|
{/* Subtitle showing round context */}
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> — account activation status
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Summary */}
|
|
|
|
|
<div className="border-t pt-3">
|
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
|
<span className="font-semibold">
|
|
|
|
|
Total: {totalActivated} of {totalProjects} 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>
|
2026-03-05 17:30:11 +01:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-03-05 14:00:19 +01:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
}
|