feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s

- Observer projects: default sort by status (rejected last), sortable status column
- Observer projects: search by country, institution, geographic zone
- Observer project detail: vertical timeline connectors between rounds
- Fix React key warning in ExpandableJurorTable and FilteringReportTabs
- Fix ScoreBadge text always white for better contrast on all backgrounds
- Remove misleading /30 denominator from heatmap juror reviewed count
- INTAKE stats: show Start-ups, Business Concepts, Countries (not States/Categories)
- DiversityMetrics: extractCountry() for country-only display in charts
- Fix nested button hydration error in filtering report mobile view
- Color project titles by outcome in filtering report (green/red/amber)
- Redesign CrossStageComparisonChart: funnel viz + metrics table with attrition %
- Center doughnut chart in StatusBreakdownChart
- Remove redundant RoundTypeStatsCards from evaluation report
- Move evaluation tab bar below overview header, rename to "Juror Assignments"
- Dev email override system (DEV_EMAIL_OVERRIDE env var)
- Session refresh on role change without re-login
- Role switcher in user dropdown menu
- formatCategory() utility for consistent category display
- Activity feed max height constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:37:50 +01:00
parent e7b99fff63
commit a556732b46
23 changed files with 2108 additions and 326 deletions

1732
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@react-grab/mcp": "^0.1.25",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
@@ -110,6 +111,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react-grab": "^0.1.25",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2", "tsx": "^4.19.2",

View File

