Observer platform: mobile fixes, data/UX overhaul, animated nav
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Fix dashboard default round selection to target active round instead of R1 - Move edition selector from dashboard header to hamburger menu via shared context - Add observer-friendly status labels (Not Reviewed / Under Review / Reviewed) - Fix pipeline completion: closed rounds show 100%, cap all rates at 100% - Round badge on projects list shows furthest round reached - Hide scores/evals for projects with zero evaluations - Enhance project detail round history with pass/reject indicators from ProjectRoundState - Remove irrelevant fields (Org Type, Budget, Duration) from project detail - Clickable juror workload with expandable project assignments - Humanize activity feed with icons and readable messages - Fix jurors table: responsive card layout on mobile - Fix criteria chart: horizontal bars for readable labels on mobile - Animate hamburger menu open/close with CSS grid transition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { ObserverNav } from '@/components/layouts/observer-nav'
|
import { ObserverNav } from '@/components/layouts/observer-nav'
|
||||||
|
import { EditionProvider } from '@/components/observer/observer-edition-context'
|
||||||
|
|
||||||
export default async function ObserverLayout({
|
export default async function ObserverLayout({
|
||||||
children,
|
children,
|
||||||
@@ -10,13 +11,15 @@ export default async function ObserverLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<ObserverNav
|
<EditionProvider>
|
||||||
user={{
|
<ObserverNav
|
||||||
name: session.user.name,
|
user={{
|
||||||
email: session.user.email,
|
name: session.user.name,
|
||||||
}}
|
email: session.user.email,
|
||||||
/>
|
}}
|
||||||
<main className="container-app py-6">{children}</main>
|
/>
|
||||||
|
<main className="container-app py-6">{children}</main>
|
||||||
|
</EditionProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,47 +504,90 @@ function JurorsTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-[400px]" />
|
<Skeleton className="h-[400px]" />
|
||||||
) : jurors.length > 0 ? (
|
) : jurors.length > 0 ? (
|
||||||
<Card>
|
<>
|
||||||
<CardContent className="p-0">
|
{/* Desktop Table */}
|
||||||
<Table>
|
<Card className="hidden md:block">
|
||||||
<TableHeader>
|
<CardContent className="p-0">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Juror</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="text-right">Assigned</TableHead>
|
<TableRow>
|
||||||
<TableHead className="text-right">Completed</TableHead>
|
<TableHead>Juror</TableHead>
|
||||||
<TableHead className="min-w-[140px]">Rate</TableHead>
|
<TableHead className="text-right">Assigned</TableHead>
|
||||||
<TableHead className="text-right">Avg Score</TableHead>
|
<TableHead className="text-right">Completed</TableHead>
|
||||||
<TableHead className="text-right">Std Dev</TableHead>
|
<TableHead className="min-w-[140px]">Rate</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="text-right">Avg Score</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-right">Std Dev</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableBody>
|
|
||||||
{jurors.map((j) => (
|
|
||||||
<TableRow key={j.userId}>
|
|
||||||
<TableCell className="font-medium">{j.name}</TableCell>
|
|
||||||
<TableCell className="text-right">{j.assigned}</TableCell>
|
|
||||||
<TableCell className="text-right">{j.completed}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Progress value={j.completionRate} className="h-2 w-20" />
|
|
||||||
<span className="text-sm tabular-nums">{j.completionRate}%</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">{j.averageScore.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">{j.stddev.toFixed(2)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{j.isOutlier ? (
|
|
||||||
<Badge variant="destructive">Outlier</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">Normal</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{jurors.map((j) => (
|
||||||
</CardContent>
|
<TableRow key={j.userId}>
|
||||||
</Card>
|
<TableCell className="font-medium">{j.name}</TableCell>
|
||||||
|
<TableCell className="text-right">{j.assigned}</TableCell>
|
||||||
|
<TableCell className="text-right">{j.completed}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={j.completionRate} className="h-2 w-20" />
|
||||||
|
<span className="text-sm tabular-nums">{j.completionRate}%</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.averageScore.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.stddev.toFixed(2)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{j.isOutlier ? (
|
||||||
|
<Badge variant="destructive">Outlier</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Normal</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{jurors.map((j) => (
|
||||||
|
<Card key={j.userId}>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium text-sm truncate">{j.name}</p>
|
||||||
|
{j.isOutlier ? (
|
||||||
|
<Badge variant="destructive" className="shrink-0">Outlier</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="shrink-0">Normal</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={j.completionRate} className="h-2 flex-1" />
|
||||||
|
<span className="text-sm tabular-nums shrink-0">{j.completionRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Assigned</span>
|
||||||
|
<span className="tabular-nums">{j.assigned}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Completed</span>
|
||||||
|
<span className="tabular-nums">{j.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Avg Score</span>
|
||||||
|
<span className="tabular-nums">{j.averageScore.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Std Dev</span>
|
||||||
|
<span className="tabular-nums">{j.stddev.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : hasSelection ? (
|
) : hasSelection ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
|
|
||||||
const chartData = data.map((d) => ({
|
const chartData = data.map((d) => ({
|
||||||
criterion:
|
criterion:
|
||||||
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
d.name.length > 40 ? d.name.substring(0, 40) + '...' : d.name,
|
||||||
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
|
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>Score by Evaluation Criteria</span>
|
<span>Score by Evaluation Criteria</span>
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
Overall Avg: {overallAverage.toFixed(2)}
|
Overall Avg: {overallAverage.toFixed(2)}
|
||||||
@@ -46,10 +46,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
categories={['Avg Score']}
|
categories={['Avg Score']}
|
||||||
colors={[BRAND_TEAL] as string[]}
|
colors={[BRAND_TEAL] as string[]}
|
||||||
maxValue={10}
|
maxValue={10}
|
||||||
yAxisWidth={40}
|
layout="vertical"
|
||||||
|
yAxisWidth={160}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
className="h-[300px]"
|
className="h-[300px]"
|
||||||
rotateLabelX={{ angle: -45, xAxisHeight: 60 }}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,11 +2,40 @@
|
|||||||
|
|
||||||
import { BarChart3, Home, FolderKanban } from 'lucide-react'
|
import { BarChart3, Home, FolderKanban } from 'lucide-react'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
import { useEditionContext } from '@/components/observer/observer-edition-context'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
interface ObserverNavProps {
|
interface ObserverNavProps {
|
||||||
user: RoleNavUser
|
user: RoleNavUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditionSelector() {
|
||||||
|
const { programs, selectedProgramId, setSelectedProgramId } = useEditionContext()
|
||||||
|
|
||||||
|
if (programs.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={selectedProgramId} onValueChange={setSelectedProgramId}>
|
||||||
|
<SelectTrigger className="w-full md:w-[180px]">
|
||||||
|
<SelectValue placeholder="Select edition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{programs.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.year ? `${p.year} Edition` : p.name ?? p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ObserverNav({ user }: ObserverNavProps) {
|
export function ObserverNav({ user }: ObserverNavProps) {
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -32,6 +61,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
|||||||
roleName="Observer"
|
roleName="Observer"
|
||||||
user={user}
|
user={user}
|
||||||
basePath="/observer"
|
basePath="/observer"
|
||||||
|
editionSelector={<EditionSelector />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ type RoleNavProps = {
|
|||||||
basePath: string
|
basePath: string
|
||||||
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
|
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
|
||||||
statusBadge?: React.ReactNode
|
statusBadge?: React.ReactNode
|
||||||
|
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
|
||||||
|
editionSelector?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { status: sessionStatus } = useSession()
|
const { status: sessionStatus } = useSession()
|
||||||
@@ -93,6 +95,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||||||
|
|
||||||
{/* User menu & mobile toggle */}
|
{/* User menu & mobile toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -161,42 +164,54 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu — animated with CSS grid */}
|
||||||
{isMobileMenuOpen && (
|
<div
|
||||||
<div className="border-t md:hidden">
|
className={cn(
|
||||||
<nav className="container-app py-4 space-y-1">
|
'grid md:hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||||
{navigation.map((item) => {
|
isMobileMenuOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||||
const isActive = isNavItemActive(pathname, item.href, basePath)
|
)}
|
||||||
return (
|
>
|
||||||
<Link
|
<div className="overflow-hidden">
|
||||||
key={item.name}
|
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
|
||||||
href={item.href as Route}
|
<nav className="container-app py-4 space-y-1">
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
{navigation.map((item) => {
|
||||||
className={cn(
|
const isActive = isNavItemActive(pathname, item.href, basePath)
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
return (
|
||||||
isActive
|
<Link
|
||||||
? 'bg-primary/10 text-primary'
|
key={item.name}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
href={item.href as Route}
|
||||||
)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{editionSelector && (
|
||||||
|
<div className="border-t pt-4 mt-4 px-3">
|
||||||
|
{editionSelector}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start text-destructive hover:text-destructive"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
{item.name}
|
Sign Out
|
||||||
</Link>
|
</Button>
|
||||||
)
|
</div>
|
||||||
})}
|
</nav>
|
||||||
<div className="border-t pt-4 mt-4">
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start text-destructive hover:text-destructive"
|
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
Sign Out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, Fragment } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -14,13 +14,6 @@ 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 {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -32,6 +25,7 @@ import {
|
|||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
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 {
|
import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -41,7 +35,13 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Activity,
|
Activity,
|
||||||
RefreshCw,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ArrowRight,
|
||||||
|
Lock,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -76,14 +76,50 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]):
|
|||||||
return (weightedSum / total).toFixed(1)
|
return (weightedSum / total).toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_DOT_COLORS: Record<string, string> = {
|
const ACTIVITY_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||||
ROUND_ACTIVATED: 'bg-emerald-500',
|
ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
|
||||||
ROUND_CLOSED: 'bg-slate-500',
|
ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
|
||||||
EVALUATION_SUBMITTED: 'bg-blue-500',
|
'round.reopened': { icon: Clock, color: 'text-emerald-500' },
|
||||||
ASSIGNMENT_CREATED: 'bg-violet-500',
|
'round.closed': { icon: Lock, color: 'text-slate-500' },
|
||||||
PROJECT_ADVANCED: 'bg-teal-500',
|
EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
|
||||||
PROJECT_REJECTED: 'bg-rose-500',
|
ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
|
||||||
RESULT_LOCKED: 'bg-amber-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'> = {
|
||||||
@@ -94,31 +130,17 @@ const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
const { programs, selectedProgramId, activeRoundId } = useEditionContext()
|
||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data: programs } = trpc.program.list.useQuery(
|
const roundIdParam = activeRoundId || undefined
|
||||||
{ includeStages: true },
|
|
||||||
{ refetchInterval: 30_000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (programs && programs.length > 0 && !selectedProgramId) {
|
|
||||||
const firstProgram = programs[0]
|
|
||||||
setSelectedProgramId(firstProgram.id)
|
|
||||||
const firstRound = (firstProgram.rounds ?? [])[0]
|
|
||||||
if (firstRound) setSelectedRoundId(firstRound.id)
|
|
||||||
}
|
|
||||||
}, [programs, selectedProgramId])
|
|
||||||
|
|
||||||
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 },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
|
const selectedProgram = programs.find((p) => p.id === selectedProgramId)
|
||||||
const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
|
const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
|
||||||
|
|
||||||
const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
|
const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
|
||||||
@@ -167,37 +189,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
|
||||||
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
||||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700">
|
|
||||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
|
||||||
Auto-refresh
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={selectedProgramId}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setSelectedProgramId(val)
|
|
||||||
const prog = programs?.find((p) => p.id === val)
|
|
||||||
const firstRound = (prog?.rounds ?? [])[0]
|
|
||||||
setSelectedRoundId(firstRound?.id ?? '')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full sm:w-[220px]">
|
|
||||||
<SelectValue placeholder="Select edition" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(programs ?? []).map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{(p as { year?: number }).year ? `${(p as { year?: number }).year} Edition` : p.name ?? p.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Six Stat Tiles */}
|
{/* Six Stat Tiles */}
|
||||||
@@ -411,23 +405,53 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{topJurors.length > 0 ? (
|
{topJurors.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{topJurors.map((juror) => (
|
{topJurors.map((juror) => {
|
||||||
<div key={juror.id} className="space-y-1">
|
const isExpanded = expandedJurorId === juror.id
|
||||||
<div className="flex items-center justify-between text-sm">
|
return (
|
||||||
<span className="truncate font-medium" title={juror.name ?? ''}>
|
<div key={juror.id}>
|
||||||
{juror.name ?? 'Unknown'}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
<span className="ml-2 shrink-0 text-xs tabular-nums text-muted-foreground">
|
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
|
||||||
{juror.completionRate}%
|
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
|
||||||
</span>
|
>
|
||||||
|
<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>
|
||||||
<Progress value={juror.completionRate} className="h-1.5" />
|
)
|
||||||
<p className="text-[11px] text-muted-foreground">
|
})}
|
||||||
{juror.completed} / {juror.assigned} evaluations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -474,9 +498,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||||
<ClipboardList className="h-4 w-4 text-emerald-500" />
|
<ClipboardList className="h-4 w-4 text-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
Recent Projects
|
Recently Reviewed
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Latest project activity</CardDescription>
|
<CardDescription>Latest project reviews</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{projectsData && projectsData.projects.length > 0 ? (
|
{projectsData && projectsData.projects.length > 0 ? (
|
||||||
@@ -486,7 +510,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Score</TableHead>
|
<TableHead className="text-right whitespace-nowrap">Score</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -505,10 +529,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-sm">
|
<TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
|
||||||
{project.averageScore !== null ? project.averageScore.toFixed(1) : '—'}
|
{project.evaluationCount > 0 && project.averageScore !== null
|
||||||
|
? project.averageScore.toFixed(1)
|
||||||
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -549,32 +575,22 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{activityFeed && activityFeed.length > 0 ? (
|
{activityFeed && activityFeed.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{activityFeed.map((item) => (
|
{activityFeed.slice(0, 5).map((item) => {
|
||||||
<div key={item.id} className="flex items-start gap-3">
|
const iconDef = ACTIVITY_ICONS[item.eventType]
|
||||||
<span
|
const IconComponent = iconDef?.icon ?? Activity
|
||||||
className={cn(
|
const iconColor = iconDef?.color ?? 'text-slate-400'
|
||||||
'mt-1.5 h-2 w-2 shrink-0 rounded-full',
|
return (
|
||||||
ACTIVITY_DOT_COLORS[item.eventType] ?? 'bg-slate-400',
|
<div key={item.id} className="flex items-start gap-3">
|
||||||
)}
|
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
|
||||||
/>
|
<p className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
<div className="min-w-0 flex-1">
|
{humanizeActivity(item)}
|
||||||
<p className="text-sm leading-snug">
|
|
||||||
<span className="font-medium">
|
|
||||||
{item.eventType.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
{item.entityType && (
|
|
||||||
<span className="text-muted-foreground"> — {item.entityType.replace(/_/g, ' ').toLowerCase()}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
{item.actorName && (
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
<p className="text-[11px] text-muted-foreground">by {item.actorName}</p>
|
{relativeTime(item.createdAt)}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
)
|
||||||
{relativeTime(item.createdAt)}
|
})}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
67
src/components/observer/observer-edition-context.tsx
Normal file
67
src/components/observer/observer-edition-context.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
|
type Program = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
year?: number
|
||||||
|
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditionContextValue = {
|
||||||
|
programs: Program[]
|
||||||
|
selectedProgramId: string
|
||||||
|
setSelectedProgramId: (id: string) => void
|
||||||
|
activeRoundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditionContext = createContext<EditionContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useEditionContext() {
|
||||||
|
const ctx = useContext(EditionContext)
|
||||||
|
if (!ctx) throw new Error('useEditionContext must be used within EditionProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestRound(rounds: Array<{ id: string; status: string }>): string {
|
||||||
|
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
|
||||||
|
if (active) return active.id
|
||||||
|
const closed = [...rounds].filter(r => r.status === 'ROUND_CLOSED').pop()
|
||||||
|
if (closed) return closed.id
|
||||||
|
return rounds[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||||
|
|
||||||
|
const { data: programs } = trpc.program.list.useQuery(
|
||||||
|
{ includeStages: true },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (programs && programs.length > 0 && !selectedProgramId) {
|
||||||
|
setSelectedProgramId(programs[0].id)
|
||||||
|
}
|
||||||
|
}, [programs, selectedProgramId])
|
||||||
|
|
||||||
|
const typedPrograms = (programs ?? []) as Program[]
|
||||||
|
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
|
||||||
|
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
|
||||||
|
const activeRoundId = findBestRound(rounds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditionContext.Provider
|
||||||
|
value={{
|
||||||
|
programs: typedPrograms,
|
||||||
|
selectedProgramId,
|
||||||
|
setSelectedProgramId,
|
||||||
|
activeRoundId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EditionContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||||
@@ -85,9 +85,13 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, assignments, stats, competitionRounds, allRequirements } =
|
const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements } =
|
||||||
data
|
data
|
||||||
|
|
||||||
|
const roundStateMap = new Map(
|
||||||
|
(projectRoundStates ?? []).map((s) => [s.roundId, s]),
|
||||||
|
)
|
||||||
|
|
||||||
const criteriaMap = new Map<
|
const criteriaMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ label: string; type: string; trueLabel?: string; falseLabel?: string }
|
{ label: string; type: string; trueLabel?: string; falseLabel?: string }
|
||||||
@@ -338,26 +342,12 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
: '-'}
|
: '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Org Type</p>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{project.institution || '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Country</p>
|
<p className="text-xs text-muted-foreground">Country</p>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{project.country || project.geographicZone || '-'}
|
{project.country || project.geographicZone || '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Budget</p>
|
|
||||||
<p className="text-sm font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Duration</p>
|
|
||||||
<p className="text-sm font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">AI Score</p>
|
<p className="text-xs text-muted-foreground">AI Score</p>
|
||||||
<p className="text-sm font-medium">-</p>
|
<p className="text-sm font-medium">-</p>
|
||||||
@@ -421,72 +411,102 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Round History */}
|
{/* Round History */}
|
||||||
{competitionRounds.length > 0 && (
|
{competitionRounds.length > 0 && (() => {
|
||||||
<AnimatedCard index={3}>
|
const passedCount = competitionRounds.filter((r) => {
|
||||||
<Card>
|
const s = roundStateMap.get(r.id)
|
||||||
<CardHeader>
|
return s && (s.state === 'PASSED' || s.state === 'COMPLETED')
|
||||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
}).length
|
||||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
const rejectedRound = competitionRounds.find((r) => {
|
||||||
<Calendar className="h-4 w-4 text-violet-500" />
|
const s = roundStateMap.get(r.id)
|
||||||
</div>
|
return s?.state === 'REJECTED'
|
||||||
Round History
|
})
|
||||||
</CardTitle>
|
return (
|
||||||
</CardHeader>
|
<AnimatedCard index={3}>
|
||||||
<CardContent>
|
<Card>
|
||||||
<ol className="space-y-4">
|
<CardHeader>
|
||||||
{competitionRounds.map((round, idx) => {
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
// Determine round status from assignments
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
const roundAssignments = assignments.filter(
|
<Calendar className="h-4 w-4 text-violet-500" />
|
||||||
(a) => a.roundId === round.id,
|
</div>
|
||||||
)
|
Round History
|
||||||
const hasInProgressAssignments = roundAssignments.some(
|
</CardTitle>
|
||||||
(a) => a.evaluation?.status === 'DRAFT',
|
<CardDescription>
|
||||||
)
|
{rejectedRound
|
||||||
const allSubmitted =
|
? `Rejected at ${rejectedRound.name}`
|
||||||
roundAssignments.length > 0 &&
|
: `Passed ${passedCount} of ${competitionRounds.length} rounds`}
|
||||||
roundAssignments.every(
|
</CardDescription>
|
||||||
(a) => a.evaluation?.status === 'SUBMITTED',
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{competitionRounds.map((round) => {
|
||||||
|
const roundState = roundStateMap.get(round.id)
|
||||||
|
const state = roundState?.state
|
||||||
|
|
||||||
|
const roundAssignments = assignments.filter(
|
||||||
|
(a) => a.roundId === round.id,
|
||||||
)
|
)
|
||||||
const isPast = idx < competitionRounds.length - 1 && allSubmitted
|
|
||||||
const isActive = hasInProgressAssignments || (!isPast && roundAssignments.length > 0 && !allSubmitted)
|
let icon: React.ReactNode
|
||||||
return (
|
let statusLabel: string | null = null
|
||||||
<li key={round.id} className="flex items-start gap-3">
|
if (state === 'PASSED' || state === 'COMPLETED') {
|
||||||
{isPast || allSubmitted ? (
|
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
||||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
statusLabel = 'Passed'
|
||||||
) : isActive ? (
|
} else if (state === 'REJECTED') {
|
||||||
|
icon = <AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||||
|
statusLabel = 'Rejected at this round'
|
||||||
|
} else if (state === 'IN_PROGRESS') {
|
||||||
|
icon = (
|
||||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
<span className="relative flex h-3 w-3">
|
<span className="relative flex h-3 w-3">
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
)
|
||||||
<Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
statusLabel = 'Active'
|
||||||
)}
|
} else if (state === 'PENDING') {
|
||||||
<div className="min-w-0">
|
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
||||||
<p className="text-sm font-medium">{round.name}</p>
|
statusLabel = 'Pending'
|
||||||
{roundAssignments.length > 0 && (
|
} else {
|
||||||
<p className="text-xs text-muted-foreground">
|
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" />
|
||||||
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
|
}
|
||||||
</p>
|
|
||||||
|
return (
|
||||||
|
<li key={round.id} className="flex items-start gap-3">
|
||||||
|
{icon}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{round.name}</p>
|
||||||
|
{statusLabel && (
|
||||||
|
<p className={cn(
|
||||||
|
'text-xs',
|
||||||
|
state === 'REJECTED' ? 'text-destructive' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{statusLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{roundAssignments.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{state === 'IN_PROGRESS' && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
{isActive && (
|
)
|
||||||
<Badge
|
})}
|
||||||
variant="outline"
|
</ol>
|
||||||
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
|
</CardContent>
|
||||||
>
|
</Card>
|
||||||
Active
|
</AnimatedCard>
|
||||||
</Badge>
|
)
|
||||||
)}
|
})()}
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── Evaluations Tab ── */}
|
{/* ── Evaluations Tab ── */}
|
||||||
@@ -496,7 +516,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Users className="h-10 w-10 text-muted-foreground/40" />
|
<Users className="h-10 w-10 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
No jury assignments yet
|
{project.status === 'ASSIGNED'
|
||||||
|
? 'Awaiting jury evaluation — assigned and pending review'
|
||||||
|
: 'No jury assignments yet'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -251,8 +251,9 @@ export function ObserverProjectsContent() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||||
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
<SelectItem value="NOT_REVIEWED">Not Reviewed</SelectItem>
|
||||||
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
|
||||||
|
<SelectItem value="REVIEWED">Reviewed</SelectItem>
|
||||||
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||||
@@ -344,10 +345,10 @@ export function ObserverProjectsContent() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{project.averageScore !== null ? (
|
{project.evaluationCount > 0 && project.averageScore !== null ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="tabular-nums w-8 text-sm">
|
<span className="tabular-nums w-8 text-sm">
|
||||||
{project.averageScore.toFixed(1)}
|
{project.averageScore.toFixed(1)}
|
||||||
@@ -404,24 +405,26 @@ export function ObserverProjectsContent() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.roundName}
|
{project.roundName}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex gap-3">
|
{project.evaluationCount > 0 && (
|
||||||
<span>
|
<div className="flex gap-3">
|
||||||
Score:{' '}
|
<span>
|
||||||
{project.averageScore !== null
|
Score:{' '}
|
||||||
? project.averageScore.toFixed(1)
|
{project.averageScore !== null
|
||||||
: '-'}
|
? project.averageScore.toFixed(1)
|
||||||
</span>
|
: '-'}
|
||||||
<span>
|
</span>
|
||||||
{project.evaluationCount} eval
|
<span>
|
||||||
{project.evaluationCount !== 1 ? 's' : ''}
|
{project.evaluationCount} eval
|
||||||
</span>
|
{project.evaluationCount !== 1 ? 's' : ''}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
|
|||||||
REJECTED: { variant: 'destructive' },
|
REJECTED: { variant: 'destructive' },
|
||||||
WITHDRAWN: { variant: 'secondary' },
|
WITHDRAWN: { variant: 'secondary' },
|
||||||
|
|
||||||
|
// Observer-derived statuses
|
||||||
|
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
|
||||||
|
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||||
|
|
||||||
// Evaluation statuses
|
// Evaluation statuses
|
||||||
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||||
@@ -43,7 +47,14 @@ type StatusBadgeProps = {
|
|||||||
|
|
||||||
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
||||||
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
||||||
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
NONE: 'NOT INVITED',
|
||||||
|
NOT_REVIEWED: 'Not Reviewed',
|
||||||
|
UNDER_REVIEW: 'Under Review',
|
||||||
|
REVIEWED: 'Reviewed',
|
||||||
|
SEMIFINALIST: 'Semi-finalist',
|
||||||
|
}
|
||||||
|
const label = LABEL_OVERRIDES[status] ?? status.replace(/_/g, ' ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const analyticsRouter = router({
|
|||||||
where: assignmentWhere(input),
|
where: assignmentWhere(input),
|
||||||
include: {
|
include: {
|
||||||
user: { select: { name: true } },
|
user: { select: { name: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
evaluation: {
|
evaluation: {
|
||||||
select: { id: true, status: true },
|
select: { id: true, status: true },
|
||||||
},
|
},
|
||||||
@@ -132,7 +133,7 @@ export const analyticsRouter = router({
|
|||||||
// Group by user
|
// Group by user
|
||||||
const byUser: Record<
|
const byUser: Record<
|
||||||
string,
|
string,
|
||||||
{ name: string; assigned: number; completed: number }
|
{ name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string }[] }
|
||||||
> = {}
|
> = {}
|
||||||
|
|
||||||
assignments.forEach((assignment) => {
|
assignments.forEach((assignment) => {
|
||||||
@@ -142,12 +143,19 @@ export const analyticsRouter = router({
|
|||||||
name: assignment.user.name || 'Unknown',
|
name: assignment.user.name || 'Unknown',
|
||||||
assigned: 0,
|
assigned: 0,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
|
projects: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
byUser[userId].assigned++
|
byUser[userId].assigned++
|
||||||
if (assignment.evaluation?.status === 'SUBMITTED') {
|
const evalStatus = assignment.evaluation?.status
|
||||||
|
if (evalStatus === 'SUBMITTED') {
|
||||||
byUser[userId].completed++
|
byUser[userId].completed++
|
||||||
}
|
}
|
||||||
|
byUser[userId].projects.push({
|
||||||
|
id: assignment.project.id,
|
||||||
|
title: assignment.project.title,
|
||||||
|
evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return Object.entries(byUser)
|
return Object.entries(byUser)
|
||||||
@@ -676,7 +684,7 @@ export const analyticsRouter = router({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const completionRate = totalAssignments > 0
|
const completionRate = totalAssignments > 0
|
||||||
? Math.round((submittedEvaluations / totalAssignments) * 100)
|
? Math.min(100, Math.round((submittedEvaluations / totalAssignments) * 100))
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
|
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
|
||||||
@@ -850,9 +858,11 @@ export const analyticsRouter = router({
|
|||||||
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
|
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
|
||||||
const totalAssignments = assignmentCountByRound.get(round.id) || 0
|
const totalAssignments = assignmentCountByRound.get(round.id) || 0
|
||||||
const completedEvaluations = completedEvalsByRound.get(round.id) || 0
|
const completedEvaluations = completedEvalsByRound.get(round.id) || 0
|
||||||
const completionRate = totalAssignments > 0
|
const completionRate = (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED')
|
||||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
? 100
|
||||||
: 0
|
: totalAssignments > 0
|
||||||
|
? Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100))
|
||||||
|
: 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
@@ -914,7 +924,8 @@ export const analyticsRouter = router({
|
|||||||
where.projectRoundStates = { some: { roundId: input.roundId } }
|
where.projectRoundStates = { some: { roundId: input.roundId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.status) {
|
const OBSERVER_DERIVED_STATUSES = ['NOT_REVIEWED', 'UNDER_REVIEW', 'REVIEWED']
|
||||||
|
if (input.status && !OBSERVER_DERIVED_STATUSES.includes(input.status)) {
|
||||||
where.status = input.status
|
where.status = input.status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,16 +953,25 @@ export const analyticsRouter = router({
|
|||||||
assignments: {
|
assignments: {
|
||||||
select: {
|
select: {
|
||||||
roundId: true,
|
roundId: true,
|
||||||
round: { select: { id: true, name: true } },
|
round: { select: { id: true, name: true, sortOrder: true } },
|
||||||
evaluation: {
|
evaluation: {
|
||||||
select: { globalScore: true, status: true },
|
select: { globalScore: true, status: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectRoundStates: {
|
||||||
|
select: {
|
||||||
|
roundId: true,
|
||||||
|
state: true,
|
||||||
|
round: { select: { id: true, name: true, sortOrder: true } },
|
||||||
|
},
|
||||||
|
orderBy: { round: { sortOrder: 'desc' } },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: prismaOrderBy,
|
orderBy: prismaOrderBy,
|
||||||
// When sorting by computed fields, fetch all then slice in JS
|
// When sorting by computed fields or filtering by observer-derived status, fetch all then slice in JS
|
||||||
...(input.sortBy === 'title'
|
...(input.sortBy === 'title' && !OBSERVER_DERIVED_STATUSES.includes(input.status ?? '')
|
||||||
? { skip: (input.page - 1) * input.perPage, take: input.perPage }
|
? { skip: (input.page - 1) * input.perPage, take: input.perPage }
|
||||||
: {}),
|
: {}),
|
||||||
}),
|
}),
|
||||||
@@ -962,6 +982,9 @@ export const analyticsRouter = router({
|
|||||||
const submitted = p.assignments
|
const submitted = p.assignments
|
||||||
.map((a) => a.evaluation)
|
.map((a) => a.evaluation)
|
||||||
.filter((e) => e?.status === 'SUBMITTED')
|
.filter((e) => e?.status === 'SUBMITTED')
|
||||||
|
const drafts = p.assignments
|
||||||
|
.map((a) => a.evaluation)
|
||||||
|
.filter((e) => e?.status === 'DRAFT')
|
||||||
const scores = submitted
|
const scores = submitted
|
||||||
.map((e) => e?.globalScore)
|
.map((e) => e?.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
@@ -970,51 +993,74 @@ export const analyticsRouter = router({
|
|||||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Filter assignments to the queried round so we show the correct round name
|
// Show the furthest round the project reached (from projectRoundStates, ordered by sortOrder desc)
|
||||||
|
const furthestRoundState = p.projectRoundStates[0]
|
||||||
|
// Fallback to assignment round if no round states
|
||||||
const roundAssignment = input.roundId
|
const roundAssignment = input.roundId
|
||||||
? p.assignments.find((a) => a.roundId === input.roundId)
|
? p.assignments.find((a) => a.roundId === input.roundId)
|
||||||
: p.assignments[0]
|
: p.assignments[0]
|
||||||
|
|
||||||
|
// Derive observer-friendly status
|
||||||
|
let observerStatus: string
|
||||||
|
if (p.status === 'REJECTED') observerStatus = 'REJECTED'
|
||||||
|
else if (p.status === 'SEMIFINALIST') observerStatus = 'SEMIFINALIST'
|
||||||
|
else if (p.status === 'FINALIST') observerStatus = 'FINALIST'
|
||||||
|
else if (p.status === 'SUBMITTED') observerStatus = 'SUBMITTED'
|
||||||
|
else if (submitted.length > 0) observerStatus = 'REVIEWED'
|
||||||
|
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
|
||||||
|
else observerStatus = 'NOT_REVIEWED'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
teamName: p.teamName,
|
teamName: p.teamName,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
|
observerStatus,
|
||||||
country: p.country,
|
country: p.country,
|
||||||
roundId: roundAssignment?.round?.id ?? '',
|
roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
|
||||||
roundName: roundAssignment?.round?.name ?? '',
|
roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
|
||||||
averageScore,
|
averageScore,
|
||||||
evaluationCount: submitted.length,
|
evaluationCount: submitted.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Filter by observer-derived status in JS
|
||||||
|
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
|
||||||
|
? input.status
|
||||||
|
: null
|
||||||
|
const filtered = observerStatusFilter
|
||||||
|
? mapped.filter((p) => p.observerStatus === observerStatusFilter)
|
||||||
|
: mapped
|
||||||
|
const filteredTotal = observerStatusFilter ? filtered.length : total
|
||||||
|
|
||||||
// Sort by computed fields (score, evaluations) in JS
|
// Sort by computed fields (score, evaluations) in JS
|
||||||
let sorted = mapped
|
let sorted = filtered
|
||||||
if (input.sortBy === 'score') {
|
if (input.sortBy === 'score') {
|
||||||
sorted = mapped.sort((a, b) => {
|
sorted = filtered.sort((a, b) => {
|
||||||
const sa = a.averageScore ?? -1
|
const sa = a.averageScore ?? -1
|
||||||
const sb = b.averageScore ?? -1
|
const sb = b.averageScore ?? -1
|
||||||
return input.sortDir === 'asc' ? sa - sb : sb - sa
|
return input.sortDir === 'asc' ? sa - sb : sb - sa
|
||||||
})
|
})
|
||||||
} else if (input.sortBy === 'evaluations') {
|
} else if (input.sortBy === 'evaluations') {
|
||||||
sorted = mapped.sort((a, b) =>
|
sorted = filtered.sort((a, b) =>
|
||||||
input.sortDir === 'asc'
|
input.sortDir === 'asc'
|
||||||
? a.evaluationCount - b.evaluationCount
|
? a.evaluationCount - b.evaluationCount
|
||||||
: b.evaluationCount - a.evaluationCount
|
: b.evaluationCount - a.evaluationCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate in JS for computed-field sorts
|
// Paginate in JS for computed-field sorts or observer status filter
|
||||||
const paginated = input.sortBy !== 'title'
|
const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter
|
||||||
|
const paginated = needsJsPagination
|
||||||
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
|
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
|
||||||
: sorted
|
: sorted
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects: paginated,
|
projects: paginated,
|
||||||
total,
|
total: filteredTotal,
|
||||||
page: input.page,
|
page: input.page,
|
||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
totalPages: Math.ceil(total / input.perPage),
|
totalPages: Math.ceil(filteredTotal / input.perPage),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -1256,15 +1302,21 @@ export const analyticsRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get competition rounds for file grouping
|
// Get competition rounds for file grouping
|
||||||
let competitionRounds: { id: string; name: string }[] = []
|
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
||||||
const competition = await ctx.prisma.competition.findFirst({
|
const competition = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: projectRaw.programId },
|
where: { programId: projectRaw.programId },
|
||||||
include: { rounds: { select: { id: true, name: true }, orderBy: { sortOrder: 'asc' } } },
|
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (competition) {
|
if (competition) {
|
||||||
competitionRounds = competition.rounds
|
competitionRounds = competition.rounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get project round states for round history
|
||||||
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { projectId: input.id },
|
||||||
|
select: { roundId: true, state: true, enteredAt: true, exitedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
// Get file requirements for all rounds
|
// Get file requirements for all rounds
|
||||||
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
|
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
|
||||||
if (competitionRounds.length > 0) {
|
if (competitionRounds.length > 0) {
|
||||||
@@ -1306,6 +1358,7 @@ export const analyticsRouter = router({
|
|||||||
assignments: assignmentsWithAvatars,
|
assignments: assignmentsWithAvatars,
|
||||||
stats,
|
stats,
|
||||||
competitionRounds,
|
competitionRounds,
|
||||||
|
projectRoundStates,
|
||||||
allRequirements,
|
allRequirements,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user