fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Security fixes: - Block self-registration via magic link (PrismaAdapter createUser throws) - Magic links only sent to existing ACTIVE users (prevents enumeration) - signIn callback rejects non-existent users (defense-in-depth) - Change schema default role from JURY_MEMBER to APPLICANT - Add authentication to live-voting SSE stream endpoint - Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load (remove purpose from eagerly pre-fetched URL queries) Bug fixes: - Fix impersonation skeleton screen on applicant dashboard - Fix onboarding redirect loop in auth layout Observer dashboard redesign (Steps 1-6): - Clickable round pipeline with selected round highlighting - Round-type-specific dashboard panels (intake, filtering, evaluation, submission, mentoring, live final, deliberation) - Enhanced activity feed with server-side humanization - Previous round comparison section - New backend queries for round-specific analytics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -302,7 +302,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||||
role UserRole @default(JURY_MEMBER)
|
role UserRole @default(APPLICANT)
|
||||||
roles UserRole[] @default([])
|
roles UserRole[] @default([])
|
||||||
status UserStatus @default(INVITED)
|
status UserStatus @default(INVITED)
|
||||||
expertiseTags String[] @default([])
|
expertiseTags String[] @default([])
|
||||||
|
|||||||
@@ -1337,7 +1337,7 @@ export default function AwardDetailPage({
|
|||||||
<TableRow key={j.id}>
|
<TableRow key={j.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserAvatar user={j.user} size="sm" />
|
<UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{j.user.name || 'Unnamed'}
|
{j.user.name || 'Unnamed'}
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ const fileTypeLabels: Record<string, string> = {
|
|||||||
|
|
||||||
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
||||||
const { data: viewData } = trpc.file.getDownloadUrl.useQuery(
|
const { data: viewData } = trpc.file.getDownloadUrl.useQuery(
|
||||||
{ bucket, objectKey, forDownload: false, purpose: 'open' as const },
|
{ bucket, objectKey, forDownload: false },
|
||||||
{ staleTime: 10 * 60 * 1000 }
|
{ staleTime: 10 * 60 * 1000 }
|
||||||
)
|
)
|
||||||
const { data: dlData } = trpc.file.getDownloadUrl.useQuery(
|
const { data: dlData } = trpc.file.getDownloadUrl.useQuery(
|
||||||
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
|
{ bucket, objectKey, forDownload: true, fileName },
|
||||||
{ staleTime: 10 * 60 * 1000 }
|
{ staleTime: 10 * 60 * 1000 }
|
||||||
)
|
)
|
||||||
const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url
|
const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sessionStatus === 'loading' || isLoading) {
|
if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -26,18 +26,23 @@ export default async function AuthLayout({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
|
// If user hasn't completed onboarding, don't redirect away from auth pages.
|
||||||
const role = session.user.role
|
// The /onboarding page lives in this (auth) layout, so they need to stay here.
|
||||||
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
if (!dbUser.onboardingCompletedAt) {
|
||||||
redirect('/admin')
|
// Fall through — let them access /onboarding (and other auth pages)
|
||||||
} else if (role === 'JURY_MEMBER') {
|
} else {
|
||||||
redirect('/jury')
|
const role = session.user.role
|
||||||
} else if (role === 'OBSERVER') {
|
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
||||||
redirect('/observer')
|
redirect('/admin')
|
||||||
} else if (role === 'MENTOR') {
|
} else if (role === 'JURY_MEMBER') {
|
||||||
redirect('/mentor')
|
redirect('/jury')
|
||||||
} else if (role === 'APPLICANT') {
|
} else if (role === 'OBSERVER') {
|
||||||
redirect('/applicant')
|
redirect('/observer')
|
||||||
|
} else if (role === 'MENTOR') {
|
||||||
|
redirect('/mentor')
|
||||||
|
} else if (role === 'APPLICANT') {
|
||||||
|
redirect('/applicant')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If user doesn't exist in DB, fall through and show auth page
|
// If user doesn't exist in DB, fall through and show auth page
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function GET(request: NextRequest): Promise<Response> {
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
// Require authentication — prevent unauthenticated access to live vote data
|
||||||
|
const userSession = await auth()
|
||||||
|
if (!userSession?.user) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const sessionId = searchParams.get('sessionId')
|
const sessionId = searchParams.get('sessionId')
|
||||||
|
|
||||||
|
|||||||
22
src/components/observer/dashboard/deliberation-panel.tsx
Normal file
22
src/components/observer/dashboard/deliberation-panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
export function DeliberationPanel() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<div className="relative inline-block mb-4">
|
||||||
|
<Lock className="h-12 w-12 text-muted-foreground/40 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Results Coming Soon</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
The jury is deliberating. Results will be shared when finalized.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
261
src/components/observer/dashboard/evaluation-panel.tsx
Normal file
261
src/components/observer/dashboard/evaluation-panel.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import {
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Award,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function EvaluationPanel({ roundId, programId }: { roundId: string; programId: string }) {
|
||||||
|
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: advConfig } = trpc.analytics.getRoundAdvancementConfig.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 60_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: dashStats } = trpc.analytics.getDashboardStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
|
||||||
|
{ programId, roundId },
|
||||||
|
{ enabled: !!programId, refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
|
||||||
|
{ roundId, perPage: 8 },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
totalAssignments: number
|
||||||
|
completedEvaluations: number
|
||||||
|
completionRate: number
|
||||||
|
activeJurors: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const allJurors = jurorWorkload ?? []
|
||||||
|
const projects = projectsData?.projects ?? []
|
||||||
|
|
||||||
|
const scoreDistribution = dashStats?.scoreDistribution ?? []
|
||||||
|
const maxScoreCount = Math.max(...scoreDistribution.map((b) => b.count), 1)
|
||||||
|
const scoreColors: Record<string, string> = {
|
||||||
|
'9-10': '#053d57',
|
||||||
|
'7-8': '#1e7a8a',
|
||||||
|
'5-6': '#557f8c',
|
||||||
|
'3-4': '#c4453a',
|
||||||
|
'1-2': '#de0f1e',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Advancement Method Card */}
|
||||||
|
{advConfig && (
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="border-brand-teal/30 bg-brand-teal/5">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-brand-teal/10 p-2">
|
||||||
|
<Target className="h-5 w-5 text-brand-teal" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{advConfig.advanceMode === 'threshold'
|
||||||
|
? `Score Threshold ≥ ${advConfig.advanceScoreThreshold ?? '?'}`
|
||||||
|
: 'Top N Advancement'}
|
||||||
|
</p>
|
||||||
|
{advConfig.advanceMode === 'count' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{advConfig.startupAdvanceCount != null && `${advConfig.startupAdvanceCount} Startups`}
|
||||||
|
{advConfig.startupAdvanceCount != null && advConfig.conceptAdvanceCount != null && ', '}
|
||||||
|
{advConfig.conceptAdvanceCount != null && `${advConfig.conceptAdvanceCount} Business Concepts`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completion Progress */}
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton className="h-20 rounded-lg" />
|
||||||
|
) : stats ? (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">Evaluation Progress</span>
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{stats.completionRate}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Progress value={stats.completionRate} className="h-2 mb-1" />
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{stats.completedEvaluations} / {stats.totalAssignments} evaluations · {stats.activeJurors} jurors
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Score Distribution */}
|
||||||
|
{scoreDistribution.length > 0 && (
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-amber-500" />
|
||||||
|
Score Distribution
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{scoreDistribution.map((bucket) => (
|
||||||
|
<div key={bucket.label} className="flex items-center gap-2">
|
||||||
|
<span className="w-8 text-right text-[11px] font-medium tabular-nums text-muted-foreground">
|
||||||
|
{bucket.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 14 }}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${maxScoreCount > 0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
|
||||||
|
backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-6 text-right text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{bucket.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Juror Workload */}
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="h-4 w-4 text-violet-500" />
|
||||||
|
Juror Workload
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{allJurors.length > 0 ? (
|
||||||
|
<div className="max-h-[320px] overflow-y-auto -mr-2 pr-2 space-y-2">
|
||||||
|
{allJurors.map((juror) => {
|
||||||
|
const isExpanded = expandedJurorId === juror.id
|
||||||
|
return (
|
||||||
|
<div key={juror.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate font-medium">{juror.name ?? 'Unknown'}</span>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{juror.completionRate}%
|
||||||
|
</span>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={juror.completionRate} className="h-1.5" />
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{juror.completed} / {juror.assigned} evaluations
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{isExpanded && juror.projects && (
|
||||||
|
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
|
||||||
|
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
|
||||||
|
<Link
|
||||||
|
key={proj.id}
|
||||||
|
href={`/observer/projects/${proj.id}` as Route}
|
||||||
|
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
|
||||||
|
>
|
||||||
|
<span className="truncate">{proj.title}</span>
|
||||||
|
<StatusBadge status={proj.evalStatus} size="sm" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No juror assignments yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Recently Reviewed */}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<AnimatedCard index={3}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Award className="h-4 w-4 text-emerald-500" />
|
||||||
|
Recently Reviewed
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{projects
|
||||||
|
.filter((p) => {
|
||||||
|
const s = p.observerStatus ?? p.status
|
||||||
|
return s !== 'NOT_REVIEWED' && s !== 'SUBMITTED'
|
||||||
|
})
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/observer/projects/${p.id}` as Route}
|
||||||
|
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm truncate">{p.title}</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<StatusBadge status={p.observerStatus ?? p.status} size="sm" />
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{p.evaluationCount > 0 && p.averageScore !== null
|
||||||
|
? p.averageScore.toFixed(1)
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
251
src/components/observer/dashboard/filtering-panel.tsx
Normal file
251
src/components/observer/dashboard/filtering-panel.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function FilteringPanel({ roundId }: { roundId: string }) {
|
||||||
|
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: resultStats } = trpc.analytics.getFilteringResultStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: results, isLoading: resultsLoading } = trpc.analytics.getFilteringResults.useQuery(
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
|
||||||
|
page,
|
||||||
|
perPage: 15,
|
||||||
|
},
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
totalScreened: number
|
||||||
|
passed: number
|
||||||
|
filteredOut: number
|
||||||
|
flagged: number
|
||||||
|
passRate: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const total = stats?.totalScreened ?? 0
|
||||||
|
|
||||||
|
const outcomeColor: Record<string, string> = {
|
||||||
|
PASSED: 'bg-emerald-500',
|
||||||
|
FILTERED_OUT: 'bg-rose-500',
|
||||||
|
FLAGGED: 'bg-amber-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Screening Stats Bar */}
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton className="h-24 rounded-lg" />
|
||||||
|
) : stats ? (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Filter className="h-4 w-4 text-brand-teal" />
|
||||||
|
<span className="text-sm font-semibold">Screening Results</span>
|
||||||
|
<Badge variant="secondary" className="ml-auto tabular-nums">{total} screened</Badge>
|
||||||
|
</div>
|
||||||
|
{/* Segmented bar */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="flex h-3 rounded-full overflow-hidden bg-muted">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${(stats.passed / total) * 100}%` }}
|
||||||
|
title={`Passed: ${stats.passed}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-rose-500 transition-all"
|
||||||
|
style={{ width: `${(stats.filteredOut / total) * 100}%` }}
|
||||||
|
title={`Filtered: ${stats.filteredOut}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-amber-500 transition-all"
|
||||||
|
style={{ width: `${(stats.flagged / total) * 100}%` }}
|
||||||
|
title={`Flagged: ${stats.flagged}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 inline-block" />
|
||||||
|
Passed {stats.passed}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-rose-500 inline-block" />
|
||||||
|
Filtered {stats.filteredOut}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500 inline-block" />
|
||||||
|
Flagged {stats.flagged}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Detailed Stats */}
|
||||||
|
{resultStats && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-emerald-600">
|
||||||
|
{resultStats.passed}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Passed</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-amber-600">
|
||||||
|
{resultStats.overridden}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Overridden</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums">
|
||||||
|
{resultStats.total > 0 ? Math.round((resultStats.passed / resultStats.total) * 100) : 0}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Pass Rate</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Results Table */}
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm">AI Screening Results</CardTitle>
|
||||||
|
<Select value={outcomeFilter} onValueChange={(v) => { setOutcomeFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="All" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">All</SelectItem>
|
||||||
|
<SelectItem value="PASSED">Passed</SelectItem>
|
||||||
|
<SelectItem value="FILTERED_OUT">Filtered</SelectItem>
|
||||||
|
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{resultsLoading ? (
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : results && results.results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="divide-y">
|
||||||
|
{results.results.map((r: any) => (
|
||||||
|
<div key={r.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/observer/projects/${r.project?.id}` as Route}
|
||||||
|
className="text-sm font-medium truncate hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{r.project?.title ?? 'Unknown'}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{r.project?.competitionCategory ?? ''} · {r.project?.country ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{(() => {
|
||||||
|
const effectiveOutcome = r.finalOutcome ?? r.outcome
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={cn(
|
||||||
|
'h-2 w-2 rounded-full',
|
||||||
|
outcomeColor[effectiveOutcome] ?? 'bg-muted',
|
||||||
|
)} />
|
||||||
|
<span className="text-xs">{effectiveOutcome?.replace(/_/g, ' ')}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{expandedId === r.id
|
||||||
|
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expandedId === r.id && (
|
||||||
|
<div className="px-4 pb-3 pt-0">
|
||||||
|
<div className="rounded bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{(() => {
|
||||||
|
const screening = r.aiScreeningJson as Record<string, unknown> | null
|
||||||
|
const reasoning = (screening?.reasoning ?? screening?.explanation ?? r.overrideReason ?? 'No details available') as string
|
||||||
|
return reasoning
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Pagination */}
|
||||||
|
{results.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Page {results.page} of {results.totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= results.totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No screening results yet.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src/components/observer/dashboard/intake-panel.tsx
Normal file
163
src/components/observer/dashboard/intake-panel.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { Inbox, Globe, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
function relativeTime(date: Date | string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const then = new Date(date).getTime()
|
||||||
|
const diff = Math.floor((now - then) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntakePanel({ roundId, programId }: { roundId: string; programId: string }) {
|
||||||
|
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
|
||||||
|
{ roundId, perPage: 8 },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
|
||||||
|
{ programId, roundId },
|
||||||
|
{ enabled: !!programId, refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
totalProjects: number
|
||||||
|
byCategory: { category: string; count: number }[]
|
||||||
|
byState: { state: string; count: number }[]
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const projects = projectsData?.projects ?? []
|
||||||
|
const topCountries = (geoData ?? []).slice(0, 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">{stats.totalProjects}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Total Projects</p>
|
||||||
|
</Card>
|
||||||
|
{stats.byCategory.map((c) => (
|
||||||
|
<Card key={c.category} className="p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">{c.count}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{c.category}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Recent Submissions */}
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Inbox className="h-4 w-4 text-brand-teal" />
|
||||||
|
Recent Submissions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/observer/projects/${p.id}` as Route}
|
||||||
|
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{p.teamName ?? 'No team'} · {p.country ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
|
||||||
|
{p.country ?? ''}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No submissions yet.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Country Ranking */}
|
||||||
|
{topCountries.length > 0 && (
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Globe className="h-4 w-4 text-blue-500" />
|
||||||
|
Top Countries
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{topCountries.map((c, i) => (
|
||||||
|
<div key={c.countryCode} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="text-muted-foreground tabular-nums mr-2">{i + 1}.</span>
|
||||||
|
{c.countryCode}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="tabular-nums text-xs">
|
||||||
|
{c.count}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Breakdown */}
|
||||||
|
{stats && stats.byCategory.length > 0 && (
|
||||||
|
<AnimatedCard index={3}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<FolderOpen className="h-4 w-4 text-emerald-500" />
|
||||||
|
Category Breakdown
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{stats.byCategory.map((c) => {
|
||||||
|
const pct = stats.totalProjects > 0 ? Math.round((c.count / stats.totalProjects) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={c.category} className="flex-1 rounded-lg bg-muted p-3 text-center">
|
||||||
|
<p className="text-lg font-semibold tabular-nums">{pct}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{c.category}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
src/components/observer/dashboard/live-final-panel.tsx
Normal file
164
src/components/observer/dashboard/live-final-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
|
||||||
|
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' },
|
||||||
|
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true },
|
||||||
|
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' },
|
||||||
|
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveFinalPanel({ roundId }: { roundId: string }) {
|
||||||
|
const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 10_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
sessionStatus: string
|
||||||
|
voteCount: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED'
|
||||||
|
const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED
|
||||||
|
|
||||||
|
const jurors = liveDash?.jurors ?? []
|
||||||
|
const votedCount = jurors.filter((j: any) => j.hasVoted).length
|
||||||
|
const standings = liveDash?.standings ?? []
|
||||||
|
const visibility = liveDash?.observerScoreVisibility ?? 'after_completion'
|
||||||
|
const scoresVisible = visibility === 'realtime'
|
||||||
|
|| (visibility === 'after_completion' && sessionStatus === 'COMPLETED')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Session Status Card */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-24 rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<Card className={cn('p-5', statusConfig.bg)}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Radio className={cn('h-8 w-8', statusConfig.color)} />
|
||||||
|
{statusConfig.pulse && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={cn('text-lg font-semibold', statusConfig.color)}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vote Count */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">
|
||||||
|
{liveDash?.voteCount ?? stats?.voteCount ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Total Votes</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">
|
||||||
|
{votedCount}/{jurors.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Jurors Voted</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Juror Participation */}
|
||||||
|
{jurors.length > 0 && (
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="h-4 w-4 text-violet-500" />
|
||||||
|
Juror Participation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
|
||||||
|
{jurors.map((j: any) => (
|
||||||
|
<div key={j.id} className="flex items-center justify-between text-sm py-1">
|
||||||
|
<span className="truncate">{j.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant={j.hasVoted ? 'default' : 'outline'}
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
j.hasVoted && 'bg-emerald-500 hover:bg-emerald-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{j.hasVoted ? 'Voted' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Standings / Score Visibility */}
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Trophy className="h-4 w-4 text-amber-500" />
|
||||||
|
Standings
|
||||||
|
{scoresVisible ? (
|
||||||
|
<Eye className="h-3.5 w-3.5 text-emerald-500 ml-auto" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{scoresVisible && standings.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{standings.map((s: any, i: number) => (
|
||||||
|
<div key={s.projectId} className="flex items-center justify-between text-sm py-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-muted-foreground tabular-nums font-medium w-5 text-right">
|
||||||
|
{i + 1}.
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{s.projectTitle}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="tabular-nums shrink-0">
|
||||||
|
{typeof s.score === 'number' ? s.score.toFixed(1) : s.score}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<EyeOff className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{sessionStatus === 'COMPLETED'
|
||||||
|
? 'Scores are hidden by admin configuration.'
|
||||||
|
: 'Scores will be revealed when voting completes.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
src/components/observer/dashboard/mentoring-panel.tsx
Normal file
139
src/components/observer/dashboard/mentoring-panel.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { MessageCircle, Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
export function MentoringPanel({ roundId }: { roundId: string }) {
|
||||||
|
const [expandedMentorId, setExpandedMentorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: mentoringData, isLoading } = trpc.analytics.getMentoringDashboard.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
mentorAssignments: number
|
||||||
|
totalMessages: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const assignments = mentoringData?.assignments ?? []
|
||||||
|
const activeMentors = mentoringData?.activeMentors ?? 0
|
||||||
|
const totalMentors = mentoringData?.totalMentors ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums">
|
||||||
|
{activeMentors}/{totalMentors}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Active Mentors</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums">
|
||||||
|
{mentoringData?.totalMessages ?? stats?.totalMessages ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Messages</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold tabular-nums">
|
||||||
|
{stats?.mentorAssignments ?? assignments.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Assignments</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mentor-Mentee Pairings */}
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="h-4 w-4 text-brand-teal" />
|
||||||
|
Mentor-Mentee Pairings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{assignments.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto -mr-2 pr-2">
|
||||||
|
{assignments.map((mentor: any) => {
|
||||||
|
const isExpanded = expandedMentorId === mentor.mentorId
|
||||||
|
return (
|
||||||
|
<div key={mentor.mentorId} className="border rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors rounded-lg"
|
||||||
|
onClick={() => setExpandedMentorId(isExpanded ? null : mentor.mentorId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{mentor.mentorName}</span>
|
||||||
|
{mentor.projects?.some((p: any) => p.messageCount > 0) && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5">
|
||||||
|
Recently active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="tabular-nums text-xs">
|
||||||
|
{mentor.projects?.length ?? 0} projects
|
||||||
|
</Badge>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && mentor.projects && (
|
||||||
|
<div className="border-t divide-y">
|
||||||
|
{mentor.projects.map((proj: any) => (
|
||||||
|
<Link
|
||||||
|
key={proj.id}
|
||||||
|
href={`/observer/projects/${proj.id}` as Route}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm truncate">{proj.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{proj.teamName ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0 text-xs text-muted-foreground">
|
||||||
|
<MessageCircle className="h-3 w-3" />
|
||||||
|
<span className="tabular-nums">{proj.messageCount}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No mentor assignments yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/components/observer/dashboard/previous-round-section.tsx
Normal file
148
src/components/observer/dashboard/previous-round-section.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.analytics.getPreviousRoundComparison.useQuery(
|
||||||
|
{ currentRoundId },
|
||||||
|
{ refetchInterval: 60_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-40 w-full rounded-lg" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.hasPrevious) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previousRound, currentRound, eliminated, categoryBreakdown, countryAttrition } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard index={5}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-between w-full text-left"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||||
|
<TrendingDown className="h-4 w-4 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
Compared to Previous Round: {previousRound.name}
|
||||||
|
</CardTitle>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
: <ChevronUp className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Headline Stat */}
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4">
|
||||||
|
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{eliminated} project{eliminated !== 1 ? 's' : ''} eliminated
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{previousRound.projectCount} → {currentRound.projectCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Survival Bars */}
|
||||||
|
{categoryBreakdown && categoryBreakdown.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
By Category
|
||||||
|
</p>
|
||||||
|
{categoryBreakdown.map((cat: any) => {
|
||||||
|
const maxVal = Math.max(cat.previous, 1)
|
||||||
|
const prevPct = 100
|
||||||
|
const currPct = (cat.current / maxVal) * 100
|
||||||
|
return (
|
||||||
|
<div key={cat.category} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium truncate">{cat.category}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{cat.previous} → {cat.current}
|
||||||
|
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all"
|
||||||
|
style={{ width: `${prevPct}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-brand-teal transition-all"
|
||||||
|
style={{ width: `${currPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Country Attrition */}
|
||||||
|
{countryAttrition && countryAttrition.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Country Attrition (Top 10)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
|
{countryAttrition.map((c: any) => (
|
||||||
|
<div key={c.country} className="flex items-center justify-between text-sm py-0.5">
|
||||||
|
<span className="truncate">{c.country}</span>
|
||||||
|
<Badge variant="destructive" className="tabular-nums text-xs">
|
||||||
|
-{c.lost}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Score Comparison */}
|
||||||
|
{previousRound.avgScore != null && currentRound.avgScore != null && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card className="p-3 text-center border-muted">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{previousRound.name}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{typeof previousRound.avgScore === 'number'
|
||||||
|
? previousRound.avgScore.toFixed(1)
|
||||||
|
: previousRound.avgScore}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Avg Score</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center border-brand-teal/30">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{currentRound.name}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{typeof currentRound.avgScore === 'number'
|
||||||
|
? currentRound.avgScore.toFixed(1)
|
||||||
|
: currentRound.avgScore}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Avg Score</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
src/components/observer/dashboard/submission-panel.tsx
Normal file
164
src/components/observer/dashboard/submission-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { FileText, Upload, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
function relativeTime(date: Date | string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const then = new Date(date).getTime()
|
||||||
|
const diff = Math.floor((now - then) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_TYPE_ICONS: Record<string, string> = {
|
||||||
|
pdf: '📄',
|
||||||
|
image: '🖼️',
|
||||||
|
video: '🎥',
|
||||||
|
default: '📎',
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(fileType: string | null | undefined): string {
|
||||||
|
if (!fileType) return FILE_TYPE_ICONS.default
|
||||||
|
const ft = fileType.toLowerCase()
|
||||||
|
if (ft.includes('pdf')) return FILE_TYPE_ICONS.pdf
|
||||||
|
if (ft.includes('image') || ft.includes('png') || ft.includes('jpg') || ft.includes('jpeg')) return FILE_TYPE_ICONS.image
|
||||||
|
if (ft.includes('video') || ft.includes('mp4')) return FILE_TYPE_ICONS.video
|
||||||
|
return FILE_TYPE_ICONS.default
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionPanel({ roundId, programId }: { roundId: string; programId: string }) {
|
||||||
|
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: recentFiles } = trpc.analytics.getRecentFiles.useQuery(
|
||||||
|
{ roundId, limit: 10 },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
|
||||||
|
{ roundId, perPage: 15 },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = roundStats?.stats as {
|
||||||
|
totalFiles: number
|
||||||
|
teamsSubmitted: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const files = recentFiles ?? []
|
||||||
|
const projects = projectsData?.projects ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Skeleton className="h-20 rounded-lg" />
|
||||||
|
<Skeleton className="h-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-violet-500" />
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">{stats.totalFiles}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Files Uploaded</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-blue-500" />
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">{stats.teamsSubmitted}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Teams Submitted</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Recent Document Uploads */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="h-4 w-4 text-violet-500" />
|
||||||
|
Recent Documents
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{files.map((f: any) => (
|
||||||
|
<div key={f.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-lg shrink-0">
|
||||||
|
{fileIcon(f.fileType)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{f.fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
<Link
|
||||||
|
href={`/observer/projects/${f.project?.id}` as Route}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{f.project?.title ?? 'Unknown project'}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
|
||||||
|
{f.createdAt ? relativeTime(f.createdAt) : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Teams */}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="h-4 w-4 text-emerald-500" />
|
||||||
|
Project Teams
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/observer/projects/${p.id}` as Route}
|
||||||
|
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{p.teamName ?? 'No team'} · {p.country ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{p.country ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,36 +11,30 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
|
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
|
||||||
import { useEditionContext } from '@/components/observer/observer-edition-context'
|
import { useEditionContext } from '@/components/observer/observer-edition-context'
|
||||||
import {
|
import {
|
||||||
ClipboardList,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
Globe,
|
Globe,
|
||||||
ChevronRight,
|
|
||||||
Activity,
|
Activity,
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
ArrowRight,
|
|
||||||
Lock,
|
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
ClipboardList,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { IntakePanel } from '@/components/observer/dashboard/intake-panel'
|
||||||
|
import { FilteringPanel } from '@/components/observer/dashboard/filtering-panel'
|
||||||
|
import { EvaluationPanel } from '@/components/observer/dashboard/evaluation-panel'
|
||||||
|
import { SubmissionPanel } from '@/components/observer/dashboard/submission-panel'
|
||||||
|
import { MentoringPanel } from '@/components/observer/dashboard/mentoring-panel'
|
||||||
|
import { LiveFinalPanel } from '@/components/observer/dashboard/live-final-panel'
|
||||||
|
import { DeliberationPanel } from '@/components/observer/dashboard/deliberation-panel'
|
||||||
|
import { PreviousRoundSection } from '@/components/observer/dashboard/previous-round-section'
|
||||||
|
|
||||||
function relativeTime(date: Date | string): string {
|
function relativeTime(date: Date | string): string {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const then = new Date(date).getTime()
|
const then = new Date(date).getTime()
|
||||||
@@ -56,11 +47,7 @@ function relativeTime(date: Date | string): string {
|
|||||||
|
|
||||||
function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string {
|
function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string {
|
||||||
const midpoints: Record<string, number> = {
|
const midpoints: Record<string, number> = {
|
||||||
'9-10': 9.5,
|
'9-10': 9.5, '7-8': 7.5, '5-6': 5.5, '3-4': 3.5, '1-2': 1.5,
|
||||||
'7-8': 7.5,
|
|
||||||
'5-6': 5.5,
|
|
||||||
'3-4': 3.5,
|
|
||||||
'1-2': 1.5,
|
|
||||||
}
|
}
|
||||||
let total = 0
|
let total = 0
|
||||||
let weightedSum = 0
|
let weightedSum = 0
|
||||||
@@ -75,52 +62,6 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]):
|
|||||||
return (weightedSum / total).toFixed(1)
|
return (weightedSum / total).toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
|
||||||
ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
|
|
||||||
ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
|
|
||||||
'round.reopened': { icon: Clock, color: 'text-emerald-500' },
|
|
||||||
'round.closed': { icon: Lock, color: 'text-slate-500' },
|
|
||||||
EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
|
|
||||||
ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
|
|
||||||
PROJECT_ADVANCED: { icon: ArrowRight, color: 'text-teal-500' },
|
|
||||||
PROJECT_REJECTED: { icon: XCircle, color: 'text-rose-500' },
|
|
||||||
RESULT_LOCKED: { icon: Lock, color: 'text-amber-500' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanizeActivity(item: { eventType: string; actorName?: string | null; details?: Record<string, unknown> | null }): string {
|
|
||||||
const actor = item.actorName ?? 'System'
|
|
||||||
const details = item.details ?? {}
|
|
||||||
const projectName = (details.projectTitle ?? details.projectName ?? '') as string
|
|
||||||
const roundName = (details.roundName ?? '') as string
|
|
||||||
|
|
||||||
switch (item.eventType) {
|
|
||||||
case 'EVALUATION_SUBMITTED':
|
|
||||||
return projectName
|
|
||||||
? `${actor} submitted a review for ${projectName}`
|
|
||||||
: `${actor} submitted a review`
|
|
||||||
case 'ROUND_ACTIVATED':
|
|
||||||
case 'round.reopened':
|
|
||||||
return roundName ? `${roundName} was opened` : 'A round was opened'
|
|
||||||
case 'ROUND_CLOSED':
|
|
||||||
case 'round.closed':
|
|
||||||
return roundName ? `${roundName} was closed` : 'A round was closed'
|
|
||||||
case 'ASSIGNMENT_CREATED':
|
|
||||||
return projectName
|
|
||||||
? `${projectName} was assigned to a juror`
|
|
||||||
: 'A project was assigned'
|
|
||||||
case 'PROJECT_ADVANCED':
|
|
||||||
return projectName
|
|
||||||
? `${projectName} advanced${roundName ? ` to ${roundName}` : ''}`
|
|
||||||
: 'A project advanced'
|
|
||||||
case 'PROJECT_REJECTED':
|
|
||||||
return projectName ? `${projectName} was rejected` : 'A project was rejected'
|
|
||||||
case 'RESULT_LOCKED':
|
|
||||||
return roundName ? `Results locked for ${roundName}` : 'Results were locked'
|
|
||||||
default:
|
|
||||||
return `${actor}: ${item.eventType.replace(/_/g, ' ').toLowerCase()}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
|
const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||||
ROUND_ACTIVE: 'default',
|
ROUND_ACTIVE: 'default',
|
||||||
ROUND_CLOSED: 'secondary',
|
ROUND_CLOSED: 'secondary',
|
||||||
@@ -128,11 +69,52 @@ const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'>
|
|||||||
ROUND_ARCHIVED: 'secondary',
|
ROUND_ARCHIVED: 'secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
const CATEGORY_ICONS: Record<string, { icon: typeof Activity; color: string }> = {
|
||||||
const { programs, selectedProgramId, activeRoundId } = useEditionContext()
|
round: { icon: Clock, color: 'text-teal-500' },
|
||||||
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
|
evaluation: { icon: CheckCircle, color: 'text-blue-500' },
|
||||||
|
project: { icon: ClipboardList, color: 'text-emerald-500' },
|
||||||
|
file: { icon: Upload, color: 'text-violet-500' },
|
||||||
|
deliberation: { icon: Users, color: 'text-amber-500' },
|
||||||
|
system: { icon: Activity, color: 'text-slate-400' },
|
||||||
|
}
|
||||||
|
|
||||||
const roundIdParam = activeRoundId || undefined
|
function RoundPanel({ roundType, roundId, programId }: { roundType: string; roundId: string; programId: string }) {
|
||||||
|
switch (roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return <IntakePanel roundId={roundId} programId={programId} />
|
||||||
|
case 'FILTERING':
|
||||||
|
return <FilteringPanel roundId={roundId} />
|
||||||
|
case 'EVALUATION':
|
||||||
|
return <EvaluationPanel roundId={roundId} programId={programId} />
|
||||||
|
case 'SUBMISSION':
|
||||||
|
return <SubmissionPanel roundId={roundId} programId={programId} />
|
||||||
|
case 'MENTORING':
|
||||||
|
return <MentoringPanel roundId={roundId} />
|
||||||
|
case 'LIVE_FINAL':
|
||||||
|
return <LiveFinalPanel roundId={roundId} />
|
||||||
|
case 'DELIBERATION':
|
||||||
|
return <DeliberationPanel />
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Card className="p-6 text-center text-muted-foreground">
|
||||||
|
<p>Select a round to view details.</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||||
|
const {
|
||||||
|
programs,
|
||||||
|
selectedProgramId,
|
||||||
|
selectedRoundId,
|
||||||
|
setSelectedRoundId,
|
||||||
|
selectedRoundType,
|
||||||
|
rounds,
|
||||||
|
activeRoundId,
|
||||||
|
} = useEditionContext()
|
||||||
|
|
||||||
|
const roundIdParam = selectedRoundId || undefined
|
||||||
|
|
||||||
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
|
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
|
||||||
{ roundId: roundIdParam },
|
{ roundId: roundIdParam },
|
||||||
@@ -147,51 +129,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
{ enabled: !!competitionId, refetchInterval: 30_000 },
|
{ enabled: !!competitionId, refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
|
|
||||||
{ programId: selectedProgramId || undefined },
|
|
||||||
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
|
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
|
||||||
{ programId: selectedProgramId },
|
{ programId: selectedProgramId },
|
||||||
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
|
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
|
|
||||||
{ perPage: 10 },
|
|
||||||
{ refetchInterval: 30_000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
|
const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
|
||||||
{ limit: 10 },
|
{ limit: 15, roundId: selectedRoundId || undefined },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const countryCount = geoData ? geoData.length : 0
|
const countryCount = geoData ? geoData.length : 0
|
||||||
|
|
||||||
const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
|
const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
|
||||||
|
|
||||||
const allJurors = jurorWorkload ?? []
|
|
||||||
|
|
||||||
const scoreColors: Record<string, string> = {
|
|
||||||
'9-10': '#053d57',
|
|
||||||
'7-8': '#1e7a8a',
|
|
||||||
'5-6': '#557f8c',
|
|
||||||
'3-4': '#c4453a',
|
|
||||||
'1-2': '#de0f1e',
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxScoreCount = stats
|
|
||||||
? Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
|
|
||||||
: 1
|
|
||||||
|
|
||||||
const recentlyReviewed = (projectsData?.projects ?? []).filter(
|
|
||||||
(p) => {
|
|
||||||
const status = p.observerStatus ?? p.status
|
|
||||||
return status !== 'REJECTED' && status !== 'NOT_REVIEWED' && status !== 'SUBMITTED'
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -227,7 +177,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Pipeline */}
|
{/* Clickable Pipeline */}
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -237,59 +187,80 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
Competition Pipeline
|
Competition Pipeline
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Round-by-round progression overview</CardDescription>
|
<CardDescription>Click a round to view its details</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{overviewLoading || !competitionId ? (
|
{overviewLoading || !competitionId ? (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Skeleton key={i} className="h-32 w-40 shrink-0 rounded-lg" />
|
<Skeleton key={i} className="h-32 w-44 shrink-0 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : roundOverview && roundOverview.rounds.length > 0 ? (
|
) : roundOverview && roundOverview.rounds.length > 0 ? (
|
||||||
<div className="flex items-stretch gap-0 overflow-x-auto pb-2">
|
<div className="flex items-stretch gap-0 overflow-x-auto pb-2">
|
||||||
{roundOverview.rounds.map((round, idx) => (
|
{roundOverview.rounds.map((round, idx) => {
|
||||||
<div key={round.roundName + idx} className="flex items-center">
|
const isSelected = selectedRoundId === round.roundId
|
||||||
<Card className="w-44 shrink-0 border shadow-sm">
|
const isActive = round.roundStatus === 'ROUND_ACTIVE'
|
||||||
<CardContent className="p-3 space-y-2">
|
return (
|
||||||
<p className="text-xs font-semibold leading-tight truncate" title={round.roundName}>
|
<div key={round.roundId ?? round.roundName + idx} className="flex items-center">
|
||||||
{round.roundName}
|
<button
|
||||||
</p>
|
type="button"
|
||||||
<div className="flex flex-wrap gap-1">
|
onClick={() => setSelectedRoundId(round.roundId)}
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
className="text-left focus:outline-none"
|
||||||
{round.roundType.replace(/_/g, ' ')}
|
>
|
||||||
</Badge>
|
<Card className={cn(
|
||||||
<Badge
|
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md',
|
||||||
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
|
isSelected && 'ring-2 ring-brand-teal shadow-md',
|
||||||
className="text-[10px] px-1.5 py-0"
|
)}>
|
||||||
>
|
<CardContent className="p-3 space-y-2">
|
||||||
{round.roundStatus === 'ROUND_ACTIVE'
|
<div className="flex items-center gap-1.5">
|
||||||
? 'Active'
|
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
|
||||||
: round.roundStatus === 'ROUND_CLOSED'
|
{round.roundName}
|
||||||
? 'Closed'
|
</p>
|
||||||
: round.roundStatus === 'ROUND_DRAFT'
|
{isActive && (
|
||||||
? 'Draft'
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
: round.roundStatus === 'ROUND_ARCHIVED'
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
? 'Archived'
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||||
: round.roundStatus}
|
</span>
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-1">
|
||||||
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||||
</p>
|
{round.roundType.replace(/_/g, ' ')}
|
||||||
<div className="space-y-1">
|
</Badge>
|
||||||
<Progress value={round.completionRate} className="h-1.5" />
|
<Badge
|
||||||
<p className="text-[10px] text-muted-foreground tabular-nums">
|
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
|
||||||
{round.completionRate}% complete
|
className="text-[10px] px-1.5 py-0"
|
||||||
</p>
|
>
|
||||||
</div>
|
{round.roundStatus === 'ROUND_ACTIVE'
|
||||||
</CardContent>
|
? 'Active'
|
||||||
</Card>
|
: round.roundStatus === 'ROUND_CLOSED'
|
||||||
{idx < roundOverview.rounds.length - 1 && (
|
? 'Closed'
|
||||||
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
|
: round.roundStatus === 'ROUND_DRAFT'
|
||||||
)}
|
? 'Draft'
|
||||||
</div>
|
: round.roundStatus === 'ROUND_ARCHIVED'
|
||||||
))}
|
? 'Archived'
|
||||||
|
: round.roundStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={round.completionRate} className="h-1.5" />
|
||||||
|
<p className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{round.completionRate}% complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
{idx < roundOverview.rounds.length - 1 && (
|
||||||
|
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
|
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
|
||||||
@@ -298,202 +269,26 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Middle Row */}
|
{/* Main Content: Round Panel + Activity Feed */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Left column: Score Distribution + Recently Reviewed stacked */}
|
{/* Left: Round-specific panel */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="lg:col-span-2">
|
||||||
{/* Score Distribution */}
|
{selectedRoundId && selectedRoundType ? (
|
||||||
<AnimatedCard index={7}>
|
<RoundPanel
|
||||||
<Card>
|
roundType={selectedRoundType}
|
||||||
<CardHeader className="pb-2">
|
roundId={selectedRoundId}
|
||||||
<CardTitle className="flex items-center gap-2.5 text-base">
|
programId={selectedProgramId}
|
||||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
/>
|
||||||
<TrendingUp className="h-4 w-4 text-amber-500" />
|
) : (
|
||||||
</div>
|
<Card className="p-6 text-center text-muted-foreground">
|
||||||
Score Distribution
|
<p>Select a round from the pipeline above.</p>
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{stats.scoreDistribution.map((bucket) => (
|
|
||||||
<div key={bucket.label} className="flex items-center gap-2">
|
|
||||||
<span className="w-8 text-right text-[11px] font-medium tabular-nums text-muted-foreground">
|
|
||||||
{bucket.label}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 14 }}>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: `${maxScoreCount > 0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
|
|
||||||
backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-6 text-right text-[11px] tabular-nums text-muted-foreground">
|
|
||||||
{bucket.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-4 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
)}
|
||||||
|
|
||||||
{/* Recently Reviewed */}
|
|
||||||
<AnimatedCard index={10} className="flex-1 flex flex-col">
|
|
||||||
<Card className="flex-1 flex flex-col">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2.5 text-base">
|
|
||||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
|
||||||
<ClipboardList className="h-4 w-4 text-emerald-500" />
|
|
||||||
</div>
|
|
||||||
Recently Reviewed
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Latest project reviews</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{recentlyReviewed.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Project</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right whitespace-nowrap">Score</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{recentlyReviewed.map((project) => (
|
|
||||||
<TableRow key={project.id}>
|
|
||||||
<TableCell className="max-w-[140px]">
|
|
||||||
<Link
|
|
||||||
href={`/observer/projects/${project.id}` as Route}
|
|
||||||
className="block truncate text-sm font-medium hover:underline"
|
|
||||||
title={project.title}
|
|
||||||
>
|
|
||||||
{project.title}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={project.observerStatus ?? project.status} size="sm" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
|
|
||||||
{project.evaluationCount > 0 && project.averageScore !== null
|
|
||||||
? project.averageScore.toFixed(1)
|
|
||||||
: '—'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="border-t px-4 py-3">
|
|
||||||
<Link
|
|
||||||
href={"/observer/projects" as Route}
|
|
||||||
className="flex items-center gap-1 text-sm font-medium text-brand-teal hover:underline"
|
|
||||||
>
|
|
||||||
View All <ChevronRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 p-4">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Juror Workload — scrollable list of all jurors */}
|
{/* Right: Activity Feed */}
|
||||||
<AnimatedCard index={8}>
|
|
||||||
<Card className="h-full flex flex-col">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2.5 text-base">
|
|
||||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
|
||||||
<Users className="h-4 w-4 text-violet-500" />
|
|
||||||
</div>
|
|
||||||
Juror Workload
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>All jurors by assignment</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 overflow-hidden">
|
|
||||||
{allJurors.length > 0 ? (
|
|
||||||
<div className="max-h-[500px] overflow-y-auto -mr-2 pr-2 space-y-3">
|
|
||||||
{allJurors.map((juror) => {
|
|
||||||
const isExpanded = expandedJurorId === juror.id
|
|
||||||
return (
|
|
||||||
<div key={juror.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="truncate font-medium" title={juror.name ?? ''}>
|
|
||||||
{juror.name ?? 'Unknown'}
|
|
||||||
</span>
|
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1.5">
|
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
|
||||||
{juror.completionRate}%
|
|
||||||
</span>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Progress value={juror.completionRate} className="h-1.5" />
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{juror.completed} / {juror.assigned} evaluations
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
{isExpanded && juror.projects && (
|
|
||||||
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
|
|
||||||
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
|
|
||||||
<Link
|
|
||||||
key={proj.id}
|
|
||||||
href={`/observer/projects/${proj.id}` as Route}
|
|
||||||
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
|
|
||||||
>
|
|
||||||
<span className="truncate">{proj.title}</span>
|
|
||||||
<StatusBadge status={proj.evalStatus} size="sm" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="space-y-1">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-1.5 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Activity Feed */}
|
|
||||||
<AnimatedCard index={9}>
|
<AnimatedCard index={9}>
|
||||||
<Card className="h-full">
|
<Card className="h-full flex flex-col">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2.5 text-base">
|
<CardTitle className="flex items-center gap-2.5 text-base">
|
||||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||||
@@ -503,21 +298,18 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Recent platform events</CardDescription>
|
<CardDescription>Recent platform events</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 overflow-hidden">
|
||||||
{activityFeed && activityFeed.length > 0 ? (
|
{activityFeed && activityFeed.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="max-h-[600px] overflow-y-auto -mr-2 pr-2 space-y-3">
|
||||||
{activityFeed
|
{activityFeed.slice(0, 8).map((item) => {
|
||||||
.filter((item) => !item.eventType.includes('transitioned') && !item.eventType.includes('transition'))
|
const iconDef = CATEGORY_ICONS[item.category ?? 'system'] ?? CATEGORY_ICONS.system
|
||||||
.slice(0, 5)
|
const IconComponent = iconDef.icon
|
||||||
.map((item) => {
|
const iconColor = iconDef.color
|
||||||
const iconDef = ACTIVITY_ICONS[item.eventType]
|
|
||||||
const IconComponent = iconDef?.icon ?? Activity
|
|
||||||
const iconColor = iconDef?.color ?? 'text-slate-400'
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-start gap-3">
|
<div key={item.id} className="flex items-start gap-3">
|
||||||
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
|
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
|
||||||
<p className="min-w-0 flex-1 text-sm leading-snug">
|
<p className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
{humanizeActivity(item)}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
{relativeTime(item.createdAt)}
|
{relativeTime(item.createdAt)}
|
||||||
@@ -542,6 +334,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Previous Round Comparison */}
|
||||||
|
{selectedRoundId && (
|
||||||
|
<PreviousRoundSection currentRoundId={selectedRoundId} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Full-width Map */}
|
{/* Full-width Map */}
|
||||||
<AnimatedCard index={11}>
|
<AnimatedCard index={11}>
|
||||||
{selectedProgramId ? (
|
{selectedProgramId ? (
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
|
type RoundInfo = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
competitionId?: string
|
||||||
|
roundType?: string
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
type Program = {
|
type Program = {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
year?: number
|
year?: number
|
||||||
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
|
rounds?: RoundInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditionContextValue = {
|
type EditionContextValue = {
|
||||||
@@ -15,6 +24,13 @@ type EditionContextValue = {
|
|||||||
selectedProgramId: string
|
selectedProgramId: string
|
||||||
setSelectedProgramId: (id: string) => void
|
setSelectedProgramId: (id: string) => void
|
||||||
activeRoundId: string
|
activeRoundId: string
|
||||||
|
/** The user-selected round (defaults to best/active round) */
|
||||||
|
selectedRoundId: string
|
||||||
|
setSelectedRoundId: (id: string) => void
|
||||||
|
/** Derived roundType for the selected round */
|
||||||
|
selectedRoundType: string
|
||||||
|
/** All rounds for the selected program (sorted by sortOrder) */
|
||||||
|
rounds: RoundInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditionContext = createContext<EditionContextValue | null>(null)
|
const EditionContext = createContext<EditionContextValue | null>(null)
|
||||||
@@ -35,23 +51,37 @@ function findBestRound(rounds: Array<{ id: string; status: string }>): string {
|
|||||||
|
|
||||||
export function EditionProvider({ children }: { children: ReactNode }) {
|
export function EditionProvider({ children }: { children: ReactNode }) {
|
||||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||||
|
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||||
|
|
||||||
const { data: programs } = trpc.program.list.useQuery(
|
const { data: programs } = trpc.program.list.useQuery(
|
||||||
{ includeStages: true },
|
{ includeStages: true },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (programs && programs.length > 0 && !selectedProgramId) {
|
|
||||||
setSelectedProgramId(programs[0].id)
|
|
||||||
}
|
|
||||||
}, [programs, selectedProgramId])
|
|
||||||
|
|
||||||
const typedPrograms = (programs ?? []) as Program[]
|
const typedPrograms = (programs ?? []) as Program[]
|
||||||
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
|
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
|
||||||
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
|
const rounds = useMemo(
|
||||||
|
() => ((selectedProgram?.rounds ?? []) as RoundInfo[]).slice().sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
|
||||||
|
[selectedProgram?.rounds],
|
||||||
|
)
|
||||||
const activeRoundId = findBestRound(rounds)
|
const activeRoundId = findBestRound(rounds)
|
||||||
|
|
||||||
|
// Auto-select first program
|
||||||
|
useEffect(() => {
|
||||||
|
if (typedPrograms.length > 0 && !selectedProgramId) {
|
||||||
|
setSelectedProgramId(typedPrograms[0].id)
|
||||||
|
}
|
||||||
|
}, [typedPrograms, selectedProgramId])
|
||||||
|
|
||||||
|
// Auto-select best round when program changes or rounds load
|
||||||
|
useEffect(() => {
|
||||||
|
if (rounds.length > 0 && (!selectedRoundId || !rounds.some(r => r.id === selectedRoundId))) {
|
||||||
|
setSelectedRoundId(findBestRound(rounds))
|
||||||
|
}
|
||||||
|
}, [rounds, selectedRoundId])
|
||||||
|
|
||||||
|
const selectedRoundType = rounds.find(r => r.id === selectedRoundId)?.roundType ?? ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditionContext.Provider
|
<EditionContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -59,6 +89,10 @@ export function EditionProvider({ children }: { children: ReactNode }) {
|
|||||||
selectedProgramId,
|
selectedProgramId,
|
||||||
setSelectedProgramId,
|
setSelectedProgramId,
|
||||||
activeRoundId,
|
activeRoundId,
|
||||||
|
selectedRoundId,
|
||||||
|
setSelectedRoundId,
|
||||||
|
selectedRoundType,
|
||||||
|
rounds,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ interface RequirementUploadSlotProps {
|
|||||||
|
|
||||||
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
|
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
|
||||||
const { data } = trpc.file.getDownloadUrl.useQuery(
|
const { data } = trpc.file.getDownloadUrl.useQuery(
|
||||||
{ bucket, objectKey, forDownload: false, purpose: 'open' as const },
|
{ bucket, objectKey, forDownload: false },
|
||||||
{ staleTime: 10 * 60 * 1000 }
|
{ staleTime: 10 * 60 * 1000 }
|
||||||
)
|
)
|
||||||
const href = typeof data === 'string' ? data : data?.url
|
const href = typeof data === 'string' ? data : data?.url
|
||||||
@@ -87,7 +87,7 @@ function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: stri
|
|||||||
|
|
||||||
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
||||||
const { data } = trpc.file.getDownloadUrl.useQuery(
|
const { data } = trpc.file.getDownloadUrl.useQuery(
|
||||||
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
|
{ bucket, objectKey, forDownload: true, fileName },
|
||||||
{ staleTime: 10 * 60 * 1000 }
|
{ staleTime: 10 * 60 * 1000 }
|
||||||
)
|
)
|
||||||
const href = typeof data === 'string' ? data : data?.url
|
const href = typeof data === 'string' ? data : data?.url
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
adapter: {
|
adapter: {
|
||||||
...PrismaAdapter(prisma),
|
...PrismaAdapter(prisma),
|
||||||
|
// Block auto-creation of users via magic link — only pre-created users can log in
|
||||||
|
createUser: () => {
|
||||||
|
throw new Error('Self-registration is not allowed. Please contact an administrator.')
|
||||||
|
},
|
||||||
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
|
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
|
||||||
try {
|
try {
|
||||||
return await prisma.verificationToken.delete({
|
return await prisma.verificationToken.delete({
|
||||||
@@ -39,7 +43,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
// Email provider for magic links (used for first login and password reset)
|
// Email provider for magic links (only for existing active users)
|
||||||
EmailProvider({
|
EmailProvider({
|
||||||
// Server config required by NextAuth validation but not used —
|
// Server config required by NextAuth validation but not used —
|
||||||
// sendVerificationRequest below fully overrides email sending via getTransporter()
|
// sendVerificationRequest below fully overrides email sending via getTransporter()
|
||||||
@@ -54,6 +58,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
|
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
|
||||||
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
|
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
|
||||||
sendVerificationRequest: async ({ identifier: email, url }) => {
|
sendVerificationRequest: async ({ identifier: email, url }) => {
|
||||||
|
// Only send magic links to existing, ACTIVE users
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase().trim() },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
if (!existingUser || existingUser.status !== 'ACTIVE') {
|
||||||
|
// Silently skip — don't reveal whether the email exists (prevents enumeration)
|
||||||
|
console.log(`[auth] Magic link requested for non-active/unknown email: ${email}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
await sendMagicLinkEmail(email, url)
|
await sendMagicLinkEmail(email, url)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -355,7 +369,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (dbUser?.status === 'SUSPENDED') {
|
// Block non-existent users (defense-in-depth against adapter auto-creation)
|
||||||
|
if (!dbUser) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbUser.status === 'SUSPENDED') {
|
||||||
return false // Block suspended users
|
return false // Block suspended users
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,12 +382,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
// The completeOnboarding mutation sets status to ACTIVE.
|
// The completeOnboarding mutation sets status to ACTIVE.
|
||||||
|
|
||||||
// Add user data for JWT callback
|
// Add user data for JWT callback
|
||||||
if (dbUser) {
|
user.id = dbUser.id
|
||||||
user.id = dbUser.id
|
user.role = dbUser.role
|
||||||
user.role = dbUser.role
|
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
||||||
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
||||||
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login time on actual sign-in
|
// Update last login time on actual sign-in
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { normalizeCountryToCode } from '@/lib/countries'
|
|||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import { getProjectLogoUrl } from '../utils/project-logo-url'
|
import { getProjectLogoUrl } from '../utils/project-logo-url'
|
||||||
import { aggregateVotes } from '../services/deliberation'
|
import { aggregateVotes } from '../services/deliberation'
|
||||||
|
import { validateRoundConfig } from '@/types/competition-configs'
|
||||||
|
import type { LiveFinalConfig } from '@/types/competition-configs'
|
||||||
|
|
||||||
const editionOrRoundInput = z.object({
|
const editionOrRoundInput = z.object({
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
@@ -1482,13 +1484,26 @@ export const analyticsRouter = router({
|
|||||||
* Activity feed — recent audit log entries for observer dashboard
|
* Activity feed — recent audit log entries for observer dashboard
|
||||||
*/
|
*/
|
||||||
getActivityFeed: observerProcedure
|
getActivityFeed: observerProcedure
|
||||||
.input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
|
.input(z.object({
|
||||||
|
limit: z.number().min(1).max(50).default(10),
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
}).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const limit = input?.limit ?? 10
|
const limit = input?.limit ?? 10
|
||||||
|
const roundId = input?.roundId
|
||||||
|
|
||||||
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
// --- DecisionAuditLog entries (dot-notation events) ---
|
||||||
|
const dalWhere: Record<string, unknown> = {}
|
||||||
|
if (roundId) {
|
||||||
|
dalWhere.OR = [
|
||||||
|
{ entityType: 'Round', entityId: roundId },
|
||||||
|
{ detailsJson: { path: ['roundId'], equals: roundId } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const dalEntries = await ctx.prisma.decisionAuditLog.findMany({
|
||||||
|
where: dalWhere,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit,
|
take: limit * 2,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
eventType: true,
|
eventType: true,
|
||||||
@@ -1500,25 +1515,203 @@ export const analyticsRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- AuditLog entries (SCREAMING_SNAKE_CASE actions) ---
|
||||||
|
const alActions = [
|
||||||
|
'EVALUATION_SUBMITTED', 'EVALUATION_SAVE_DRAFT',
|
||||||
|
'PROJECT_CREATE', 'PROJECT_UPDATE',
|
||||||
|
'FILE_VIEWED', 'FILE_OPENED', 'FILE_DOWNLOADED',
|
||||||
|
]
|
||||||
|
const alWhere: Record<string, unknown> = { action: { in: alActions } }
|
||||||
|
if (roundId) {
|
||||||
|
alWhere.detailsJson = { path: ['roundId'], equals: roundId }
|
||||||
|
}
|
||||||
|
const alEntries = await ctx.prisma.auditLog.findMany({
|
||||||
|
where: alWhere,
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
take: limit * 2,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
action: true,
|
||||||
|
entityType: true,
|
||||||
|
entityId: true,
|
||||||
|
userId: true,
|
||||||
|
detailsJson: true,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Batch-fetch actor names
|
// Batch-fetch actor names
|
||||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
const allActorIds = [
|
||||||
const actors = actorIds.length > 0
|
...new Set([
|
||||||
|
...dalEntries.map((e) => e.actorId),
|
||||||
|
...alEntries.map((e) => e.userId),
|
||||||
|
].filter(Boolean)),
|
||||||
|
] as string[]
|
||||||
|
const actors = allActorIds.length > 0
|
||||||
? await ctx.prisma.user.findMany({
|
? await ctx.prisma.user.findMany({
|
||||||
where: { id: { in: actorIds } },
|
where: { id: { in: allActorIds } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
|
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
|
||||||
|
|
||||||
return entries.map((entry) => ({
|
type FeedItem = {
|
||||||
id: entry.id,
|
id: string
|
||||||
eventType: entry.eventType,
|
description: string
|
||||||
entityType: entry.entityType,
|
category: 'round' | 'evaluation' | 'project' | 'file' | 'deliberation' | 'system'
|
||||||
entityId: entry.entityId,
|
createdAt: Date
|
||||||
actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
|
}
|
||||||
details: entry.detailsJson as Record<string, unknown> | null,
|
|
||||||
createdAt: entry.createdAt,
|
// Humanize DecisionAuditLog entries
|
||||||
}))
|
const dalItems: FeedItem[] = dalEntries
|
||||||
|
.filter((e) => !e.eventType.includes('transitioned') && !e.eventType.includes('cursor_updated'))
|
||||||
|
.map((entry) => {
|
||||||
|
const actor = entry.actorId ? actorMap.get(entry.actorId) ?? 'System' : 'System'
|
||||||
|
const details = (entry.detailsJson ?? {}) as Record<string, unknown>
|
||||||
|
const roundName = (details.roundName ?? '') as string
|
||||||
|
const projectTitle = (details.projectTitle ?? details.projectName ?? '') as string
|
||||||
|
|
||||||
|
let description: string
|
||||||
|
let category: FeedItem['category'] = 'system'
|
||||||
|
|
||||||
|
switch (entry.eventType) {
|
||||||
|
case 'round.activated':
|
||||||
|
case 'round.reopened':
|
||||||
|
description = roundName ? `${roundName} was opened` : 'A round was opened'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'round.closed':
|
||||||
|
description = roundName ? `${roundName} was closed` : 'A round was closed'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'round.archived':
|
||||||
|
description = roundName ? `${roundName} was archived` : 'A round was archived'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'round.finalized':
|
||||||
|
description = roundName ? `Results finalized for ${roundName}` : 'Round results finalized'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'results.locked':
|
||||||
|
description = roundName ? `Results locked for ${roundName}` : 'Results were locked'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'results.unlocked':
|
||||||
|
description = roundName ? `Results unlocked for ${roundName}` : 'Results were unlocked'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'override.applied':
|
||||||
|
description = projectTitle
|
||||||
|
? `${actor} overrode decision for ${projectTitle}`
|
||||||
|
: `${actor} applied a decision override`
|
||||||
|
category = 'project'
|
||||||
|
break
|
||||||
|
case 'finalization.project_outcome':
|
||||||
|
description = projectTitle
|
||||||
|
? `${projectTitle} outcome: ${(details.outcome as string) ?? 'determined'}`
|
||||||
|
: 'Project outcome determined'
|
||||||
|
category = 'project'
|
||||||
|
break
|
||||||
|
case 'deliberation.created':
|
||||||
|
description = 'Deliberation session created'
|
||||||
|
category = 'deliberation'
|
||||||
|
break
|
||||||
|
case 'deliberation.finalized':
|
||||||
|
description = 'Deliberation session finalized'
|
||||||
|
category = 'deliberation'
|
||||||
|
break
|
||||||
|
case 'deliberation.admin_override':
|
||||||
|
description = `${actor} applied deliberation override`
|
||||||
|
category = 'deliberation'
|
||||||
|
break
|
||||||
|
case 'live.session_started':
|
||||||
|
description = 'Live voting session started'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'submission_window.opened':
|
||||||
|
description = 'Submission window opened'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'submission_window.closed':
|
||||||
|
description = 'Submission window closed'
|
||||||
|
category = 'round'
|
||||||
|
break
|
||||||
|
case 'mentor_workspace.activated':
|
||||||
|
description = projectTitle
|
||||||
|
? `Mentoring workspace activated for ${projectTitle}`
|
||||||
|
: 'Mentoring workspace activated'
|
||||||
|
category = 'project'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
description = `${actor}: ${entry.eventType.replace(/[_.]/g, ' ')}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: entry.id, description, category, createdAt: entry.createdAt }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Humanize AuditLog entries
|
||||||
|
const alItems: FeedItem[] = alEntries.map((entry) => {
|
||||||
|
const actor = entry.userId ? actorMap.get(entry.userId) ?? 'Someone' : 'System'
|
||||||
|
const details = (entry.detailsJson ?? {}) as Record<string, unknown>
|
||||||
|
const projectTitle = (details.projectTitle ?? details.entityLabel ?? '') as string
|
||||||
|
|
||||||
|
let description: string
|
||||||
|
let category: FeedItem['category'] = 'system'
|
||||||
|
|
||||||
|
switch (entry.action) {
|
||||||
|
case 'EVALUATION_SUBMITTED':
|
||||||
|
description = projectTitle
|
||||||
|
? `${actor} submitted evaluation for ${projectTitle}`
|
||||||
|
: `${actor} submitted an evaluation`
|
||||||
|
category = 'evaluation'
|
||||||
|
break
|
||||||
|
case 'EVALUATION_SAVE_DRAFT':
|
||||||
|
description = projectTitle
|
||||||
|
? `${actor} saved draft evaluation for ${projectTitle}`
|
||||||
|
: `${actor} saved a draft evaluation`
|
||||||
|
category = 'evaluation'
|
||||||
|
break
|
||||||
|
case 'PROJECT_CREATE':
|
||||||
|
description = projectTitle
|
||||||
|
? `New project submitted: ${projectTitle}`
|
||||||
|
: 'New project submitted'
|
||||||
|
category = 'project'
|
||||||
|
break
|
||||||
|
case 'PROJECT_UPDATE':
|
||||||
|
description = projectTitle
|
||||||
|
? `${projectTitle} was updated`
|
||||||
|
: 'A project was updated'
|
||||||
|
category = 'project'
|
||||||
|
break
|
||||||
|
case 'FILE_VIEWED':
|
||||||
|
case 'FILE_OPENED':
|
||||||
|
description = `${actor} viewed a document${projectTitle ? ` for ${projectTitle}` : ''}`
|
||||||
|
category = 'file'
|
||||||
|
break
|
||||||
|
case 'FILE_DOWNLOADED':
|
||||||
|
description = `${actor} downloaded a document${projectTitle ? ` for ${projectTitle}` : ''}`
|
||||||
|
category = 'file'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
description = `${actor}: ${entry.action.replace(/_/g, ' ').toLowerCase()}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `al_${entry.id}`,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
createdAt: entry.timestamp,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge and sort by date, take limit
|
||||||
|
const merged = [...dalItems, ...alItems]
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
|
||||||
|
return merged
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1752,4 +1945,338 @@ export const analyticsRouter = router({
|
|||||||
totalProjects: projectAssignCounts.size,
|
totalProjects: projectAssignCounts.size,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Observer Dashboard V2 Queries
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
getPreviousRoundComparison: observerProcedure
|
||||||
|
.input(z.object({ currentRoundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.currentRoundId },
|
||||||
|
select: { id: true, name: true, roundType: true, sortOrder: true, competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find the previous round by sortOrder
|
||||||
|
const previousRound = await ctx.prisma.round.findFirst({
|
||||||
|
where: {
|
||||||
|
competitionId: currentRound.competitionId,
|
||||||
|
sortOrder: { lt: currentRound.sortOrder },
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'desc' },
|
||||||
|
select: { id: true, name: true, roundType: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
return { hasPrevious: false as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project counts and category breakdowns for both rounds
|
||||||
|
const [prevStates, currStates, prevProjects, currProjects] = await Promise.all([
|
||||||
|
ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: previousRound.id },
|
||||||
|
select: { projectId: true, state: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: currentRound.id },
|
||||||
|
select: { projectId: true, state: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.findMany({
|
||||||
|
where: { projectRoundStates: { some: { roundId: previousRound.id } } },
|
||||||
|
select: { id: true, competitionCategory: true, country: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.findMany({
|
||||||
|
where: { projectRoundStates: { some: { roundId: currentRound.id } } },
|
||||||
|
select: { id: true, competitionCategory: true, country: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const prevPassedCount = prevStates.filter(s => s.state === 'PASSED').length
|
||||||
|
const prevRejectedCount = prevStates.filter(s => s.state === 'REJECTED').length
|
||||||
|
|
||||||
|
// Category breakdown
|
||||||
|
const prevByCategory = new Map<string, number>()
|
||||||
|
prevProjects.forEach(p => {
|
||||||
|
const cat = p.competitionCategory ?? 'Uncategorized'
|
||||||
|
prevByCategory.set(cat, (prevByCategory.get(cat) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
const currByCategory = new Map<string, number>()
|
||||||
|
currProjects.forEach(p => {
|
||||||
|
const cat = p.competitionCategory ?? 'Uncategorized'
|
||||||
|
currByCategory.set(cat, (currByCategory.get(cat) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
const allCategories = new Set([...prevByCategory.keys(), ...currByCategory.keys()])
|
||||||
|
const categoryBreakdown = [...allCategories].map(cat => ({
|
||||||
|
category: cat,
|
||||||
|
previous: prevByCategory.get(cat) ?? 0,
|
||||||
|
current: currByCategory.get(cat) ?? 0,
|
||||||
|
eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Country attrition
|
||||||
|
const prevByCountry = new Map<string, number>()
|
||||||
|
prevProjects.forEach(p => {
|
||||||
|
const c = p.country ?? 'Unknown'
|
||||||
|
prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
const currByCountry = new Map<string, number>()
|
||||||
|
currProjects.forEach(p => {
|
||||||
|
const c = p.country ?? 'Unknown'
|
||||||
|
currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()])
|
||||||
|
const countryAttrition = [...allCountries]
|
||||||
|
.map(country => ({
|
||||||
|
country,
|
||||||
|
previous: prevByCountry.get(country) ?? 0,
|
||||||
|
current: currByCountry.get(country) ?? 0,
|
||||||
|
lost: (prevByCountry.get(country) ?? 0) - (currByCountry.get(country) ?? 0),
|
||||||
|
}))
|
||||||
|
.filter(c => c.lost > 0)
|
||||||
|
.sort((a, b) => b.lost - a.lost)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
// Average scores (if evaluation rounds)
|
||||||
|
let prevAvgScore: number | null = null
|
||||||
|
let currAvgScore: number | null = null
|
||||||
|
const [prevEvals, currEvals] = await Promise.all([
|
||||||
|
ctx.prisma.evaluation.findMany({
|
||||||
|
where: { assignment: { roundId: previousRound.id }, status: 'SUBMITTED', globalScore: { not: null } },
|
||||||
|
select: { globalScore: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.evaluation.findMany({
|
||||||
|
where: { assignment: { roundId: currentRound.id }, status: 'SUBMITTED', globalScore: { not: null } },
|
||||||
|
select: { globalScore: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
if (prevEvals.length > 0) {
|
||||||
|
prevAvgScore = prevEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / prevEvals.length
|
||||||
|
}
|
||||||
|
if (currEvals.length > 0) {
|
||||||
|
currAvgScore = currEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / currEvals.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasPrevious: true as const,
|
||||||
|
previousRound: {
|
||||||
|
id: previousRound.id,
|
||||||
|
name: previousRound.name,
|
||||||
|
type: previousRound.roundType,
|
||||||
|
projectCount: prevProjects.length,
|
||||||
|
avgScore: prevAvgScore,
|
||||||
|
passedCount: prevPassedCount,
|
||||||
|
rejectedCount: prevRejectedCount,
|
||||||
|
},
|
||||||
|
currentRound: {
|
||||||
|
id: currentRound.id,
|
||||||
|
name: currentRound.name,
|
||||||
|
type: currentRound.roundType,
|
||||||
|
projectCount: currProjects.length,
|
||||||
|
avgScore: currAvgScore,
|
||||||
|
},
|
||||||
|
eliminated: prevProjects.length - currProjects.length,
|
||||||
|
categoryBreakdown,
|
||||||
|
countryAttrition,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRoundAdvancementConfig: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { roundType: true, configJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = round.configJson as Record<string, unknown> | null
|
||||||
|
if (!config) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
advanceMode: (config.advanceMode as string) ?? 'count',
|
||||||
|
startupAdvanceCount: config.startupAdvanceCount as number | undefined,
|
||||||
|
conceptAdvanceCount: config.conceptAdvanceCount as number | undefined,
|
||||||
|
advanceScoreThreshold: config.advanceScoreThreshold as number | undefined,
|
||||||
|
advancementMode: config.advancementMode as string | undefined,
|
||||||
|
advancementConfig: config.advancementConfig as Record<string, unknown> | undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRecentFiles: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), limit: z.number().min(1).max(50).default(10) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const files = await ctx.prisma.projectFile.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: input.limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
createdAt: true,
|
||||||
|
project: {
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
}),
|
||||||
|
|
||||||
|
getMentoringDashboard: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
project: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mentorId: true,
|
||||||
|
projectId: true,
|
||||||
|
completionStatus: true,
|
||||||
|
mentor: { select: { id: true, name: true } },
|
||||||
|
project: { select: { id: true, title: true, teamName: true } },
|
||||||
|
messages: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by mentor
|
||||||
|
const mentorMap = new Map<string, {
|
||||||
|
mentorName: string
|
||||||
|
mentorId: string
|
||||||
|
projects: { id: string; title: string; teamName: string | null; messageCount: number }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
let totalMessages = 0
|
||||||
|
const activeMentorIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
const mentorId = a.mentorId
|
||||||
|
if (!mentorMap.has(mentorId)) {
|
||||||
|
mentorMap.set(mentorId, {
|
||||||
|
mentorName: a.mentor.name ?? 'Unknown',
|
||||||
|
mentorId,
|
||||||
|
projects: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const messageCount = a.messages.length
|
||||||
|
totalMessages += messageCount
|
||||||
|
if (messageCount > 0) activeMentorIds.add(mentorId)
|
||||||
|
|
||||||
|
mentorMap.get(mentorId)!.projects.push({
|
||||||
|
id: a.project.id,
|
||||||
|
title: a.project.title,
|
||||||
|
teamName: a.project.teamName,
|
||||||
|
messageCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignments: [...mentorMap.values()],
|
||||||
|
totalMessages,
|
||||||
|
activeMentors: activeMentorIds.size,
|
||||||
|
totalMentors: mentorMap.size,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getLiveFinalDashboard: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Get round config for observer visibility setting
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true, roundType: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let observerScoreVisibility: 'realtime' | 'after_completion' | 'hidden' = 'after_completion'
|
||||||
|
try {
|
||||||
|
if (round.roundType === 'LIVE_FINAL') {
|
||||||
|
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
|
||||||
|
observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion'
|
||||||
|
}
|
||||||
|
} catch { /* use default */ }
|
||||||
|
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
currentProjectIndex: true,
|
||||||
|
projectOrderJson: true,
|
||||||
|
votingMode: true,
|
||||||
|
votes: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
|
score: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
sessionStatus: 'NOT_STARTED' as const,
|
||||||
|
observerScoreVisibility,
|
||||||
|
voteCount: 0,
|
||||||
|
jurors: [] as { id: string; name: string; hasVoted: boolean }[],
|
||||||
|
standings: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get jurors assigned to this round
|
||||||
|
const jurorUsers = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true, user: { select: { id: true, name: true } } },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const voterIds = new Set(session.votes.map(v => v.userId))
|
||||||
|
const jurors = jurorUsers.map(j => ({
|
||||||
|
id: j.user.id,
|
||||||
|
name: j.user.name ?? 'Unknown',
|
||||||
|
hasVoted: voterIds.has(j.user.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Calculate standings if visibility allows
|
||||||
|
const showScores =
|
||||||
|
observerScoreVisibility === 'realtime' ||
|
||||||
|
(observerScoreVisibility === 'after_completion' && session.status === 'COMPLETED')
|
||||||
|
|
||||||
|
let standings: { projectId: string; projectTitle: string; avgScore: number; voteCount: number }[] | null = null
|
||||||
|
|
||||||
|
if (showScores && session.votes.length > 0) {
|
||||||
|
const projectScores = new Map<string, number[]>()
|
||||||
|
for (const v of session.votes) {
|
||||||
|
if (v.score != null) {
|
||||||
|
if (!projectScores.has(v.projectId)) projectScores.set(v.projectId, [])
|
||||||
|
projectScores.get(v.projectId)!.push(v.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = [...projectScores.keys()]
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
const projMap = new Map(projects.map(p => [p.id, p.title]))
|
||||||
|
|
||||||
|
standings = [...projectScores.entries()]
|
||||||
|
.map(([projectId, scores]) => ({
|
||||||
|
projectId,
|
||||||
|
projectTitle: projMap.get(projectId) ?? 'Unknown',
|
||||||
|
avgScore: scores.reduce((a, b) => a + b, 0) / scores.length,
|
||||||
|
voteCount: scores.length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.avgScore - a.avgScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionStatus: session.status,
|
||||||
|
observerScoreVisibility,
|
||||||
|
voteCount: session.votes.length,
|
||||||
|
jurors,
|
||||||
|
standings,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||||
import {
|
import {
|
||||||
getImageUploadUrl,
|
getImageUploadUrl,
|
||||||
@@ -110,9 +110,9 @@ export const logoRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a project's logo URL
|
* Get a project's logo URL (any authenticated user — logos are public display data)
|
||||||
*/
|
*/
|
||||||
getUrl: adminProcedure
|
getUrl: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const programRouter = router({
|
|||||||
competitionId: round.competitionId,
|
competitionId: round.competitionId,
|
||||||
status: round.status,
|
status: round.status,
|
||||||
roundType: round.roundType,
|
roundType: round.roundType,
|
||||||
|
sortOrder: round.sortOrder,
|
||||||
votingEndAt: round.windowCloseAt,
|
votingEndAt: round.windowCloseAt,
|
||||||
_count: {
|
_count: {
|
||||||
projects: round._count?.projectRoundStates || 0,
|
projects: round._count?.projectRoundStates || 0,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
|||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||||
@@ -481,7 +482,7 @@ export const specialAwardRouter = router({
|
|||||||
listJurors: protectedProcedure
|
listJurors: protectedProcedure
|
||||||
.input(z.object({ awardId: z.string() }))
|
.input(z.object({ awardId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.prisma.awardJuror.findMany({
|
const jurors = await ctx.prisma.awardJuror.findMany({
|
||||||
where: { awardId: input.awardId },
|
where: { awardId: input.awardId },
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@@ -496,6 +497,15 @@ export const specialAwardRouter = router({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
return Promise.all(
|
||||||
|
jurors.map(async (j) => ({
|
||||||
|
...j,
|
||||||
|
user: {
|
||||||
|
...j.user,
|
||||||
|
avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ export const LiveFinalConfigSchema = z.object({
|
|||||||
qaDurationMinutes: z.number().int().nonnegative().default(5),
|
qaDurationMinutes: z.number().int().nonnegative().default(5),
|
||||||
|
|
||||||
revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'),
|
revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'),
|
||||||
|
|
||||||
|
/** Controls whether observers can see live scores: realtime, after session completes, or never */
|
||||||
|
observerScoreVisibility: z.enum(['realtime', 'after_completion', 'hidden']).default('after_completion'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type LiveFinalConfig = z.infer<typeof LiveFinalConfigSchema>
|
export type LiveFinalConfig = z.infer<typeof LiveFinalConfigSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user