feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s
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:
1732
package-lock.json
generated
1732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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('_', ' ')}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -22,7 +23,22 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -36,14 +36,16 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<div className="flex items-center justify-center">
|
||||||
data={chartData}
|
<DonutChart
|
||||||
category="value"
|
data={chartData}
|
||||||
index="name"
|
category="value"
|
||||||
colors={colors}
|
index="name"
|
||||||
showLabel={true}
|
colors={colors}
|
||||||
className="h-[300px]"
|
showLabel={true}
|
||||||
/>
|
className="h-[250px] w-[250px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,23 +376,41 @@ 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}
|
||||||
{switchableRoles.map(([, opt]) => (
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
||||||
<DropdownMenuItem key={opt.path} asChild>
|
>
|
||||||
<Link
|
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
href={opt.path as Route}
|
<span>{opt.label}</span>
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
</Link>
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
))
|
||||||
<span>{opt.label}</span>
|
) : (
|
||||||
</Link>
|
// Submenu for 3+ roles
|
||||||
</DropdownMenuItem>
|
<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]) => (
|
||||||
|
<DropdownMenuItem key={opt.path} asChild>
|
||||||
|
<Link
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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',
|
||||||
)}>
|
)}>
|
||||||
{icon}
|
{/* 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}
|
||||||
|
</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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,42 +744,44 @@ 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">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="progress" className="gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Progress
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="jurors" className="gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Jurors
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="scores" className="gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Scores
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<Tabs defaultValue="progress" className="space-y-6">
|
<TabsContent value="progress">
|
||||||
<TabsList>
|
<ProgressSubTab
|
||||||
<TabsTrigger value="progress" className="gap-2">
|
selectedValue={selectedValue}
|
||||||
<TrendingUp className="h-4 w-4" />
|
stages={stages}
|
||||||
Progress
|
stagesLoading={stagesLoading}
|
||||||
</TabsTrigger>
|
selectedRound={selectedRound}
|
||||||
<TabsTrigger value="jurors" className="gap-2">
|
/>
|
||||||
<Users className="h-4 w-4" />
|
</TabsContent>
|
||||||
Jurors
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="scores" className="gap-2">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
Scores
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="progress">
|
<TabsContent value="jurors">
|
||||||
<ProgressSubTab
|
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
||||||
selectedValue={selectedValue}
|
</TabsContent>
|
||||||
stages={stages}
|
|
||||||
stagesLoading={stagesLoading}
|
|
||||||
selectedRound={selectedRound}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="jurors">
|
<TabsContent value="scores">
|
||||||
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
<TabsContent value="scores">
|
|
||||||
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
118
src/lib/email.ts
118
src/lib/email.ts
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
|||||||
Reference in New Issue
Block a user