feat: revamp admin member detail page, observer dashboard round timeline
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m37s
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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user