feat(mentor): mentoring-specific Round Overview card grid (§B)
Renders above Round Details when round.roundType === 'MENTORING': - Top-line counts: requested + assigned (with awaiting badge) - Request window: countdown pill (amber <48h, red <12h) - Mentor pool: size + avg load + 'View all' link to /admin/mentors - Workspace activity: msgs / files / milestones / last activity Round Details panel now shows 'Mentor Pool: N members' (linked) instead of an always-empty 'Jury Group' row on MENTORING rounds. Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
This commit is contained in:
@@ -91,6 +91,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
|||||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
|
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
||||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -582,6 +583,14 @@ export default function RoundDetailPage() {
|
|||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||||
const isMentoring = round?.roundType === 'MENTORING'
|
const isMentoring = round?.roundType === 'MENTORING'
|
||||||
|
|
||||||
|
// Mentor pool size — used by Round Details panel below to replace the
|
||||||
|
// always-empty "Jury Group" row on MENTORING rounds.
|
||||||
|
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
|
||||||
|
{},
|
||||||
|
{ enabled: isMentoring },
|
||||||
|
)
|
||||||
|
const mentorPoolSize = mentorPool?.poolSize ?? 0
|
||||||
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
|
||||||
const hasAwards = roundAwards.length > 0
|
const hasAwards = roundAwards.length > 0
|
||||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||||
@@ -1469,6 +1478,9 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mentoring-specific stats \u2014 only on MENTORING rounds */}
|
||||||
|
{isMentoring && <MentoringRoundOverview roundId={roundId} />}
|
||||||
|
|
||||||
{/* Round Info + Project Breakdown */}
|
{/* Round Info + Project Breakdown */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={2}>
|
||||||
@@ -1482,7 +1494,9 @@ export default function RoundDetailPage() {
|
|||||||
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
||||||
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
||||||
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
||||||
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
isMentoring
|
||||||
|
? { label: 'Mentor Pool', value: <Link href="/admin/mentors" className="font-medium hover:underline">{mentorPoolSize} member{mentorPoolSize === 1 ? '' : 's'}</Link> }
|
||||||
|
: { label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
||||||
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
||||||
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
|
|||||||
217
src/components/admin/round/mentoring-round-overview.tsx
Normal file
217
src/components/admin/round/mentoring-round-overview.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
MessageCircle,
|
||||||
|
Target,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeFuture(date: Date | null): { label: string; tone: 'normal' | 'amber' | 'red' } {
|
||||||
|
if (!date) return { label: '—', tone: 'normal' }
|
||||||
|
const ms = date.getTime() - Date.now()
|
||||||
|
if (ms <= 0) return { label: 'Closed', tone: 'red' }
|
||||||
|
const hours = Math.floor(ms / 3_600_000)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const tone: 'normal' | 'amber' | 'red' =
|
||||||
|
hours <= 12 ? 'red' : hours <= 48 ? 'amber' : 'normal'
|
||||||
|
const label = days > 0 ? `Closes in ${days}d` : `Closes in ${hours}h`
|
||||||
|
return { label, tone }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativePast(date: Date | null): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
const ms = Date.now() - date.getTime()
|
||||||
|
const minutes = Math.floor(ms / 60_000)
|
||||||
|
const hours = Math.floor(ms / 3_600_000)
|
||||||
|
const days = Math.floor(ms / 86_400_000)
|
||||||
|
if (days > 0) return `${days}d ago`
|
||||||
|
if (hours > 0) return `${hours}h ago`
|
||||||
|
return `${Math.max(0, minutes)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentoringRoundOverview({ roundId }: Props) {
|
||||||
|
const { data: stats, isLoading: statsLoading } = trpc.mentor.getRoundStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
|
||||||
|
if (statsLoading || poolLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!stats || !pool) return null
|
||||||
|
|
||||||
|
const requestedPct = stats.totalProjects
|
||||||
|
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||||
|
: 0
|
||||||
|
const assignedPct = stats.totalProjects
|
||||||
|
? Math.round((stats.assignedCount / stats.totalProjects) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const window = formatRelativeFuture(
|
||||||
|
stats.requestWindow.deadline ? new Date(stats.requestWindow.deadline) : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const avgLoad =
|
||||||
|
pool.poolSize > 0 ? (pool.totalCurrentAssignments / pool.poolSize).toFixed(1) : '—'
|
||||||
|
const lastActivity = stats.workspaceActivity.lastActivityAt
|
||||||
|
? new Date(stats.workspaceActivity.lastActivityAt)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Requested mentoring
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold tabular-nums">
|
||||||
|
{stats.requestedCount}
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm font-normal">
|
||||||
|
/ {stats.totalProjects}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">{requestedPct}% of round</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Mentor assigned
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className="text-3xl font-bold tabular-nums">{stats.assignedCount}</div>
|
||||||
|
<UserCheck className="text-muted-foreground h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{assignedPct}% of round{' '}
|
||||||
|
{stats.awaitingAssignment > 0 && (
|
||||||
|
<span className="text-amber-700 dark:text-amber-400">
|
||||||
|
· {stats.awaitingAssignment} awaiting
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Request window
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<Clock className="text-muted-foreground h-5 w-5" />
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
window.tone === 'red'
|
||||||
|
? 'destructive'
|
||||||
|
: window.tone === 'amber'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{window.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
{stats.requestWindow.deadline
|
||||||
|
? `Closes ${new Date(stats.requestWindow.deadline).toLocaleDateString()} · ${stats.requestWindow.deadlineDays}-day window`
|
||||||
|
: 'No window deadline set'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Mentor pool
|
||||||
|
</CardTitle>
|
||||||
|
<Link
|
||||||
|
href="/admin/mentors"
|
||||||
|
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className="text-3xl font-bold tabular-nums">{pool.poolSize}</div>
|
||||||
|
<Users className="text-muted-foreground h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Avg load <span className="text-foreground font-medium">{avgLoad}</span> ·{' '}
|
||||||
|
{pool.totalCurrentAssignments} active
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2 xl:col-span-4">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageCircle className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold tabular-nums">{stats.workspaceActivity.messageCount}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">messages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold tabular-nums">{stats.workspaceActivity.fileCount}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">files</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold tabular-nums">
|
||||||
|
{stats.workspaceActivity.milestoneCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">milestones</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{formatRelativePast(lastActivity)}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">last activity</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user