Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening
UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,16 @@ import {
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
@@ -63,10 +71,13 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
}))
|
||||
) || []
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
@@ -97,55 +108,79 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{rounds.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeRounds} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{rounds.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRounds} active
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalProjects}</div>
|
||||
<p className="text-xs text-muted-foreground">Across all rounds</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeRounds}</div>
|
||||
<p className="text-xs text-muted-foreground">Currently active</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{activeRounds}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalPrograms}</div>
|
||||
<p className="text-xs text-muted-foreground">Total programs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Round-specific overview stats */}
|
||||
{selectedRoundId && (
|
||||
{/* Round/edition-specific overview stats */}
|
||||
{hasSelection && (
|
||||
<>
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -163,7 +198,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
</div>
|
||||
) : overviewStats ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Selected Round Details</h3>
|
||||
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -207,7 +242,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -304,41 +339,44 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ roundId: selectedRoundId, limit: 15 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
{ ...queryInput, limit: 15 },
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -483,11 +521,14 @@ function CrossRoundTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
@@ -508,11 +549,14 @@ function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
@@ -533,22 +577,26 @@ function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
}
|
||||
|
||||
export default function ObserverReportsPage() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedValue
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -565,11 +613,16 @@ export default function ObserverReportsPage() {
|
||||
{roundsLoading ? (
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
) : rounds.length > 0 ? (
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-full sm:w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
@@ -590,7 +643,7 @@ export default function ObserverReportsPage() {
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
@@ -598,38 +651,38 @@ export default function ObserverReportsPage() {
|
||||
<GitCompare className="h-4 w-4" />
|
||||
Cross-Round
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="consistency" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Juror Consistency
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diversity" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{selectedRoundId && (
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
roundId={selectedRoundId}
|
||||
roundName={rounds.find((r) => r.id === selectedRoundId)?.name}
|
||||
programName={rounds.find((r) => r.id === selectedRoundId)?.programName}
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab selectedRoundId={selectedRoundId} />
|
||||
<OverviewTab selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
{selectedRoundId ? (
|
||||
<AnalyticsTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<AnalyticsTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round from the dropdown above to view analytics
|
||||
Choose a round or edition from the dropdown above to view analytics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -641,15 +694,15 @@ export default function ObserverReportsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
{selectedRoundId ? (
|
||||
<JurorConsistencyTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<JurorConsistencyTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round above to view juror consistency metrics
|
||||
Choose a round or edition above to view juror consistency metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -657,15 +710,15 @@ export default function ObserverReportsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diversity">
|
||||
{selectedRoundId ? (
|
||||
<DiversityTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<DiversityTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Globe className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round above to view diversity metrics
|
||||
Choose a round or edition above to view diversity metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user