Fix observer reports: charts, filtering, project preview, dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Rewrite diversity metrics: horizontal bar charts for ocean issues and geographic distribution (replaces unreadable vertical/donut charts) - Rewrite juror score heatmap: expandable table with score distribution - Rewrite juror consistency: horizontal bar visual with juror names - Merge filtering tabs into single screening view with per-project AI reasoning and expandable rows - Add project preview dialog for juror performance table - Fix status breakdown for evaluation rounds (Fully/Partially/Not Reviewed) - Show active round name instead of count on observer dashboard - Move Global tab to last position, default to first round-specific tab - Add 4-card stats layout for evaluation with reviews/project ratio - Fix oceanIssue field (singular) and remove non-existent aiSummary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { ExternalLink, MapPin, Waves, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||
|
||||
interface ProjectPreviewDialogProps {
|
||||
projectId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function ScorePill({ score }: { score: number }) {
|
||||
const bg = scoreGradient(score)
|
||||
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md px-2.5 py-1 text-sm font-bold tabular-nums"
|
||||
style={{ backgroundColor: bg, color: text }}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectPreviewDialogProps) {
|
||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||
{ id: projectId! },
|
||||
{ enabled: !!projectId && open },
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
{isLoading || !data ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-tight pr-8">
|
||||
{data.project.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Project info row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={data.project.status} />
|
||||
{data.project.teamName && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{data.project.teamName}
|
||||
</Badge>
|
||||
)}
|
||||
{data.project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.project.country}
|
||||
</Badge>
|
||||
)}
|
||||
{data.project.competitionCategory && (
|
||||
<Badge variant="secondary">{data.project.competitionCategory}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{data.project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-4">
|
||||
{data.project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Ocean Issue */}
|
||||
{data.project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Waves className="h-3 w-3" />
|
||||
{data.project.oceanIssue.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Evaluation summary */}
|
||||
{data.stats && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Evaluation Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">
|
||||
{data.stats.averageGlobalScore != null ? (
|
||||
<ScorePill score={data.stats.averageGlobalScore} />
|
||||
) : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Avg Score</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">{data.stats.totalEvaluations ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Evaluations</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">{data.assignments?.length ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Assignments</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">
|
||||
{data.stats.yesPercentage != null ? `${Math.round(data.stats.yesPercentage)}%` : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Recommend</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual evaluations */}
|
||||
{data.assignments?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Juror Evaluations</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.assignments.map((a: { id: string; user: { name: string | null }; evaluation: { status: string; globalScore: unknown } | null }) => {
|
||||
const ev = a.evaluation
|
||||
const score = ev?.status === 'SUBMITTED' && ev.globalScore != null
|
||||
? Number(ev.globalScore)
|
||||
: null
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{a.user.name ?? 'Unknown'}</span>
|
||||
{ev?.status === 'SUBMITTED' ? (
|
||||
<Badge variant="default" className="text-[10px]">Reviewed</Badge>
|
||||
) : ev?.status === 'DRAFT' ? (
|
||||
<Badge variant="secondary" className="text-[10px]">Draft</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">Pending</Badge>
|
||||
)}
|
||||
</div>
|
||||
{score !== null && <ScorePill score={score} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* View full project button */}
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link href={`/observer/projects/${projectId}` as Route}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Full Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user