@@ -650,6 +650,8 @@ function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean })
<div className="flex"> <div className="flex">
{/* Left: pipeline track */} {/* Left: pipeline track */}
<div className="flex flex-col items-center shrink-0 w-10"> <div className="flex flex-col items-center shrink-0 w-10">
{/* Spacer to vertically center dot with the round card (py-2.5 + half line height) */}
<div className="h-[18px] shrink-0" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="relative z-10 flex items-center justify-center"> <div className="relative z-10 flex items-center justify-center">
@@ -688,7 +690,7 @@ function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean })
style={{ borderLeftColor: typeColors.dot }} style={{ borderLeftColor: typeColors.dot }}
> >
<span className={cn( <span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]', 'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[88px]',
typeColors.text typeColors.text
)}> )}>
{round.roundType.replace('_', ' ')} {round.roundType.replace('_', ' ')}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Script from "next/script";
import './globals.css' import './globals.css'
import { Providers } from './providers' import { Providers } from './providers'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
@@ -23,6 +24,21 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head>
{process.env.NODE_ENV === "development" && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
{process.env.NODE_ENV === "development" && (
<Script
src="//unpkg.com/@react-grab/mcp/dist/client.global.js"
strategy="lazyOnload"
/>
)}
</head>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers> <Providers>
<VersionGuard /> <VersionGuard />

View File

@@ -1,7 +1,18 @@
'use client' 'use client'
import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { scoreGradient } from './chart-theme'
import { ArrowRight } from 'lucide-react'
interface StageComparison { interface StageComparison {
roundId: string roundId: string
@@ -30,99 +41,115 @@ export function CrossStageComparisonChart({
) )
} }
const baseData = data.map((round) => ({ const maxProjects = Math.max(...data.map((d) => d.projectCount), 1)
name: round.roundName,
Projects: round.projectCount,
Evaluations: round.evaluationCount,
'Completion Rate': round.completionRate,
'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Round Metrics Comparison</CardTitle> <CardTitle className="text-base">Round Progression</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> {/* Pipeline funnel visualization */}
<Card> <div className="flex items-center gap-2 mb-6 overflow-x-auto pb-2">
<CardHeader className="pb-2"> {data.map((round, idx) => (
<CardTitle className="text-sm font-medium">Projects</CardTitle> <div key={round.roundId} className="flex items-center gap-2">
</CardHeader> <div className="flex flex-col items-center min-w-[100px]">
<CardContent className="pt-0"> <div
<BarChart className="rounded-lg bg-[#053d57] flex items-center justify-center text-white font-bold text-lg tabular-nums transition-all"
data={baseData} style={{
index="name" width: `${Math.max(60, (round.projectCount / maxProjects) * 120)}px`,
categories={['Projects']} height: `${Math.max(40, (round.projectCount / maxProjects) * 60)}px`,
colors={['blue']} }}
showLegend={false} >
yAxisWidth={40} {round.projectCount}
className="h-[200px]" </div>
/> <p className="text-xs text-muted-foreground mt-1.5 text-center leading-tight max-w-[100px] truncate">
</CardContent> {round.roundName}
</Card> </p>
</div>
{idx < data.length - 1 && (
<div className="flex flex-col items-center shrink-0">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{data[idx + 1].projectCount < round.projectCount && (
<span className="text-[10px] text-rose-500 tabular-nums font-medium">
-{round.projectCount - data[idx + 1].projectCount}
</span>
)}
</div>
)}
</div>
))}
</div>
<Card> {/* Detailed metrics table */}
<CardHeader className="pb-2"> <div className="rounded-md border">
<CardTitle className="text-sm font-medium"> <Table>
Evaluations <TableHeader>
</CardTitle> <TableRow>
</CardHeader> <TableHead>Round</TableHead>
<CardContent className="pt-0"> <TableHead className="text-right tabular-nums">Projects</TableHead>
<BarChart <TableHead className="text-right tabular-nums">Evaluations</TableHead>
data={baseData} <TableHead>Completion</TableHead>
index="name" <TableHead className="text-right">Avg Score</TableHead>
categories={['Evaluations']} </TableRow>
colors={['violet']} </TableHeader>
showLegend={false} <TableBody>
yAxisWidth={40} {data.map((round, idx) => {
className="h-[200px]" const prevCount = idx > 0 ? data[idx - 1].projectCount : null
/> const attrition = prevCount !== null && prevCount > 0
</CardContent> ? Math.round(((prevCount - round.projectCount) / prevCount) * 100)
</Card> : null
<Card> return (
<CardHeader className="pb-2"> <TableRow key={round.roundId}>
<CardTitle className="text-sm font-medium"> <TableCell>
Completion Rate <div className="flex items-center gap-2">
</CardTitle> <span className="font-medium text-sm">{round.roundName}</span>
</CardHeader> {attrition !== null && attrition > 0 && (
<CardContent className="pt-0"> <Badge variant="outline" className="text-[10px] text-rose-600 border-rose-200">
<BarChart -{attrition}%
data={baseData} </Badge>
index="name" )}
categories={['Completion Rate']} </div>
colors={['emerald']} </TableCell>
showLegend={false} <TableCell className="text-right tabular-nums font-medium">
maxValue={100} {round.projectCount}
yAxisWidth={40} </TableCell>
valueFormatter={(v) => `${v}%`} <TableCell className="text-right tabular-nums">
className="h-[200px]" {round.evaluationCount > 0 ? round.evaluationCount : '—'}
/> </TableCell>
</CardContent> <TableCell>
</Card> {round.evaluationCount > 0 ? (
<div className="flex items-center gap-2">
<Card> <Progress value={round.completionRate} className="w-16 h-2" />
<CardHeader className="pb-2"> <span className="text-xs tabular-nums text-muted-foreground">
<CardTitle className="text-sm font-medium"> {round.completionRate}%
Average Score </span>
</CardTitle> </div>
</CardHeader> ) : (
<CardContent className="pt-0"> <span className="text-xs text-muted-foreground"></span>
<BarChart )}
data={baseData} </TableCell>
index="name" <TableCell className="text-right">
categories={['Avg Score']} {round.averageScore !== null ? (
colors={['amber']} <span
showLegend={false} className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
maxValue={10} style={{
yAxisWidth={40} backgroundColor: scoreGradient(round.averageScore),
className="h-[200px]" color: '#ffffff',
/> }}
</CardContent> >
</Card> {round.averageScore.toFixed(1)}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -20,7 +20,7 @@ function getScoreColor(score: number | null): string {
function getTextColor(score: number | null): string { function getTextColor(score: number | null): string {
if (score === null) return 'inherit' if (score === null) return 'inherit'
return score >= 6 ? '#ffffff' : '#1a1a1a' return '#ffffff'
} }
function ScoreBadge({ score }: { score: number }) { function ScoreBadge({ score }: { score: number }) {
@@ -73,7 +73,6 @@ function JurorSummaryRow({
</td> </td>
<td className="py-3 px-4 text-center tabular-nums text-sm"> <td className="py-3 px-4 text-center tabular-nums text-sm">
{scored.length} {scored.length}
<span className="text-muted-foreground">/{projectCount}</span>
</td> </td>
<td className="py-3 px-4 text-center"> <td className="py-3 px-4 text-center">
{averageScore !== null ? ( {averageScore !== null ? (

View File

@@ -36,14 +36,16 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-center">
<DonutChart <DonutChart
data={chartData} data={chartData}
category="value" category="value"
index="name" index="name"
colors={colors} colors={colors}
showLabel={true} showLabel={true}
className="h-[300px]" className="h-[250px] w-[250px]"
/> />
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -23,8 +23,8 @@ type ActivityFeedProps = {
export function ActivityFeed({ activity }: ActivityFeedProps) { export function ActivityFeed({ activity }: ActivityFeedProps) {
return ( return (
<Card> <Card className="flex flex-col overflow-hidden" style={{ maxHeight: 420 }}>
<CardHeader className="pb-3"> <CardHeader className="pb-3 shrink-0">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<Activity className="h-4 w-4 text-brand-blue" /> <Activity className="h-4 w-4 text-brand-blue" />
@@ -32,7 +32,7 @@ export function ActivityFeed({ activity }: ActivityFeedProps) {
<CardTitle className="text-base">Activity</CardTitle> <CardTitle className="text-base">Activity</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="overflow-y-auto min-h-0">
{activity.length === 0 ? ( {activity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/30" /> <Activity className="h-8 w-8 text-muted-foreground/30" />

View File

@@ -12,6 +12,9 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -315,26 +318,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)} )}
</nav> </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 */} {/* User Profile Section */}
<div className="border-t p-3"> <div className="border-t p-3">
<DropdownMenu> <DropdownMenu>
@@ -393,12 +376,27 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{switchableRoles.length > 0 && ( {switchableRoles.length > 0 && (
<> <>
<DropdownMenuSeparator className="my-1" /> <DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5"> {switchableRoles.length <= 2 ? (
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60"> // Flat list for 1-2 roles
<ArrowRightLeft className="h-3 w-3" /> switchableRoles.map(([, opt]) => (
Switch View <DropdownMenuItem key={opt.path} asChild>
</p> <Link
</div> href={opt.path as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<opt.icon className="h-4 w-4 text-muted-foreground" />
<span>{opt.label}</span>
</Link>
</DropdownMenuItem>
))
) : (
// Submenu for 3+ roles
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2.5 rounded-md px-2 py-2">
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
<span>Switch View</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[160px]">
{switchableRoles.map(([, opt]) => ( {switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild> <DropdownMenuItem key={opt.path} asChild>
<Link <Link
@@ -410,6 +408,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</> </>
)} )}

View File

@@ -17,7 +17,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react' import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn, formatCategory } from '@/lib/utils'
type AIScreeningData = { type AIScreeningData = {
meetsCriteria?: boolean meetsCriteria?: boolean
@@ -200,7 +200,7 @@ export function FilteringPanel({ roundId }: { roundId: string }) {
{r.project?.title ?? 'Unknown'} {r.project?.title ?? 'Unknown'}
</Link> </Link>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{r.project?.competitionCategory ?? ''} · {r.project?.country ?? ''} {formatCategory(r.project?.competitionCategory)} · {r.project?.country ?? ''}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">

View File

@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react' import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn, formatCategory } from '@/lib/utils'
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) { export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
@@ -76,7 +76,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
return ( return (
<div key={cat.category} className="space-y-1"> <div key={cat.category} className="space-y-1">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{cat.category}</span> <span className="font-medium truncate">{formatCategory(cat.category)}</span>
<span className="text-xs text-muted-foreground tabular-nums"> <span className="text-xs text-muted-foreground tabular-nums">
{cat.previous} {cat.current} {cat.previous} {cat.current}
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span> <span className="text-rose-500 ml-1">(-{cat.eliminated})</span>

View File

@@ -128,7 +128,7 @@ function RoundNode({
return ( return (
<button type="button" onClick={onClick} className="text-left focus:outline-none"> <button type="button" onClick={onClick} className="text-left focus:outline-none">
<Card className={cn( <Card className={cn(
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md', 'w-44 shrink-0 border-2 border-border/60 shadow-sm transition-all cursor-pointer hover:shadow-md',
isSelected && 'ring-2 ring-brand-teal shadow-md', isSelected && 'ring-2 ring-brand-teal shadow-md',
)}> )}>
<CardContent className="p-3 space-y-2"> <CardContent className="p-3 space-y-2">
@@ -219,7 +219,7 @@ function PipelineView({
{/* Award Tracks */} {/* Award Tracks */}
{awardGroups.size > 0 && ( {awardGroups.size > 0 && (
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-4">
{Array.from(awardGroups.entries()).map(([awardId, group]) => ( {Array.from(awardGroups.entries()).map(([awardId, group]) => (
<div <div
key={awardId} key={awardId}
@@ -313,7 +313,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border"> <div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border">
{[ {[
{ value: stats.projectCount, label: 'Projects' }, { value: stats.projectCount, label: 'Projects' },
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName }, { value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Rounds', isText: !!stats.activeRoundName },
{ value: avgScore, label: 'Avg Score' }, { value: avgScore, label: 'Avg Score' },
{ value: `${stats.completionRate}%`, label: 'Completion' }, { value: `${stats.completionRate}%`, label: 'Completion' },
{ value: stats.jurorCount, label: 'Jurors' }, { value: stats.jurorCount, label: 'Jurors' },

View File

@@ -576,9 +576,10 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ol className="space-y-4"> <ol className="relative">
{competitionRounds.map((round, idx) => { {competitionRounds.map((round, idx) => {
const effectiveState = effectiveStates[idx] const effectiveState = effectiveStates[idx]
const isLast = idx === competitionRounds.length - 1
const roundAssignments = assignments.filter( const roundAssignments = assignments.filter(
(a) => a.roundId === round.id, (a) => a.roundId === round.id,
@@ -589,15 +590,15 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
let labelClass = 'text-muted-foreground' let labelClass = 'text-muted-foreground'
if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') { if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') {
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" /> icon = <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-500" />
statusLabel = 'Passed' statusLabel = 'Passed'
} else if (effectiveState === 'REJECTED') { } else if (effectiveState === 'REJECTED') {
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" /> icon = <XCircle className="h-5 w-5 shrink-0 text-red-500" />
statusLabel = 'Rejected at this round' statusLabel = 'Rejected at this round'
labelClass = 'text-red-600 font-medium' labelClass = 'text-red-600 font-medium'
} else if (effectiveState === 'IN_PROGRESS') { } else if (effectiveState === 'IN_PROGRESS') {
icon = ( icon = (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center"> <span className="flex h-5 w-5 shrink-0 items-center justify-center">
<span className="relative flex h-3 w-3"> <span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" /> <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
@@ -606,22 +607,32 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
) )
statusLabel = 'Active' statusLabel = 'Active'
} else if (effectiveState === 'NOT_REACHED') { } else if (effectiveState === 'NOT_REACHED') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" /> icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/15" />
statusLabel = 'Not reached' statusLabel = 'Not reached'
labelClass = 'text-muted-foreground/50 italic' labelClass = 'text-muted-foreground/50 italic'
} else if (effectiveState === 'PENDING') { } else if (effectiveState === 'PENDING') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" /> icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/40" />
statusLabel = 'Pending' statusLabel = 'Pending'
} else { } else {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" /> icon = <Circle className="h-5 w-5 shrink-0 text-muted-foreground/20" />
} }
return ( return (
<li key={round.id} className={cn( <li key={round.id} className={cn(
'flex items-start gap-3', 'relative flex items-start gap-3 pb-6',
isLast && 'pb-0',
effectiveState === 'NOT_REACHED' && 'opacity-50', effectiveState === 'NOT_REACHED' && 'opacity-50',
)}> )}>
{/* Connector line */}
{!isLast && (
<span
className="absolute left-[9px] top-6 h-[calc(100%-8px)] w-px bg-border"
aria-hidden="true"
/>
)}
<span className="relative z-10 flex items-center justify-center">
{icon} {icon}
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={cn( <p className={cn(
'text-sm font-medium', 'text-sm font-medium',

View File

@@ -56,7 +56,7 @@ export function ObserverProjectsContent() {
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [roundFilter, setRoundFilter] = useState('all') const [roundFilter, setRoundFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title') const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations' | 'status'>('status')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [perPage] = useState(20) const [perPage] = useState(20)
@@ -86,7 +86,7 @@ export function ObserverProjectsContent() {
setPage(1) setPage(1)
} }
const handleSort = (column: 'title' | 'score' | 'evaluations') => { const handleSort = (column: 'title' | 'score' | 'evaluations' | 'status') => {
if (sortBy === column) { if (sortBy === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc') setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else { } else {
@@ -184,7 +184,7 @@ export function ObserverProjectsContent() {
} }
}, [utils, roundFilter, debouncedSearch, statusFilter, sortBy, sortDir]) }, [utils, roundFilter, debouncedSearch, statusFilter, sortBy, sortDir])
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => { const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' | 'status' }) => {
if (sortBy !== column) if (sortBy !== column)
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" /> return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
return sortDir === 'asc' ? ( return sortDir === 'asc' ? (
@@ -233,7 +233,7 @@ export function ObserverProjectsContent() {
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Search by title or team..." placeholder="Search by title, team, country, institution..."
value={search} value={search}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10" className="pl-10"
@@ -329,7 +329,16 @@ export function ObserverProjectsContent() {
</TableHead> </TableHead>
<TableHead>Country</TableHead> <TableHead>Country</TableHead>
<TableHead>Round</TableHead> <TableHead>Round</TableHead>
<TableHead>Status</TableHead> <TableHead>
<button
type="button"
onClick={() => handleSort('status')}
className="inline-flex items-center hover:text-foreground transition-colors"
>
Status
<SortIcon column="status" />
</button>
</TableHead>
<TableHead> <TableHead>
<button <button
type="button" type="button"

View File

@@ -38,7 +38,6 @@ import { BarChart } from '@tremor/react'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { ExportPdfButton } from '@/components/shared/export-pdf-button' import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
import { ExpandableJurorTable } from './expandable-juror-table' import { ExpandableJurorTable } from './expandable-juror-table'
const ROUND_TYPE_LABELS: Record<string, string> = { const ROUND_TYPE_LABELS: Record<string, string> = {
@@ -139,11 +138,7 @@ function ProgressSubTab({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-end flex-wrap gap-3">
<div>
<h2 className="text-base font-semibold">Progress Overview</h2>
<p className="text-sm text-muted-foreground">Evaluation progress across rounds</p>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedValue && !selectedValue.startsWith('all:') && ( {selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton <ExportPdfButton
@@ -214,7 +209,7 @@ function ProgressSubTab({
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Assignments</p> <p className="text-sm font-medium text-muted-foreground">Juror Assignments</p>
<p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p> <p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{overviewStats.projectCount > 0 {overviewStats.projectCount > 0
@@ -309,7 +304,7 @@ function ProgressSubTab({
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Projects</TableHead> <TableHead className="text-right">Projects</TableHead>
<TableHead className="text-right">Assignments</TableHead> <TableHead className="text-right">Juror Assignments</TableHead>
<TableHead className="min-w-[140px]">Completion</TableHead> <TableHead className="min-w-[140px]">Completion</TableHead>
<TableHead className="text-right">Avg Days</TableHead> <TableHead className="text-right">Avg Days</TableHead>
</TableRow> </TableRow>
@@ -398,7 +393,7 @@ function ProgressSubTab({
<p className="font-medium">{projects}</p> <p className="font-medium">{projects}</p>
</div> </div>
<div> <div>
<p className="text-muted-foreground text-xs">Assignments</p> <p className="text-muted-foreground text-xs">Juror Assignments</p>
<p className="font-medium">{assignments}</p> <p className="font-medium">{assignments}</p>
</div> </div>
</div> </div>
@@ -749,9 +744,11 @@ export function EvaluationReportTabs({ roundId, programId, stages, selectedValue
const stagesLoading = false // stages passed from parent already loaded const stagesLoading = false // stages passed from parent already loaded
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<RoundTypeStatsCards roundId={roundId} /> <div>
<h2 className="text-base font-semibold">Evaluation Overview</h2>
<p className="text-sm text-muted-foreground">Evaluation progress and juror performance</p>
</div>
<Tabs defaultValue="progress" className="space-y-6"> <Tabs defaultValue="progress" className="space-y-6">
<TabsList> <TabsList>
<TabsTrigger value="progress" className="gap-2"> <TabsTrigger value="progress" className="gap-2">

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { Fragment, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -98,9 +98,8 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{jurors.map((j) => ( {jurors.map((j) => (
<> <Fragment key={j.userId}>
<TableRow <TableRow
key={j.userId}
className="cursor-pointer hover:bg-muted/50" className="cursor-pointer hover:bg-muted/50"
onClick={() => toggle(j.userId)} onClick={() => toggle(j.userId)}
> >
@@ -179,7 +178,7 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</> </Fragment>
))} ))}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import { useState } from 'react' import { Fragment, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn, formatCategory } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -33,6 +34,15 @@ interface FilteringReportTabsProps {
type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
function outcomeTextColor(outcome: string): string {
switch (outcome) {
case 'PASSED': return 'text-emerald-700 dark:text-emerald-400'
case 'FILTERED_OUT': return 'text-rose-700 dark:text-rose-400'
case 'FLAGGED': return 'text-amber-700 dark:text-amber-400'
default: return 'text-primary'
}
}
function outcomeBadge(outcome: string) { function outcomeBadge(outcome: string) {
switch (outcome) { switch (outcome) {
case 'PASSED': case 'PASSED':
@@ -141,9 +151,8 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
const reasoning = extractReasoning(r.aiScreeningJson) const reasoning = extractReasoning(r.aiScreeningJson)
const isExpanded = expandedId === r.id const isExpanded = expandedId === r.id
return ( return (
<> <Fragment key={r.id}>
<TableRow <TableRow
key={r.id}
className="cursor-pointer hover:bg-muted/50" className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleExpand(r.id)} onClick={() => toggleExpand(r.id)}
> >
@@ -156,7 +165,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
</TableCell> </TableCell>
<TableCell> <TableCell>
<button <button
className="font-medium text-primary hover:underline text-left" className={cn('font-medium hover:underline text-left', outcomeTextColor(effectiveOutcome))}
onClick={(e) => openPreview(r.project.id, e)} onClick={(e) => openPreview(r.project.id, e)}
> >
{r.project.title} {r.project.title}
@@ -164,7 +173,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{r.project.teamName}</TableCell> <TableCell className="text-muted-foreground">{r.project.teamName}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{r.project.competitionCategory ?? '—'} {formatCategory(r.project.competitionCategory) || '—'}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{r.project.country ?? '—'} {r.project.country ?? '—'}
@@ -205,7 +214,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</> </Fragment>
) )
})} })}
</TableBody> </TableBody>
@@ -221,14 +230,17 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
return ( return (
<Card key={r.id}> <Card key={r.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<button <div
className="w-full text-left" className="w-full text-left cursor-pointer"
role="button"
tabIndex={0}
onClick={() => toggleExpand(r.id)} onClick={() => toggleExpand(r.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggleExpand(r.id) }}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<button <button
className="font-medium text-sm text-primary hover:underline text-left truncate block max-w-full" className={cn('font-medium text-sm hover:underline text-left truncate block max-w-full', outcomeTextColor(effectiveOutcome))}
onClick={(e) => openPreview(r.project.id, e)} onClick={(e) => openPreview(r.project.id, e)}
> >
{r.project.title} {r.project.title}
@@ -245,10 +257,10 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
</div> </div>
</div> </div>
<div className="flex gap-3 text-xs text-muted-foreground mt-1"> <div className="flex gap-3 text-xs text-muted-foreground mt-1">
{r.project.competitionCategory && <span>{r.project.competitionCategory}</span>} {r.project.competitionCategory && <span>{formatCategory(r.project.competitionCategory)}</span>}
{r.project.country && <span>{r.project.country}</span>} {r.project.country && <span>{r.project.country}</span>}
</div> </div>
</button> </div>
{isExpanded && ( {isExpanded && (
<div className="mt-3 pt-3 border-t space-y-2"> <div className="mt-3 pt-3 border-t space-y-2">

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { formatCategory } from '@/lib/utils'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -81,7 +82,7 @@ export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectP
</Badge> </Badge>
)} )}
{data.project.competitionCategory && ( {data.project.competitionCategory && (
<Badge variant="secondary">{data.project.competitionCategory}</Badge> <Badge variant="secondary">{formatCategory(data.project.competitionCategory)}</Badge>
)} )}
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
FileText, FileText,
MessageSquare, MessageSquare,
Lock, Lock,
Globe,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
@@ -80,8 +81,9 @@ export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) {
case 'INTAKE': case 'INTAKE':
return [ return [
{ label: 'Total Projects', value: (stats.totalProjects as number) ?? 0, icon: Inbox, color: '#053d57' }, { label: 'Total Projects', value: (stats.totalProjects as number) ?? 0, icon: Inbox, color: '#053d57' },
{ label: 'States', value: ((stats.byState as Array<unknown>)?.length ?? 0), icon: BarChart3, color: '#557f8c' }, { label: 'Start-ups', value: (stats.startupCount as number) ?? 0, icon: BarChart3, color: '#1e7a8a' },
{ label: 'Categories', value: ((stats.byCategory as Array<unknown>)?.length ?? 0), icon: Filter, color: '#1e7a8a' }, { label: 'Business Concepts', value: (stats.conceptCount as number) ?? 0, icon: FileText, color: '#557f8c' },
{ label: 'Countries', value: (stats.countryCount as number) ?? 0, icon: Globe, color: '#2d8659' },
] ]
case 'FILTERING': case 'FILTERING':

View File

@@ -283,9 +283,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
} }
// On session update, handle impersonation or normal refresh // On session update, handle impersonation or normal refresh
if (trigger === 'update' && session) { if (trigger === 'update') {
// Start impersonation // Start impersonation
if (session.impersonate && typeof session.impersonate === 'string') { if (session?.impersonate && typeof session.impersonate === 'string') {
// Only SUPER_ADMIN can impersonate (defense-in-depth) // Only SUPER_ADMIN can impersonate (defense-in-depth)
if (token.role === 'SUPER_ADMIN' && !token.impersonating) { if (token.role === 'SUPER_ADMIN' && !token.impersonating) {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
@@ -311,7 +311,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
} }
} }
// End impersonation // End impersonation
else if (session.endImpersonation && token.impersonating) { else if (session?.endImpersonation && token.impersonating) {
const original = token.impersonating as { originalId: string; originalRole: UserRole; originalRoles: UserRole[]; originalEmail: string } const original = token.impersonating as { originalId: string; originalRole: UserRole; originalRoles: UserRole[]; originalEmail: string }
token.id = original.originalId token.id = original.originalId
token.role = original.originalRole token.role = original.originalRole

View File

@@ -2,6 +2,19 @@ import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer' import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
/**
* Dev email override: when DEV_EMAIL_OVERRIDE is set, ALL outgoing emails
* are redirected to that address. The original recipient is noted in the subject.
*/
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
const { transporter, from } = await getTransporter()
const to = DEV_EMAIL_OVERRIDE || opts.to
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
await transporter.sendMail({ from, to, subject, text: opts.text, html: opts.html })
}
// Cached transporter and config hash to detect changes // Cached transporter and config hash to detect changes
let cachedTransporter: Transporter | null = null let cachedTransporter: Transporter | null = null
let cachedConfigHash = '' let cachedConfigHash = ''
@@ -2253,15 +2266,7 @@ export async function sendStyledNotificationEmail(
) )
} }
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
// ============================================================================= // =============================================================================
@@ -2277,15 +2282,7 @@ export async function sendPasswordResetEmail(
expiryMinutes: number = 30 expiryMinutes: number = 30
): Promise<void> { ): Promise<void> {
const template = getPasswordResetTemplate(url, expiryMinutes) const template = getPasswordResetTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2297,15 +2294,7 @@ export async function sendMagicLinkEmail(
): Promise<void> { ): Promise<void> {
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60 const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
const template = getMagicLinkTemplate(url, expiryMinutes) const template = getMagicLinkTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2319,15 +2308,7 @@ export async function sendInvitationEmail(
expiryHours?: number expiryHours?: number
): Promise<void> { ): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role, expiryHours) const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2340,15 +2321,7 @@ export async function sendJuryInvitationEmail(
roundName: string roundName: string
): Promise<void> { ): Promise<void> {
const template = getJuryInvitationTemplate(name || '', url, roundName) const template = getJuryInvitationTemplate(name || '', url, roundName)
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2369,15 +2342,7 @@ export async function sendEvaluationReminderEmail(
deadline, deadline,
assignmentsUrl assignmentsUrl
) )
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2398,15 +2363,7 @@ export async function sendAnnouncementEmail(
ctaText, ctaText,
ctaUrl ctaUrl
) )
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2422,10 +2379,7 @@ export async function sendTestEmail(toEmail: string): Promise<boolean> {
Sent at ${new Date().toISOString()} Sent at ${new Date().toISOString()}
</p> </p>
` `
const { transporter, from } = await getTransporter() await sendEmail({
await transporter.sendMail({
from,
to: toEmail, to: toEmail,
subject: 'MOPC Portal - Test Email', subject: 'MOPC Portal - Test Email',
text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.', text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.',
@@ -2466,15 +2420,7 @@ export async function sendApplicationConfirmationEmail(
programName, programName,
customMessage customMessage
) )
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2493,15 +2439,7 @@ export async function sendTeamMemberInviteEmail(
teamLeadName, teamLeadName,
inviteUrl inviteUrl
) )
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }
/** /**
@@ -2561,13 +2499,5 @@ export async function sendNotificationEmail(
linkUrl?: string linkUrl?: string
): Promise<void> { ): Promise<void> {
const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl)) const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl))
const { transporter, from } = await getTransporter() await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
} }

View File

@@ -60,6 +60,16 @@ export function daysUntil(date: Date | string): number {
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
} }
const CATEGORY_LABELS: Record<string, string> = {
STARTUP: 'Start-up',
BUSINESS_CONCEPT: 'Business Concept',
}
export function formatCategory(category: string | null | undefined): string {
if (!category) return ''
return CATEGORY_LABELS[category] ?? category.replace(/_/g, ' ')
}
export function formatRelativeTime(date: Date | string): string { export function formatRelativeTime(date: Date | string): string {
const now = new Date() const now = new Date()
const target = new Date(date) const target = new Date(date)

View File

@@ -31,6 +31,13 @@ function evalWhere(input: { roundId?: string; programId?: string }, extra: Recor
return { ...base, ...extra } return { ...base, ...extra }
} }
/** Extract country from "City, Region, Country" location string */
function extractCountry(location: string | null): string {
if (!location) return 'Unknown'
const parts = location.split(',').map(s => s.trim())
return parts[parts.length - 1] || 'Unknown'
}
export const analyticsRouter = router({ export const analyticsRouter = router({
/** /**
* Get score distribution (histogram data) * Get score distribution (histogram data)
@@ -664,10 +671,10 @@ export const analyticsRouter = router({
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] } return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
} }
// By country // By country (extract country from "City, Region, Country" format)
const countryCounts: Record<string, number> = {} const countryCounts: Record<string, number> = {}
projects.forEach((p) => { projects.forEach((p) => {
const key = p.country || 'Unknown' const key = extractCountry(p.country)
countryCounts[key] = (countryCounts[key] || 0) + 1 countryCounts[key] = (countryCounts[key] || 0) + 1
}) })
const byCountry = Object.entries(countryCounts) const byCountry = Object.entries(countryCounts)
@@ -988,7 +995,7 @@ export const analyticsRouter = router({
roundId: z.string().optional(), roundId: z.string().optional(),
search: z.string().optional(), search: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
sortBy: z.enum(['title', 'score', 'evaluations']).default('title'), sortBy: z.enum(['title', 'score', 'evaluations', 'status']).default('status'),
sortDir: z.enum(['asc', 'desc']).default('asc'), sortDir: z.enum(['asc', 'desc']).default('asc'),
page: z.number().min(1).default(1), page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20), perPage: z.number().min(1).max(100).default(20),
@@ -1012,6 +1019,9 @@ export const analyticsRouter = router({
where.OR = [ where.OR = [
{ title: { contains: input.search, mode: 'insensitive' } }, { title: { contains: input.search, mode: 'insensitive' } },
{ teamName: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } },
{ country: { contains: input.search, mode: 'insensitive' } },
{ institution: { contains: input.search, mode: 'insensitive' } },
{ geographicZone: { contains: input.search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } }, { teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } },
{ teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } }, { teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } },
] ]
@@ -1112,9 +1122,20 @@ export const analyticsRouter = router({
: mapped : mapped
const filteredTotal = observerStatusFilter ? filtered.length : total const filteredTotal = observerStatusFilter ? filtered.length : total
// Sort by computed fields (score, evaluations) in JS // Sort by computed fields (score, evaluations, status) in JS
const STATUS_ORDER: Record<string, number> = {
IN_PROGRESS: 0, PENDING: 1, COMPLETED: 2, PASSED: 3, REJECTED: 4, WITHDRAWN: 5,
}
let sorted = filtered let sorted = filtered
if (input.sortBy === 'score') { if (input.sortBy === 'status') {
sorted = filtered.sort((a, b) => {
const oa = STATUS_ORDER[a.observerStatus] ?? 3
const ob = STATUS_ORDER[b.observerStatus] ?? 3
const cmp = oa - ob
if (cmp !== 0) return input.sortDir === 'asc' ? cmp : -cmp
return a.title.localeCompare(b.title)
})
} else if (input.sortBy === 'score') {
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const sa = a.averageScore ?? -1 const sa = a.averageScore ?? -1
const sb = b.averageScore ?? -1 const sb = b.averageScore ?? -1
@@ -1129,7 +1150,7 @@ export const analyticsRouter = router({
} }
// Paginate in JS for computed-field sorts or observer status filter // Paginate in JS for computed-field sorts or observer status filter
const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter const needsJsPagination = (input.sortBy !== 'title') || observerStatusFilter
const paginated = needsJsPagination const paginated = needsJsPagination
? sorted.slice((input.page - 1) * effectivePerPage, input.page * effectivePerPage) ? sorted.slice((input.page - 1) * effectivePerPage, input.page * effectivePerPage)
: sorted : sorted
@@ -1159,7 +1180,7 @@ export const analyticsRouter = router({
switch (roundType) { switch (roundType) {
case 'INTAKE': { case 'INTAKE': {
const [total, byState, byCategory] = await Promise.all([ const [total, byState, byCategory, countryData] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }), ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.groupBy({ ctx.prisma.projectRoundState.groupBy({
by: ['state'], by: ['state'],
@@ -1171,11 +1192,21 @@ export const analyticsRouter = router({
where: { projectRoundStates: { some: { roundId: input.roundId } } }, where: { projectRoundStates: { some: { roundId: input.roundId } } },
_count: true, _count: true,
}), }),
ctx.prisma.project.findMany({
where: { projectRoundStates: { some: { roundId: input.roundId } } },
select: { country: true },
}),
]) ])
const countries = new Set(countryData.map((p) => extractCountry(p.country)).filter((c) => c !== 'Unknown'))
const startupCount = byCategory.find((c) => c.competitionCategory === 'STARTUP')?._count ?? 0
const conceptCount = byCategory.find((c) => c.competitionCategory === 'BUSINESS_CONCEPT')?._count ?? 0
return { return {
roundType, roundType,
stats: { stats: {
totalProjects: total, totalProjects: total,
startupCount,
conceptCount,
countryCount: countries.size,
byState: byState.map((s) => ({ state: s.state, count: s._count })), byState: byState.map((s) => ({ state: s.state, count: s._count })),
byCategory: byCategory.map((c) => ({ byCategory: byCategory.map((c) => ({
category: c.competitionCategory ?? 'Uncategorized', category: c.competitionCategory ?? 'Uncategorized',
@@ -2013,15 +2044,14 @@ export const analyticsRouter = router({
eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0), eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0),
})) }))
// Country attrition
const prevByCountry = new Map<string, number>() const prevByCountry = new Map<string, number>()
prevProjects.forEach(p => { prevProjects.forEach(p => {
const c = p.country ?? 'Unknown' const c = extractCountry(p.country)
prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1) prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1)
}) })
const currByCountry = new Map<string, number>() const currByCountry = new Map<string, number>()
currProjects.forEach(p => { currProjects.forEach(p => {
const c = p.country ?? 'Unknown' const c = extractCountry(p.country)
currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1) currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1)
}) })
const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()]) const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()])