feat: revamp admin member detail page, observer dashboard round timeline
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m37s

- Member detail: tabs layout, impersonate button, icon-pill card headers,
  profile details grid, quick info sidebar, jury groups, mentor assignments
- Observer dashboard: round timeline with special award support,
  round node cards, completion indicators
- Analytics: include specialAwardId/Name in observer round overview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:39:21 +01:00
parent 34fc0b81e0
commit a358e9940d
3 changed files with 620 additions and 446 deletions

View File

@@ -23,6 +23,7 @@ import {
ClipboardList,
Upload,
Users,
Trophy,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -103,6 +104,158 @@ function RoundPanel({ roundType, roundId, programId }: { roundType: string; roun
}
}
type RoundOverviewItem = {
roundId: string
roundName: string
roundType: string
roundStatus: string
totalProjects: number
completionRate: number
specialAwardId?: string | null
specialAwardName?: string | null
}
function RoundNode({
round,
isSelected,
onClick,
}: {
round: RoundOverviewItem
isSelected: boolean
onClick: () => void
}) {
const isActive = round.roundStatus === 'ROUND_ACTIVE'
return (
<button type="button" onClick={onClick} className="text-left focus:outline-none">
<Card className={cn(
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md',
isSelected && 'ring-2 ring-brand-teal shadow-md',
)}>
<CardContent className="p-3 space-y-2">
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
{round.roundName}
</p>
{isActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
)}
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
className="text-[10px] px-1.5 py-0"
>
{round.roundStatus === 'ROUND_ACTIVE'
? 'Active'
: round.roundStatus === 'ROUND_CLOSED'
? 'Closed'
: round.roundStatus === 'ROUND_DRAFT'
? 'Draft'
: round.roundStatus === 'ROUND_ARCHIVED'
? 'Archived'
: round.roundStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
</p>
<div className="space-y-1">
<Progress value={round.completionRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground tabular-nums">
{round.completionRate}% complete
</p>
</div>
</CardContent>
</Card>
</button>
)
}
function PipelineView({
rounds,
selectedRoundId,
onSelectRound,
}: {
rounds: RoundOverviewItem[]
selectedRoundId: string
onSelectRound: (id: string) => void
}) {
// Split main pipeline from award tracks
const mainRounds = rounds.filter((r) => !r.specialAwardId)
const awardGroups = new Map<string, { name: string; rounds: RoundOverviewItem[] }>()
for (const r of rounds) {
if (!r.specialAwardId) continue
if (!awardGroups.has(r.specialAwardId)) {
awardGroups.set(r.specialAwardId, { name: r.specialAwardName ?? 'Special Award', rounds: [] })
}
awardGroups.get(r.specialAwardId)!.rounds.push(r)
}
return (
<div className="space-y-4">
{/* Main Competition Pipeline */}
{mainRounds.length > 0 && (
<div className="flex items-stretch gap-0 overflow-x-auto py-1 -my-1">
{mainRounds.map((round, idx) => (
<div key={round.roundId} className="flex items-center">
<RoundNode
round={round}
isSelected={selectedRoundId === round.roundId}
onClick={() => onSelectRound(round.roundId)}
/>
{idx < mainRounds.length - 1 && (
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
)}
</div>
))}
</div>
)}
{/* Award Tracks */}
{awardGroups.size > 0 && (
<div className="space-y-3 pt-1">
{Array.from(awardGroups.entries()).map(([awardId, group]) => (
<div
key={awardId}
className="rounded-lg border border-amber-200/80 bg-amber-50/30 p-3"
>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-amber-100 shrink-0">
<Trophy className="h-3.5 w-3.5 text-amber-600" />
</div>
<p className="text-xs font-semibold text-amber-800">{group.name}</p>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-300 text-amber-700">
Award Track
</Badge>
</div>
<div className="flex items-stretch gap-0 overflow-x-auto">
{group.rounds.map((round, idx) => (
<div key={round.roundId} className="flex items-center">
<RoundNode
round={round}
isSelected={selectedRoundId === round.roundId}
onClick={() => onSelectRound(round.roundId)}
/>
{idx < group.rounds.length - 1 && (
<div className="h-px w-6 shrink-0 border-t-2 border-amber-400" />
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const {
programs,
@@ -197,71 +350,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
))}
</div>
) : roundOverview && roundOverview.rounds.length > 0 ? (
<div className="flex items-stretch gap-0 overflow-x-auto py-1 -my-1">
{roundOverview.rounds.map((round, idx) => {
const isSelected = selectedRoundId === round.roundId
const isActive = round.roundStatus === 'ROUND_ACTIVE'
return (
<div key={round.roundId ?? round.roundName + idx} className="flex items-center">
<button
type="button"
onClick={() => setSelectedRoundId(round.roundId)}
className="text-left focus:outline-none"
>
<Card className={cn(
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md',
isSelected && 'ring-2 ring-brand-teal shadow-md',
)}>
<CardContent className="p-3 space-y-2">
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
{round.roundName}
</p>
{isActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
)}
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
className="text-[10px] px-1.5 py-0"
>
{round.roundStatus === 'ROUND_ACTIVE'
? 'Active'
: round.roundStatus === 'ROUND_CLOSED'
? 'Closed'
: round.roundStatus === 'ROUND_DRAFT'
? 'Draft'
: round.roundStatus === 'ROUND_ARCHIVED'
? 'Archived'
: round.roundStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
</p>
<div className="space-y-1">
<Progress value={round.completionRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground tabular-nums">
{round.completionRate}% complete
</p>
</div>
</CardContent>
</Card>
</button>
{idx < roundOverview.rounds.length - 1 && (
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
)}
</div>
)
})}
</div>
<PipelineView
rounds={roundOverview.rounds}
selectedRoundId={selectedRoundId}
onSelectRound={setSelectedRoundId}
/>
) : (
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
)}