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:
@@ -56,6 +56,7 @@ import {
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function ReportsOverview() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
@@ -96,62 +97,91 @@ function ReportsOverview() {
|
||||
<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">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">
|
||||
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
|
||||
</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">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<CheckCircle2 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 programs</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 programs</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">Jury Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{jurorCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Active jurors</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">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{jurorCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active jurors</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">Evaluations</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{submittedEvaluations}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalEvaluations > 0
|
||||
? `${completionRate}% completion rate`
|
||||
: 'No assignments yet'}
|
||||
</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">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{totalEvaluations > 0
|
||||
? `${completionRate}% completion rate`
|
||||
: 'No assignments yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Score Distribution (if any evaluations exist) */}
|
||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
Score Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>Overall score distribution across all evaluations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -162,7 +192,7 @@ function ReportsOverview() {
|
||||
<div key={bucket.label} className="flex items-center gap-3">
|
||||
<span className="w-10 text-sm font-medium text-right">{bucket.label}</span>
|
||||
<div className="flex-1">
|
||||
<Progress value={(bucket.count / maxCount) * 100} className="h-6" />
|
||||
<Progress value={(bucket.count / maxCount) * 100} className="h-6" gradient />
|
||||
</div>
|
||||
<span className="w-8 text-sm text-muted-foreground text-right">{bucket.count}</span>
|
||||
</div>
|
||||
@@ -176,7 +206,12 @@ function ReportsOverview() {
|
||||
{/* Rounds Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Reports</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
Round Reports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
View progress and export data for each round
|
||||
</CardDescription>
|
||||
@@ -263,60 +298,73 @@ function ReportsOverview() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 RoundAnalytics() {
|
||||
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 })
|
||||
|
||||
// Flatten rounds from all programs with program name
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: `${p.year} Edition` }))) || []
|
||||
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 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 }
|
||||
)
|
||||
|
||||
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
|
||||
geoInput,
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
|
||||
)
|
||||
|
||||
if (roundsLoading) {
|
||||
@@ -350,11 +398,16 @@ function RoundAnalytics() {
|
||||
{/* Round Selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="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}
|
||||
@@ -364,7 +417,7 @@ function RoundAnalytics() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRoundId && (
|
||||
{hasSelection && (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -537,22 +590,25 @@ function CrossRoundTab() {
|
||||
}
|
||||
|
||||
function JurorConsistencyTab() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (programsLoading) {
|
||||
@@ -563,11 +619,16 @@ function JurorConsistencyTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="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}
|
||||
@@ -601,22 +662,25 @@ function JurorConsistencyTab() {
|
||||
}
|
||||
|
||||
function DiversityTab() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (programsLoading) {
|
||||
@@ -627,11 +691,16 @@ function DiversityTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="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}
|
||||
|
||||
Reference in New Issue
Block a user