Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -290,21 +290,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const {
edition,
activeRoundCount,
totalRoundCount,
activeStageCount,
totalStageCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
recentStages,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
draftStages,
unassignedProjects,
} = data
@@ -318,32 +318,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const invitedJurors = totalJurors - activeJurors
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round) => {
const submitted = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
// Compute per-stage eval stats
const stagesWithEvalStats = recentStages.map((stage: typeof recentStages[number]) => {
const submitted = stage.assignments.filter(
(a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED'
).length
const total = round._count.assignments
const total = stage._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
return { ...stage, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from rounds
// Upcoming deadlines from stages
const now = new Date()
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
const deadlines: { label: string; stageName: string; date: Date }[] = []
for (const stage of recentStages) {
if (stage.windowCloseAt && new Date(stage.windowCloseAt) > now) {
deadlines.push({
label: 'Voting closes',
roundName: round.name,
date: new Date(round.votingEndAt),
})
}
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
deadlines.push({
label: 'Submissions close',
roundName: round.name,
date: new Date(round.submissionEndDate),
label: 'Window closes',
stageName: stage.name,
date: new Date(stage.windowCloseAt),
})
}
}
@@ -388,10 +381,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<p className="text-sm font-medium text-muted-foreground">Stages</p>
<p className="text-2xl font-bold mt-1">{totalStageCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
{activeStageCount} active stage{activeStageCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -474,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Quick Actions */}
<div className="grid gap-3 sm:grid-cols-3">
<Link href="/admin/rounds/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<Link href="/admin/rounds/pipelines" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium">New Round</p>
<p className="text-xs text-muted-foreground">Create a voting round</p>
<p className="text-sm font-medium">Pipelines</p>
<p className="text-xs text-muted-foreground">Manage stages & pipelines</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
@@ -507,7 +500,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */}
{/* Stages Card (enhanced) */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
@@ -517,14 +510,14 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="rounded-lg bg-blue-500/10 p-1.5">
<CircleDot className="h-4 w-4 text-blue-500" />
</div>
Rounds
Stages
</CardTitle>
<CardDescription>
Voting rounds in {edition.name}
Pipeline stages in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds"
href="/admin/rounds/pipelines"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
@@ -532,52 +525,51 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
</CardHeader>
<CardContent>
{roundsWithEvalStats.length === 0 ? (
{stagesWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet
No stages created yet
</p>
<Link
href="/admin/rounds/new"
href="/admin/rounds/pipelines"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Create your first round
Set up your pipeline
</Link>
</div>
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}`}
{stagesWithEvalStats.map((stage) => (
<div
key={stage.id}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<StatusBadge status={round.status} />
<p className="font-medium">{stage.name}</p>
<StatusBadge status={stage.status} />
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
{stage._count.projectStageStates} projects &middot; {stage._count.assignments} assignments
{stage.totalEvals > 0 && (
<> &middot; {stage.evalPercent}% evaluated</>
)}
</p>
{round.votingStartAt && round.votingEndAt && (
{stage.windowOpenAt && stage.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Voting: {formatDateOnly(round.votingStartAt)} &ndash; {formatDateOnly(round.votingEndAt)}
Window: {formatDateOnly(stage.windowOpenAt)} &ndash; {formatDateOnly(stage.windowCloseAt)}
</p>
)}
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
{stage.totalEvals > 0 && (
<Progress value={stage.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</Link>
</div>
))}
</div>
)}
@@ -690,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
@@ -707,16 +699,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftRounds > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
{draftStages > 0 && (
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft rounds to activate</span>
<span className="text-sm">Draft stages to activate</span>
</div>
<Badge variant="secondary">{draftRounds}</Badge>
<Badge variant="secondary">{draftStages}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
{pendingCOIs === 0 && unassignedProjects === 0 && draftStages === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
@@ -739,7 +731,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</CardTitle>
</CardHeader>
<CardContent>
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
{stagesWithEvalStats.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
@@ -748,19 +740,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
) : (
<div className="space-y-5">
{roundsWithEvalStats
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
.map((round) => (
<div key={round.id} className="space-y-2">
{stagesWithEvalStats
.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0)
.map((stage: typeof stagesWithEvalStats[number]) => (
<div key={stage.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{round.name}</p>
<p className="text-sm font-medium truncate">{stage.name}</p>
<span className="text-sm font-semibold tabular-nums">
{round.evalPercent}%
{stage.evalPercent}%
</span>
</div>
<Progress value={round.evalPercent} className="h-2" gradient />
<Progress value={stage.evalPercent} className="h-2" gradient />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
{stage.submittedEvals} of {stage.totalEvals} evaluations submitted
</p>
</div>
))}
@@ -913,7 +905,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.roundName}
{deadline.label} &mdash; {deadline.stageName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}

View File

@@ -76,7 +76,7 @@ type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERV
interface Assignment {
projectId: string
roundId: string
stageId: string
}
interface MemberRow {
@@ -269,7 +269,7 @@ export default function MemberInvitePage() {
} | null>(null)
// Pre-assignment state
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [selectedStageId, setSelectedStageId] = useState<string>('')
const utils = trpc.useUtils()
@@ -284,30 +284,27 @@ export default function MemberInvitePage() {
},
})
// Fetch programs with rounds for pre-assignment
// Fetch programs with stages for pre-assignment
const { data: programsData } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
includeStages: true,
})
// Flatten all rounds from all programs
const rounds = useMemo(() => {
// Flatten all stages from all programs
const stages = useMemo(() => {
if (!programsData) return []
type ProgramWithRounds = typeof programsData[number] & {
rounds?: Array<{ id: string; name: string }>
}
return (programsData as ProgramWithRounds[]).flatMap((program) =>
(program.rounds || []).map((round) => ({
id: round.id,
name: round.name,
return programsData.flatMap((program) =>
((program.stages ?? []) as Array<{ id: string; name: string }>).map((stage: { id: string; name: string }) => ({
id: stage.id,
name: stage.name,
programName: `${program.name} ${program.year}`,
}))
)
}, [programsData])
// Fetch projects for selected round
// Fetch projects for selected stage
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
{ roundId: selectedRoundId, perPage: 200 },
{ enabled: !!selectedRoundId }
{ stageId: selectedStageId, perPage: 200 },
{ enabled: !!selectedStageId }
)
const projects = projectsData?.projects || []
@@ -355,7 +352,7 @@ export default function MemberInvitePage() {
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return
if (!selectedStageId) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== rowId) return r
@@ -363,7 +360,7 @@ export default function MemberInvitePage() {
if (existing) {
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
} else {
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
return { ...r, assignments: [...r.assignments, { projectId, stageId: selectedStageId }] }
}
})
)
@@ -587,21 +584,21 @@ export default function MemberInvitePage() {
<div className="flex-1 min-w-0">
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
<p className="text-xs text-muted-foreground">
Select a round to assign projects to jury members before they onboard
Select a stage to assign projects to jury members before they onboard
</p>
</div>
<Select
value={selectedRoundId || 'none'}
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
value={selectedStageId || 'none'}
onValueChange={(v) => setSelectedStageId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select round" />
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No pre-assignment</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -683,7 +680,7 @@ export default function MemberInvitePage() {
/>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && (
{row.role === 'JURY_MEMBER' && selectedStageId && (
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button

View File

@@ -64,12 +64,12 @@ import {
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
type RecipientType = 'ALL' | 'ROLE' | 'STAGE_JURY' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'STAGE_JURY', label: 'Stage Jury' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [roundId, setRoundId] = useState('')
const [stageId, setStageId] = useState('')
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
@@ -93,8 +93,11 @@ export default function MessagesPage() {
const utils = trpc.useUtils()
// Fetch supporting data
const { data: rounds } = trpc.round.listAll.useQuery()
const { data: programs } = trpc.program.list.useQuery()
// Get programs with stages
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
) || []
const { data: templates } = trpc.message.listTemplates.useQuery()
const { data: users } = trpc.user.list.useQuery(
{ page: 1, perPage: 100 },
@@ -121,7 +124,7 @@ export default function MessagesPage() {
setBody('')
setSelectedTemplateId('')
setSelectedRole('')
setRoundId('')
setStageId('')
setSelectedProgramId('')
setSelectedUserId('')
setIsScheduled(false)
@@ -170,14 +173,14 @@ export default function MessagesPage() {
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'ROUND_JURY': {
if (!roundId) return 'Round Jury (none selected)'
const round = (rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.find(
(r) => r.id === roundId
case 'STAGE_JURY': {
if (!stageId) return 'Stage Jury (none selected)'
const stage = rounds?.find(
(r) => r.id === stageId
)
return round
? `Jury of ${round.program ? `${round.program.name} - ` : ''}${round.name}`
: 'Round Jury'
return stage
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
@@ -214,8 +217,8 @@ export default function MessagesPage() {
toast.error('Please select a role')
return
}
if (recipientType === 'ROUND_JURY' && !roundId) {
toast.error('Please select a round')
if (recipientType === 'STAGE_JURY' && !stageId) {
toast.error('Please select a stage')
return
}
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
@@ -234,7 +237,7 @@ export default function MessagesPage() {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilter(),
roundId: roundId || undefined,
stageId: stageId || undefined,
subject: subject.trim(),
body: body.trim(),
deliveryChannels,
@@ -292,7 +295,7 @@ export default function MessagesPage() {
onValueChange={(v) => {
setRecipientType(v as RecipientType)
setSelectedRole('')
setRoundId('')
setStageId('')
setSelectedProgramId('')
setSelectedUserId('')
}}
@@ -329,15 +332,15 @@ export default function MessagesPage() {
</div>
)}
{recipientType === 'ROUND_JURY' && (
{recipientType === 'STAGE_JURY' && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
<Label>Select Stage</Label>
<Select value={stageId} onValueChange={setStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>
<SelectContent>
{(rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.map((round) => (
{rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program ? `${round.program.name} - ${round.name}` : round.name}
</SelectItem>

View File

@@ -86,53 +86,48 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardTitle>Stages</CardTitle>
<CardDescription>
Voting rounds for this program
Pipeline stages for this program
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?programId=${id}`}>
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
<Plus className="mr-2 h-4 w-4" />
New Round
Manage Pipeline
</Link>
</Button>
</CardHeader>
<CardContent>
{program.rounds.length === 0 ? (
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No rounds created yet. Create a round to start accepting projects.
No stages created yet. Set up a pipeline to get started.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Status</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<TableRow key={round.id}>
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number } }>).map((stage) => (
<TableRow key={stage.id}>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
<span className="font-medium">
{stage.name}
</span>
</TableCell>
<TableCell>
<Badge variant={statusColors[round.status] || 'secondary'}>
{round.status}
<Badge variant={statusColors[stage.status] || 'secondary'}>
{stage.status}
</Badge>
</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
<TableCell>{stage._count.projects}</TableCell>
<TableCell>{stage._count.assignments}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -42,20 +42,31 @@ async function ProgramsContent() {
const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
include: {
_count: {
select: {
rounds: true,
pipelines: {
include: {
tracks: {
include: {
stages: {
select: { id: true, status: true },
},
},
},
},
},
rounds: {
where: { status: 'ACTIVE' },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
})
if (programs.length === 0) {
// Flatten stages per program for convenience
const programsWithStageCounts = programs.map((p) => {
const allStages = p.pipelines.flatMap((pl) =>
pl.tracks.flatMap((t) => t.stages)
)
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
})
if (programsWithStageCounts.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
@@ -91,14 +102,14 @@ async function ProgramsContent() {
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Year</TableHead>
<TableHead>Rounds</TableHead>
<TableHead>Stages</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programs.map((program) => (
{programsWithStageCounts.map((program) => (
<TableRow key={program.id}>
<TableCell>
<div>
@@ -113,10 +124,10 @@ async function ProgramsContent() {
<TableCell>{program.year}</TableCell>
<TableCell>
<div>
<p>{program._count.rounds} total</p>
{program.rounds.length > 0 && (
<p>{program.stageCount} total</p>
{program.activeStageCount > 0 && (
<p className="text-sm text-muted-foreground">
{program.rounds.length} active
{program.activeStageCount} active
</p>
)}
</div>
@@ -165,7 +176,7 @@ async function ProgramsContent() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{programs.map((program) => (
{programsWithStageCounts.map((program) => (
<Card key={program.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
@@ -180,9 +191,9 @@ async function ProgramsContent() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Rounds</span>
<span className="text-muted-foreground">Stages</span>
<span>
{program._count.rounds} ({program.rounds.length} active)
{program.stageCount} ({program.activeStageCount} active)
</span>
</div>
<div className="flex items-center justify-between text-sm">

View File

@@ -120,9 +120,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
})
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundId ?? undefined,
})
const { data: existingTags } = trpc.project.getTags.useQuery({})
// Mutations
const utils = trpc.useUtils()
@@ -137,7 +135,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
utils.project.list.invalidate()
utils.round.get.invalidate()
utils.program.get.invalidate()
router.push('/admin/projects')
},
})
@@ -202,7 +200,6 @@ function EditProjectContent({ projectId }: { projectId: string }) {
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
roundId: project?.roundId ?? undefined,
tags: data.tags,
})
}

View File

@@ -86,17 +86,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch grouped files by round (if project has a roundId)
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
{ projectId, roundId: project?.roundId || '' },
{ enabled: !!project?.roundId }
)
// Fetch available rounds for upload selector (if project has a programId)
const { data: rounds } = trpc.round.listByProgram.useQuery(
{ programId: project?.programId || '' },
// Fetch available stages for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
{ enabled: !!project?.programId }
)
const availableStages = (programData?.stages as Array<{ id: string; name: string }>) || []
const utils = trpc.useUtils()
@@ -148,15 +143,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{project.roundId ? (
{project.programId ? (
<Link
href={`/admin/rounds/${project.roundId}`}
href={`/admin/programs/${project.programId}`}
className="hover:underline"
>
{project.round?.name ?? 'Round'}
{programData?.name ?? 'Program'}
</Link>
) : (
<span>No round</span>
<span>No program</span>
)}
</div>
<div className="flex items-center gap-3">
@@ -526,9 +521,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{groupedFiles && groupedFiles.length > 0 ? (
<FileViewer groupedFiles={groupedFiles} />
) : files && files.length > 0 ? (
{files && files.length > 0 ? (
<FileViewer
projectId={projectId}
files={files.map((f) => ({
@@ -551,13 +544,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
roundId={project.roundId || undefined}
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
if (project.roundId) {
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
}
}}
/>
</div>
@@ -585,7 +574,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
<Link href={`/admin/members`}>
Manage
</Link>
</Button>
@@ -688,10 +677,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
{/* AI Evaluation Summary */}
{project.roundId && stats && stats.totalEvaluations > 0 && (
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
roundId={project.roundId}
stageId={assignments[0].stageId}
/>
)}
</div>

View File

@@ -30,26 +30,26 @@ function ImportPageContent() {
const router = useRouter()
const utils = trpc.useUtils()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const stageIdParam = searchParams.get('stage')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
// Fetch active programs with rounds
// Fetch active programs with stages
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
includeStages: true,
})
// Get all rounds from programs
const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({
...r,
// Get all stages from programs
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
if (loadingPrograms) {
return <ImportPageSkeleton />
@@ -70,44 +70,44 @@ function ImportPageContent() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
<p className="text-muted-foreground">
Import projects from a CSV file into a round
Import projects from a CSV file into a stage
</p>
</div>
{/* Round selection */}
{!selectedRoundId && (
{/* Stage selection */}
{!selectedStageId && (
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardTitle>Select Stage</CardTitle>
<CardDescription>
Choose the round you want to import projects into
Choose the stage you want to import projects into
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rounds.length === 0 ? (
{stages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="mt-2 font-medium">No Active Stages</p>
<p className="text-sm text-muted-foreground">
Create a round first before importing projects
Create a stage first before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new">Create Round</Link>
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
</Button>
</div>
) : (
<>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
<div className="flex flex-col">
<span>{round.name}</span>
<span>{stage.name}</span>
<span className="text-xs text-muted-foreground">
{round.programName}
{stage.programName}
</span>
</div>
</SelectItem>
@@ -117,11 +117,11 @@ function ImportPageContent() {
<Button
onClick={() => {
if (selectedRoundId) {
router.push(`/admin/projects/import?round=${selectedRoundId}`)
if (selectedStageId) {
router.push(`/admin/projects/import?stage=${selectedStageId}`)
}
}}
disabled={!selectedRoundId}
disabled={!selectedStageId}
>
Continue
</Button>
@@ -132,14 +132,14 @@ function ImportPageContent() {
)}
{/* Import form */}
{selectedRoundId && selectedRound && (
{selectedStageId && selectedStage && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedRound.name}</p>
<p className="font-medium">Importing into: {selectedStage.name}</p>
<p className="text-sm text-muted-foreground">
{selectedRound.programName}
{selectedStage.programName}
</p>
</div>
<Button
@@ -147,11 +147,11 @@ function ImportPageContent() {
size="sm"
className="ml-auto"
onClick={() => {
setSelectedRoundId('')
setSelectedStageId('')
router.push('/admin/projects/import')
}}
>
Change Round
Change Stage
</Button>
</div>
@@ -172,32 +172,31 @@ function ImportPageContent() {
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedRound.programId}
roundId={selectedRoundId}
roundName={selectedRound.name}
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.round.get.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.round.get.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.round.get.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>

View File

@@ -85,11 +85,11 @@ const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const stageIdParam = searchParams.get('stage')
const programIdParam = searchParams.get('program')
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
// Form state
const [title, setTitle] = useState('')
@@ -113,7 +113,7 @@ function NewProjectPageContent() {
// Fetch programs
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
includeStages: true,
})
// Fetch wizard config for selected program (dropdown options)
@@ -128,7 +128,7 @@ function NewProjectPageContent() {
onSuccess: () => {
toast.success('Project created successfully')
utils.project.list.invalidate()
utils.round.get.invalidate()
utils.program.get.invalidate()
router.push('/admin/projects')
},
onError: (error) => {
@@ -136,9 +136,9 @@ function NewProjectPageContent() {
},
})
// Get rounds for selected program
// Get stages for selected program
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
const rounds = selectedProgram?.rounds || []
const stages = (selectedProgram?.stages || []) as Array<{ id: string; name: string }>
// Get dropdown options from wizard config
const categoryOptions = wizardConfig?.competitionCategories || []
@@ -216,7 +216,6 @@ function NewProjectPageContent() {
createProject.mutate({
programId: selectedProgramId,
roundId: selectedRoundId || undefined,
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
@@ -264,12 +263,12 @@ function NewProjectPageContent() {
</div>
</div>
{/* Program & Round selection */}
{/* Program & Stage selection */}
<Card>
<CardHeader>
<CardTitle>Program & Round</CardTitle>
<CardTitle>Program & Stage</CardTitle>
<CardDescription>
Select the program for this project. Round assignment is optional.
Select the program for this project. Stage assignment is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -287,7 +286,7 @@ function NewProjectPageContent() {
<Label>Program *</Label>
<Select value={selectedProgramId} onValueChange={(v) => {
setSelectedProgramId(v)
setSelectedRoundId('') // Reset round on program change
setSelectedStageId('') // Reset stage on program change
}}>
<SelectTrigger>
<SelectValue placeholder="Select a program" />
@@ -303,16 +302,16 @@ function NewProjectPageContent() {
</div>
<div className="space-y-2">
<Label>Round (optional)</Label>
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<Label>Stage (optional)</Label>
<Select value={selectedStageId || '__none__'} onValueChange={(v) => setSelectedStageId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<SelectTrigger>
<SelectValue placeholder="No round assigned" />
<SelectValue placeholder="No stage assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No round assigned</SelectItem>
{rounds.map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
<SelectItem value="__none__">No stage assigned</SelectItem>
{stages.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -96,6 +96,7 @@ import { CountryFlagImg } from '@/components/ui/country-select'
import {
ProjectFiltersBar,
type ProjectFilters,
type FilterOptions,
} from './project-filters'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -121,7 +122,7 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundId: searchParams.get('round') || '',
stageId: searchParams.get('stage') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
@@ -155,7 +156,7 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.roundId) params.set('round', filters.roundId)
if (filters.stageId) params.set('stage', filters.stageId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
@@ -180,7 +181,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
roundId: parsed.roundId,
stageId: parsed.stageId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
@@ -251,7 +252,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
stageId: filters.stageId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -283,14 +284,14 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
// Assign to round dialog state
// Assign to stage dialog state
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignRoundId, setAssignRoundId] = useState('')
const [assignStageId, setAssignStageId] = useState('')
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
@@ -350,8 +351,14 @@ export default function ProjectsPage() {
: null
const handleStartTagging = () => {
if (taggingScope === 'round' && selectedRoundForTagging) {
startTaggingJob.mutate({ roundId: selectedRoundForTagging })
if (taggingScope === 'stage' && selectedStageForTagging) {
// Router only accepts programId; resolve from the selected stage's parent program
const parentProgram = programs?.find((p) =>
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
)
if (parentProgram) {
startTaggingJob.mutate({ programId: parentProgram.id })
}
} else if (taggingScope === 'program' && selectedProgramForTagging) {
startTaggingJob.mutate({ programId: selectedProgramForTagging })
}
@@ -361,20 +368,19 @@ export default function ProjectsPage() {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null)
setSelectedRoundForTagging('')
setSelectedStageForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's rounds
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? []
const programStages = selectedProgram?.stages ?? []
// Calculate stats for display
const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging)
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
@@ -387,7 +393,7 @@ export default function ProjectsPage() {
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
@@ -406,7 +412,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
stageId: filters.stageId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -446,12 +452,12 @@ export default function ProjectsPage() {
},
})
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignRoundId('')
setBulkAssignStageId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
@@ -526,10 +532,9 @@ export default function ProjectsPage() {
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
if (!bulkStatus || selectedIds.size === 0) return
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
roundId: filters.roundId,
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
})
}
@@ -542,13 +547,13 @@ export default function ProjectsPage() {
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const assignToRound = trpc.projectPool.assignToRound.useMutation({
const assignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: () => {
toast.success('Project assigned to round')
toast.success('Project assigned to stage')
utils.project.list.invalidate()
setAssignDialogOpen(false)
setProjectToAssign(null)
setAssignRoundId('')
setAssignStageId('')
},
onError: (error) => {
toast.error(error.message || 'Failed to assign project')
@@ -579,7 +584,7 @@ export default function ProjectsPage() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
{data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}
{data ? `${data.total} projects across all stages` : 'Manage submitted projects across all stages'}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -632,7 +637,17 @@ export default function ProjectsPage() {
{/* Filters */}
<ProjectFiltersBar
filters={filters}
filterOptions={filterOptions}
filterOptions={filterOptions ? {
...filterOptions,
stages: programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
id: s.id,
name: s.name,
programName: p.name,
programYear: p.year,
})) ?? []
) ?? [],
} satisfies FilterOptions : undefined}
onChange={handleFiltersChange}
/>
@@ -755,7 +770,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.roundId ||
filters.stageId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
@@ -799,7 +814,7 @@ export default function ProjectsPage() {
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Round</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
@@ -871,8 +886,8 @@ export default function ProjectsPage() {
<TableCell>
<div>
<div className="flex items-center gap-2">
{project.round ? (
<p>{project.round.name}</p>
{project.program ? (
<p>{project.program.name}</p>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
@@ -880,7 +895,7 @@ export default function ProjectsPage() {
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
{project.program?.year}
</p>
</div>
</TableCell>
@@ -939,7 +954,7 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
{project._count.assignments === 0 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
@@ -948,7 +963,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
Assign to Stage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1000,8 +1015,8 @@ export default function ProjectsPage() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round?.name ?? 'Unassigned'}</span>
<span className="text-muted-foreground">Stage</span>
<span>{project.program?.name ?? 'Unassigned'}</span>
</div>
{project.competitionCategory && (
<div className="flex items-center justify-between text-sm">
@@ -1079,7 +1094,7 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
{project._count.assignments === 0 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
@@ -1088,7 +1103,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
Assign to Stage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1138,10 +1153,10 @@ export default function ProjectsPage() {
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span className="text-muted-foreground">Program</span>
<span className="text-right">
{project.round ? (
<>{project.round.name}</>
{project.program ? (
<>{project.program.name}</>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
@@ -1207,17 +1222,17 @@ export default function ProjectsPage() {
{selectedIds.size} selected
</Badge>
<div className="flex flex-wrap gap-2 flex-1">
{/* Assign to Round */}
{/* Assign to Stage */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkAssignDialogOpen(true)}
>
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Round
Assign to Stage
</Button>
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
{/* Change Status (only when filtered by stage) */}
{filters.stageId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
@@ -1332,30 +1347,30 @@ export default function ProjectsPage() {
</AlertDialogContent>
</AlertDialog>
{/* Assign to Round Dialog */}
{/* Assign to Stage Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
if (!open) { setProjectToAssign(null); setAssignStageId('') }
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogDescription>
Assign &quot;{projectToAssign?.title}&quot; to a round.
Assign &quot;{projectToAssign?.title}&quot; to a stage.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
<Label>Select Stage</Label>
<Select value={assignStageId} onValueChange={setAssignStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
(p.rounds || []).map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{p.name} {p.year} - {r.name}
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
<SelectItem key={s.id} value={s.id}>
{p.name} {p.year} - {s.name}
</SelectItem>
))
)}
@@ -1369,46 +1384,46 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (projectToAssign && assignRoundId) {
assignToRound.mutate({
if (projectToAssign && assignStageId) {
assignToStage.mutate({
projectIds: [projectToAssign.id],
roundId: assignRoundId,
stageId: assignStageId,
})
}
}}
disabled={!assignRoundId || assignToRound.isPending}
disabled={!assignStageId || assignToStage.isPending}
>
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Assign to Round Dialog */}
{/* Bulk Assign to Stage Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignRoundId('')
if (!open) setBulkAssignStageId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<Label>Select Stage</Label>
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
(p.rounds || []).map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{p.name} {p.year} - {r.name}
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
<SelectItem key={s.id} value={s.id}>
{p.name} {p.year} - {s.name}
</SelectItem>
))
)}
@@ -1422,16 +1437,16 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
if (bulkAssignStageId && selectedIds.size > 0) {
bulkAssignToStage.mutate({
projectIds: Array.from(selectedIds),
roundId: bulkAssignRoundId,
stageId: bulkAssignStageId,
})
}
}}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
>
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
@@ -1608,19 +1623,19 @@ export default function ProjectsPage() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('round')}
onClick={() => setTaggingScope('stage')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'round'
taggingScope === 'stage'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
Single Stage
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific round
Tag projects in one specific stage
</span>
</button>
<button
@@ -1637,7 +1652,7 @@ export default function ProjectsPage() {
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all rounds
Tag all projects across all stages
</span>
</button>
</div>
@@ -1645,22 +1660,24 @@ export default function ProjectsPage() {
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'round' ? (
{taggingScope === 'stage' ? (
<>
<Label htmlFor="round-select">Select Round</Label>
<Label htmlFor="stage-select">Select Stage</Label>
<Select
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
value={selectedStageForTagging}
onValueChange={setSelectedStageForTagging}
>
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
<SelectTrigger id="stage-select">
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>
<SelectContent>
{filterOptions?.rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.program?.name})
</SelectItem>
))}
{programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({p.name})
</SelectItem>
)) ?? []
)}
</SelectContent>
</Select>
</>
@@ -1716,7 +1733,7 @@ export default function ProjectsPage() {
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'stage' && !selectedStageForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>

View File

@@ -31,7 +31,7 @@ export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [targetStageId, setTargetStageId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
@@ -50,20 +50,22 @@ export default function ProjectPoolPage() {
{ enabled: !!selectedProgramId }
)
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
{ programId: selectedProgramId },
// Get stages from the selected program (program.list includes rounds/stages)
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToRound.useMutation({
const assignMutation = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.round.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
utils.program.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
setTargetStageId('')
refetch()
},
onError: (error) => {
@@ -72,17 +74,17 @@ export default function ProjectPoolPage() {
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetRoundId) return
if (selectedProjects.length === 0 || !targetStageId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetRoundId,
stageId: targetStageId,
})
}
const handleQuickAssign = (projectId: string, roundId: string) => {
const handleQuickAssign = (projectId: string, stageId: string) => {
assignMutation.mutate({
projectIds: [projectId],
roundId,
stageId,
})
}
@@ -109,7 +111,7 @@ export default function ProjectPoolPage() {
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation rounds
Assign unassigned projects to evaluation stages
</p>
</div>
@@ -236,20 +238,20 @@ export default function ProjectPoolPage() {
: '-'}
</td>
<td className="p-3">
{isLoadingRounds ? (
{isLoadingStages ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to round..." />
<SelectValue placeholder="Assign to stage..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -310,20 +312,20 @@ export default function ProjectPoolPage() {
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Round</DialogTitle>
<DialogTitle>Assign Projects to Stage</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<Select value={targetStageId} onValueChange={setTargetStageId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
<SelectValue placeholder="Select stage..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -335,7 +337,7 @@ export default function ProjectPoolPage() {
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetRoundId || assignMutation.isPending}
disabled={!targetStageId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign

View File

@@ -63,7 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
export interface ProjectFilters {
search: string
statuses: string[]
roundId: string
stageId: string
competitionCategory: string
oceanIssue: string
country: string
@@ -72,11 +72,11 @@ export interface ProjectFilters {
hasAssignments: boolean | undefined
}
interface FilterOptions {
rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
export interface FilterOptions {
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
}
interface ProjectFiltersBarProps {
@@ -94,7 +94,7 @@ export function ProjectFiltersBar({
const activeFilterCount = [
filters.statuses.length > 0,
filters.roundId !== '',
filters.stageId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
@@ -114,7 +114,7 @@ export function ProjectFiltersBar({
onChange({
search: filters.search,
statuses: [],
roundId: '',
stageId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
@@ -175,21 +175,21 @@ export function ProjectFiltersBar({
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Round / Edition</Label>
<Label className="text-sm">Stage / Edition</Label>
<Select
value={filters.roundId || '_all'}
value={filters.stageId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, roundId: v === '_all' ? '' : v })
onChange({ ...filters, stageId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All rounds" />
<SelectValue placeholder="All stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All rounds</SelectItem>
{filterOptions?.rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.program.year} Edition)
<SelectItem value="_all">All stages</SelectItem>
{filterOptions?.stages?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.programYear} {s.programName})
</SelectItem>
))}
</SelectContent>

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -41,6 +42,7 @@ import {
GitCompare,
UserCheck,
Globe,
Layers,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -51,7 +53,7 @@ import {
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
CrossRoundComparisonChart,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
@@ -59,11 +61,13 @@ 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 })
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
// Flatten rounds from all programs
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
// Flatten stages from all programs
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Project reporting scope (default: latest program, all rounds)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
@@ -73,7 +77,7 @@ function ReportsOverview() {
}
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery(
@@ -103,7 +107,7 @@ function ReportsOverview() {
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
const totalProjects = dashStats?.projectCount ?? 0
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r) => r.status === 'ACTIVE').length
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0
@@ -365,7 +369,7 @@ function ReportsOverview() {
<div className="flex justify-end gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?roundId=${round.id}`}
href={`/api/export/evaluations?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
@@ -375,7 +379,7 @@ function ReportsOverview() {
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/results?roundId=${round.id}`}
href={`/api/export/results?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
@@ -396,28 +400,30 @@ function ReportsOverview() {
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
return { stageId: value }
}
function RoundAnalytics() {
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
// Flatten stages from all programs with program name
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Set default selected round
// Set default selected stage
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -458,11 +464,11 @@ function RoundAnalytics() {
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
)
if (roundsLoading) {
@@ -600,26 +606,26 @@ function RoundAnalytics() {
)
}
function CrossRoundTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
)
}
@@ -629,40 +635,40 @@ function CrossRoundTab() {
return (
<div className="space-y-6">
{/* Round selector */}
{/* Stage selector */}
<Card>
<CardHeader>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>
Choose at least 2 rounds to compare metrics side by side
Choose at least 2 stages to compare metrics side by side
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{rounds.map((round) => {
const isSelected = selectedRoundIds.includes(round.id)
{stages.map((stage) => {
const isSelected = selectedStageIds.includes(stage.id)
return (
<Badge
key={round.id}
key={stage.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(round.id)}
onClick={() => toggleStage(stage.id)}
>
{round.programName} - {round.name}
{stage.programName} - {stage.name}
</Badge>
)
})}
</div>
{selectedRoundIds.length < 2 && (
{selectedStageIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 rounds to enable comparison
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedRoundIds.length >= 2 && (
{comparisonLoading && selectedStageIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
@@ -673,9 +679,9 @@ function CrossRoundTab() {
)}
{comparison && (
<CrossRoundComparisonChart data={comparison as Array<{
roundId: string
roundName: string
<CrossStageComparisonChart data={comparison as Array<{
stageId: string
stageName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -690,18 +696,18 @@ function CrossRoundTab() {
function JurorConsistencyTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -716,20 +722,20 @@ function JurorConsistencyTab() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
{p.year} Edition All Stages
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -762,18 +768,18 @@ function JurorConsistencyTab() {
function DiversityTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -788,20 +794,20 @@ function DiversityTab() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
{p.year} Edition All Stages
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -826,18 +832,18 @@ function DiversityTab() {
}
export default function ReportsPage() {
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
const pdfRounds = pdfPrograms?.flatMap((p) =>
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
const pdfStages = pdfPrograms?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
if (pdfRounds.length && !pdfRoundId) {
setPdfRoundId(pdfRounds[0].id)
if (pdfStages.length && !pdfStageId) {
setPdfStageId(pdfStages[0].id)
}
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
return (
<div className="space-y-6">
@@ -861,7 +867,7 @@ export default function ReportsPage() {
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-round" className="gap-2">
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
@@ -873,25 +879,31 @@ export default function ReportsPage() {
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<Layers className="h-4 w-4" />
By Pipeline
</Link>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
<Select value={pdfStageId || ''} onValueChange={setPdfStageId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select round for PDF" />
<SelectValue placeholder="Select stage for PDF" />
</SelectTrigger>
<SelectContent>
{pdfRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
{pdfRoundId && (
{pdfStageId && (
<ExportPdfButton
roundId={pdfRoundId}
roundName={selectedPdfRound?.name}
programName={selectedPdfRound?.programName}
stageId={pdfStageId}
roundName={selectedPdfStage?.name}
programName={selectedPdfStage?.programName}
/>
)}
</div>
@@ -902,11 +914,11 @@ export default function ReportsPage() {
</TabsContent>
<TabsContent value="analytics">
<RoundAnalytics />
<StageAnalytics />
</TabsContent>
<TabsContent value="cross-round">
<CrossRoundTab />
<TabsContent value="cross-stage">
<CrossStageTab />
</TabsContent>
<TabsContent value="consistency">

View File

@@ -0,0 +1,671 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
BarChart3,
Layers,
Users,
CheckCircle2,
Target,
Trophy,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const stateColors: Record<string, string> = {
PENDING: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
PASSED: 'bg-emerald-100 text-emerald-700',
REJECTED: 'bg-red-100 text-red-700',
COMPLETED: 'bg-emerald-100 text-emerald-700',
ELIMINATED: 'bg-red-100 text-red-700',
WAITING: 'bg-amber-100 text-amber-700',
}
const awardStatusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
NOMINATIONS_OPEN: 'bg-blue-100 text-blue-700',
VOTING_OPEN: 'bg-amber-100 text-amber-700',
CLOSED: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-gray-100 text-gray-500',
}
// ─── Stages Tab Content ────────────────────────────────────────────────────
function StagesTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedStageId, setSelectedStageId] = useState<string>('')
const { data: overview, isLoading: overviewLoading } =
trpc.analytics.getStageCompletionOverview.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: scoreDistribution, isLoading: scoresLoading } =
trpc.analytics.getStageScoreDistribution.useQuery(
{ stageId: selectedStageId },
{ enabled: !!selectedStageId }
)
if (overviewLoading) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!overview) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="py-5 text-center">
<Layers className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalStages}</p>
<p className="text-xs text-muted-foreground">Total Stages</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Target className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalProjects}</p>
<p className="text-xs text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Users className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalAssignments}</p>
<p className="text-xs text-muted-foreground">Total Assignments</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-600 mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalCompleted}</p>
<p className="text-xs text-muted-foreground">Evaluations Completed</p>
</CardContent>
</Card>
</div>
{/* Stage overview table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage Completion Overview</CardTitle>
<CardDescription>
Click a stage to see detailed score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Track</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Assignments</TableHead>
<TableHead className="text-center">Completed</TableHead>
<TableHead className="text-center">Jurors</TableHead>
<TableHead>Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.stages.map((stage) => (
<TableRow
key={stage.stageId}
className={cn(
'cursor-pointer transition-colors',
selectedStageId === stage.stageId && 'bg-brand-blue/5 dark:bg-brand-teal/5'
)}
onClick={() => setSelectedStageId(stage.stageId)}
>
<TableCell className="font-medium">{stage.stageName}</TableCell>
<TableCell className="text-muted-foreground text-sm">{stage.trackName}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalProjects}</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalAssignments}</TableCell>
<TableCell className="text-center tabular-nums">{stage.completedEvaluations}</TableCell>
<TableCell className="text-center tabular-nums">{stage.jurorCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[120px]">
<Progress value={stage.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{stage.completionRate}%
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* State breakdown per stage */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project State Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{overview.stages.map((stage) => (
<div key={stage.stageId} className="space-y-2">
<p className="text-sm font-medium">{stage.stageName}</p>
<div className="flex flex-wrap gap-2">
{stage.stateBreakdown.map((sb) => (
<Badge
key={sb.state}
variant="outline"
className={cn('text-xs', stateColors[sb.state])}
>
{sb.state}: {sb.count}
</Badge>
))}
{stage.stateBreakdown.length === 0 && (
<span className="text-xs text-muted-foreground">No projects</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Score distribution for selected stage */}
{selectedStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Score Distribution
{overview.stages.find((s) => s.stageId === selectedStageId) && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{overview.stages.find((s) => s.stageId === selectedStageId)?.stageName}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{scoresLoading ? (
<Skeleton className="h-48 w-full" />
) : scoreDistribution ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.averageGlobalScore.toFixed(1)}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.criterionDistributions.length}
</p>
<p className="text-xs text-muted-foreground">Criteria</p>
</div>
</div>
{/* Score histogram (text-based) */}
<div className="space-y-2">
<p className="text-sm font-medium">Global Score Distribution</p>
{scoreDistribution.globalDistribution.map((bucket) => {
const maxCount = Math.max(
...scoreDistribution.globalDistribution.map((b) => b.count),
1
)
const width = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex items-center gap-2">
<span className="text-xs tabular-nums w-4 text-right">{bucket.score}</span>
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden">
<div
className="h-full bg-brand-blue/60 dark:bg-brand-teal/60 rounded transition-all"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-xs tabular-nums w-6 text-right">{bucket.count}</span>
</div>
)
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Awards Tab Content ────────────────────────────────────────────────────
function AwardsTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedAwardStageId, setSelectedAwardStageId] = useState<string>('')
const { data: awards, isLoading: awardsLoading } =
trpc.analytics.getAwardSummary.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: voteDistribution, isLoading: votesLoading } =
trpc.analytics.getAwardVoteDistribution.useQuery(
{ stageId: selectedAwardStageId },
{ enabled: !!selectedAwardStageId }
)
if (awardsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!awards || awards.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No award tracks</p>
<p className="text-sm text-muted-foreground mt-1">
This pipeline has no award tracks configured.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Awards summary table */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Trophy className="h-5 w-5" />
Award Tracks
</CardTitle>
<CardDescription>
Click an award&apos;s stage to see vote/score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Award</TableHead>
<TableHead>Status</TableHead>
<TableHead>Scoring</TableHead>
<TableHead>Routing</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Completion</TableHead>
<TableHead>Winner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{awards.map((award) => (
<TableRow key={award.trackId}>
<TableCell className="font-medium">{award.awardName}</TableCell>
<TableCell>
{award.awardStatus ? (
<Badge
variant="outline"
className={cn('text-xs', awardStatusColors[award.awardStatus])}
>
{award.awardStatus.replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.scoringMode ? (
<Badge variant="outline" className="text-xs capitalize">
{award.scoringMode.toLowerCase().replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.routingMode ? (
<Badge variant="outline" className="text-xs">
{award.routingMode}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-center tabular-nums">{award.projectCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[100px]">
<Progress value={award.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{award.completionRate}%
</span>
</div>
</TableCell>
<TableCell>
{award.winner ? (
<div className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-amber-500 shrink-0" />
<span className="text-sm font-medium truncate max-w-[150px]">
{award.winner.title}
</span>
{award.winner.overridden && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
overridden
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">Not finalized</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Award stages clickable list */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Award Stage Details</CardTitle>
<CardDescription>
Select a stage to view vote and score distribution
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{awards.map((award) =>
award.stages.map((stage) => (
<button
key={stage.id}
onClick={() => setSelectedAwardStageId(stage.id)}
className={cn(
'w-full text-left rounded-lg border p-3 transition-colors',
selectedAwardStageId === stage.id
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
: 'hover:bg-muted/50'
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{award.awardName} {stage.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Type: {stage.stageType.toLowerCase().replace(/_/g, ' ')} | Status: {stage.status}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</div>
</button>
))
)}
</div>
</CardContent>
</Card>
{/* Vote/score distribution for selected award stage */}
{selectedAwardStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Vote / Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
{votesLoading ? (
<Skeleton className="h-48 w-full" />
) : voteDistribution && voteDistribution.projects.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalVotes}
</p>
<p className="text-xs text-muted-foreground">Award Votes</p>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-center">Evals</TableHead>
<TableHead className="text-center">Votes</TableHead>
<TableHead className="text-center">Avg Score</TableHead>
<TableHead className="text-center">Min</TableHead>
<TableHead className="text-center">Max</TableHead>
<TableHead className="text-center">Avg Rank</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{voteDistribution.projects.map((project) => (
<TableRow key={project.projectId}>
<TableCell className="font-medium truncate max-w-[200px]">
{project.title}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{project.teamName || '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.evaluationCount}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.voteCount}
</TableCell>
<TableCell className="text-center tabular-nums font-medium">
{project.avgScore !== null ? project.avgScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.minScore !== null ? project.minScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.maxScore !== null ? project.maxScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.avgRank !== null ? project.avgRank.toFixed(1) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground">No vote/score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Main Page ─────────────────────────────────────────────────────────────
export default function StageAnalyticsReportsPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? ''
const [selectedPipelineId, setSelectedPipelineId] = useState<string>('')
// Fetch pipelines
const { data: pipelines, isLoading: pipelinesLoading } =
trpc.pipeline.list.useQuery(
{ programId },
{ enabled: !!programId }
)
// Auto-select first pipeline
const activePipelineId = selectedPipelineId || (pipelines?.[0]?.id ?? '')
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/admin/reports" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Layers className="h-6 w-6" />
Pipeline Analytics
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stage-level reporting for the pipeline system
</p>
</div>
</div>
{/* Pipeline selector */}
{pipelines && pipelines.length > 0 && (
<Select
value={activePipelineId}
onValueChange={(v) => {
setSelectedPipelineId(v)
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Select pipeline" />
</SelectTrigger>
<SelectContent>
{pipelines.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{pipelinesLoading ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
) : !activePipelineId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
) : (
<Tabs defaultValue="stages" className="space-y-6">
<TabsList>
<TabsTrigger value="stages" className="gap-2">
<Layers className="h-4 w-4" />
Stages
</TabsTrigger>
<TabsTrigger value="awards" className="gap-2">
<Trophy className="h-4 w-4" />
Awards
</TabsTrigger>
</TabsList>
<TabsContent value="stages" className="space-y-6">
<StagesTabContent activePipelineId={activePipelineId} />
</TabsContent>
<TabsContent value="awards" className="space-y-6">
<AwardsTabContent activePipelineId={activePipelineId} />
</TabsContent>
</Tabs>
)}
</div>
)
}

View File

@@ -1,443 +0,0 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ArrowLeft,
Loader2,
Save,
Trash2,
LayoutTemplate,
Plus,
X,
GripVertical,
} from 'lucide-react'
import { toast } from 'sonner'
const ROUND_TYPE_LABELS: Record<string, string> = {
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
LIVE_EVENT: 'Live Event',
}
const CRITERION_TYPES = [
{ value: 'numeric', label: 'Numeric (1-10)' },
{ value: 'text', label: 'Text' },
{ value: 'boolean', label: 'Yes/No' },
{ value: 'section_header', label: 'Section Header' },
]
type Criterion = {
id: string
label: string
type: string
description?: string
weight?: number
min?: number
max?: number
}
export default function RoundTemplateDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const utils = trpc.useUtils()
const { data: template, isLoading } = trpc.roundTemplate.getById.useQuery({ id })
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [roundType, setRoundType] = useState('EVALUATION')
const [criteria, setCriteria] = useState<Criterion[]>([])
const [initialized, setInitialized] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
// Initialize form state from loaded data
if (template && !initialized) {
setName(template.name)
setDescription(template.description || '')
setRoundType(template.roundType)
setCriteria((template.criteriaJson as Criterion[]) || [])
setInitialized(true)
}
const updateTemplate = trpc.roundTemplate.update.useMutation({
onSuccess: () => {
utils.roundTemplate.getById.invalidate({ id })
utils.roundTemplate.list.invalidate()
toast.success('Template saved')
},
onError: (err) => {
toast.error(err.message)
},
})
const deleteTemplate = trpc.roundTemplate.delete.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
router.push('/admin/round-templates')
toast.success('Template deleted')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = () => {
updateTemplate.mutate({
id,
name: name.trim(),
description: description.trim() || undefined,
roundType: roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
criteriaJson: criteria,
})
}
const addCriterion = () => {
setCriteria([
...criteria,
{
id: `criterion_${Date.now()}`,
label: '',
type: 'numeric',
weight: 1,
min: 1,
max: 10,
},
])
}
const updateCriterion = (index: number, updates: Partial<Criterion>) => {
setCriteria(criteria.map((c, i) => (i === index ? { ...c, ...updates } : c)))
}
const removeCriterion = (index: number) => {
setCriteria(criteria.filter((_, i) => i !== index))
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!template) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/round-templates">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Templates
</Link>
</Button>
<Card>
<CardContent className="py-12 text-center">
<p className="font-medium">Template not found</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/round-templates">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Templates
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<LayoutTemplate className="h-7 w-7 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{template.name}
</h1>
<p className="text-muted-foreground">
Edit template configuration and criteria
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button onClick={handleSave} disabled={updateTemplate.isPending}>
{updateTemplate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Template Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Standard Evaluation Round"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this template is for..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Round Type</Label>
<Select value={roundType} onValueChange={setRoundType}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ROUND_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="pt-2 text-sm text-muted-foreground">
Created {new Date(template.createdAt).toLocaleDateString()} | Last updated{' '}
{new Date(template.updatedAt).toLocaleDateString()}
</div>
</CardContent>
</Card>
{/* Criteria */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Define the criteria jurors will use to evaluate projects
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addCriterion}>
<Plus className="mr-2 h-4 w-4" />
Add Criterion
</Button>
</div>
</CardHeader>
<CardContent>
{criteria.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No criteria defined yet.</p>
<Button variant="outline" className="mt-3" onClick={addCriterion}>
<Plus className="mr-2 h-4 w-4" />
Add First Criterion
</Button>
</div>
) : (
<div className="space-y-4">
{criteria.map((criterion, index) => (
<div key={criterion.id}>
{index > 0 && <Separator className="mb-4" />}
<div className="flex items-start gap-3">
<div className="mt-2 text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="sm:col-span-2 lg:col-span-2">
<Label className="text-xs text-muted-foreground">Label</Label>
<Input
value={criterion.label}
onChange={(e) =>
updateCriterion(index, { label: e.target.value })
}
placeholder="e.g., Innovation"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Type</Label>
<Select
value={criterion.type}
onValueChange={(val) =>
updateCriterion(index, { type: val })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CRITERION_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{criterion.type === 'numeric' && (
<div>
<Label className="text-xs text-muted-foreground">Weight</Label>
<Input
type="number"
min={0}
max={10}
step={0.1}
value={criterion.weight ?? 1}
onChange={(e) =>
updateCriterion(index, {
weight: parseFloat(e.target.value) || 1,
})
}
/>
</div>
)}
<div className="sm:col-span-2 lg:col-span-4">
<Label className="text-xs text-muted-foreground">
Description (optional)
</Label>
<Input
value={criterion.description || ''}
onChange={(e) =>
updateCriterion(index, { description: e.target.value })
}
placeholder="Help text for jurors..."
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="mt-5 h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeCriterion(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Template Metadata */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Template Info</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<Badge variant="secondary" className="mt-1">
{ROUND_TYPE_LABELS[template.roundType] || template.roundType}
</Badge>
</div>
<div>
<p className="text-muted-foreground">Criteria Count</p>
<p className="font-medium mt-1">{criteria.length}</p>
</div>
<div>
<p className="text-muted-foreground">Has Custom Settings</p>
<p className="font-medium mt-1">
{template.settingsJson && Object.keys(template.settingsJson as object).length > 0
? 'Yes'
: 'No'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Delete Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{template.name}&quot;? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteTemplate.mutate({ id })}
disabled={deleteTemplate.isPending}
>
{deleteTemplate.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,302 +0,0 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
LayoutTemplate,
Plus,
Calendar,
Settings2,
Trash2,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
const ROUND_TYPE_LABELS: Record<string, string> = {
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
LIVE_EVENT: 'Live Event',
}
const ROUND_TYPE_COLORS: Record<string, 'default' | 'secondary' | 'outline'> = {
FILTERING: 'secondary',
EVALUATION: 'default',
LIVE_EVENT: 'outline',
}
export default function RoundTemplatesPage() {
const utils = trpc.useUtils()
const { data: templates, isLoading } = trpc.roundTemplate.list.useQuery()
const [createOpen, setCreateOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newRoundType, setNewRoundType] = useState('EVALUATION')
const [deleteId, setDeleteId] = useState<string | null>(null)
const createTemplate = trpc.roundTemplate.create.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
setCreateOpen(false)
setNewName('')
setNewDescription('')
setNewRoundType('EVALUATION')
toast.success('Template created')
},
onError: (err) => {
toast.error(err.message)
},
})
const deleteTemplate = trpc.roundTemplate.delete.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
setDeleteId(null)
toast.success('Template deleted')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleCreate = () => {
if (!newName.trim()) return
createTemplate.mutate({
name: newName.trim(),
description: newDescription.trim() || undefined,
roundType: newRoundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
criteriaJson: [],
})
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-9 w-40" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Round Templates
</h1>
<p className="text-muted-foreground">
Save and reuse round configurations across editions
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Template</DialogTitle>
<DialogDescription>
Create a blank template. You can also save an existing round as a template from the round detail page.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="template-name">Name</Label>
<Input
id="template-name"
placeholder="e.g., Standard Evaluation Round"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="template-description">Description</Label>
<Textarea
id="template-description"
placeholder="Describe what this template is for..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="template-type">Round Type</Label>
<Select value={newRoundType} onValueChange={setNewRoundType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ROUND_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!newName.trim() || createTemplate.isPending}
>
{createTemplate.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Templates Grid */}
{templates && templates.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((template: typeof templates[number]) => {
const criteria = (template.criteriaJson as Array<unknown>) || []
const hasSettings = template.settingsJson && Object.keys(template.settingsJson as object).length > 0
return (
<Card key={template.id} className="group relative transition-colors hover:bg-muted/50">
<Link href={`/admin/round-templates/${template.id}`}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-primary" />
{template.name}
</CardTitle>
<Badge variant={ROUND_TYPE_COLORS[template.roundType] || 'secondary'}>
{ROUND_TYPE_LABELS[template.roundType] || template.roundType}
</Badge>
</div>
{template.description && (
<CardDescription className="line-clamp-2">
{template.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Settings2 className="h-4 w-4" />
{criteria.length} criteria
</div>
{hasSettings && (
<Badge variant="outline" className="text-xs">
Custom settings
</Badge>
)}
<div className="flex items-center gap-1 ml-auto">
<Calendar className="h-3.5 w-3.5" />
{new Date(template.createdAt).toLocaleDateString()}
</div>
</div>
</CardContent>
</Link>
{/* Delete button */}
<Button
variant="ghost"
size="icon"
className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setDeleteId(template.id)
}}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</Card>
)
})}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No templates yet</p>
<p className="text-sm text-muted-foreground">
Create a template or save an existing round configuration as a template
</p>
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Template
</Button>
</CardContent>
</Card>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete this template? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteTemplate.mutate({ id: deleteId })}
disabled={deleteTemplate.isPending}
>
{deleteTemplate.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,404 +0,0 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
ArrowLeft,
ShieldAlert,
CheckCircle2,
AlertCircle,
MoreHorizontal,
ShieldCheck,
UserX,
StickyNote,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
function COIManagementContent({ roundId }: { roundId: string }) {
const [conflictsOnly, setConflictsOnly] = useState(false)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
roundId,
hasConflictOnly: conflictsOnly || undefined,
})
const utils = trpc.useUtils()
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
onSuccess: (data) => {
utils.evaluation.listCOIByRound.invalidate({ roundId })
toast.success(`COI marked as "${data.reviewAction}"`)
},
onError: (error) => {
toast.error(error.message || 'Failed to review COI')
},
})
if (loadingRound || loadingCOI) {
return <COISkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
const totalCount = coiList?.length ?? 0
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
const getReviewBadge = (reviewAction: string | null) => {
switch (reviewAction) {
case 'cleared':
return (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Cleared
</Badge>
)
case 'reassigned':
return (
<Badge variant="default" className="bg-blue-600">
<UserX className="mr-1 h-3 w-3" />
Reassigned
</Badge>
)
case 'noted':
return (
<Badge variant="secondary">
<StickyNote className="mr-1 h-3 w-3" />
Noted
</Badge>
)
default:
return (
<Badge variant="outline" className="text-amber-600 border-amber-300">
Pending Review
</Badge>
)
}
}
const getConflictTypeBadge = (type: string | null) => {
switch (type) {
case 'financial':
return <Badge variant="destructive">Financial</Badge>
case 'personal':
return <Badge variant="secondary">Personal</Badge>
case 'organizational':
return <Badge variant="outline">Organizational</Badge>
case 'other':
return <Badge variant="outline">Other</Badge>
default:
return null
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<ShieldAlert className="h-6 w-6" />
Conflict of Interest Declarations
</h1>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{reviewedCount}</div>
</CardContent>
</Card>
</div>
{/* COI Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Declarations</CardTitle>
<CardDescription>
Review and manage conflict of interest declarations from jury members
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Switch
id="conflicts-only"
checked={conflictsOnly}
onCheckedChange={setConflictsOnly}
/>
<Label htmlFor="conflicts-only" className="text-sm">
Conflicts only
</Label>
</div>
</div>
</CardHeader>
<CardContent>
{coiList && coiList.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Juror</TableHead>
<TableHead>Conflict</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coiList.map((coi) => (
<TableRow key={coi.id}>
<TableCell className="font-medium max-w-[200px] truncate">
{coi.assignment.project.title}
</TableCell>
<TableCell>
{coi.user.name || coi.user.email}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<Badge variant="destructive">Yes</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-300">
No
</Badge>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
</TableCell>
<TableCell className="max-w-[200px]">
{coi.description ? (
<span className="text-sm text-muted-foreground truncate block">
{coi.description}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<div className="space-y-1">
{getReviewBadge(coi.reviewAction)}
{coi.reviewedBy && (
<p className="text-xs text-muted-foreground">
by {coi.reviewedBy.name || coi.reviewedBy.email}
{coi.reviewedAt && (
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
)}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={reviewCOI.isPending}
>
{reviewCOI.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'cleared',
})
}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Clear
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'reassigned',
})
}
>
<UserX className="mr-2 h-4 w-4" />
Reassign
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'noted',
})
}
>
<StickyNote className="mr-2 h-4 w-4" />
Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Declarations Yet</p>
<p className="text-sm text-muted-foreground">
{conflictsOnly
? 'No conflicts of interest have been declared for this round'
: 'No jury members have submitted COI declarations for this round yet'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function COISkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-80" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function COIManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<COISkeleton />}>
<COIManagementContent roundId={id} />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
'use client'
import { use, useEffect } from 'react'
import { useRouter } from 'next/navigation'
// Redirect to round details page - filtering is now integrated there
export default function FilteringDashboardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const router = useRouter()
useEffect(() => {
router.replace(`/admin/rounds/${roundId}`)
}, [router, roundId])
return (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Redirecting to round details...</p>
</div>
)
}

View File

@@ -1,599 +0,0 @@
'use client'
import { use, useState, useCallback } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Pagination } from '@/components/shared/pagination'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
CheckCircle2,
XCircle,
AlertTriangle,
ChevronDown,
RotateCcw,
Loader2,
ShieldCheck,
Download,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const OUTCOME_BADGES: Record<
string,
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
> = {
PASSED: {
variant: 'default',
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
label: 'Passed',
},
FILTERED_OUT: {
variant: 'destructive',
icon: <XCircle className="mr-1 h-3 w-3" />,
label: 'Filtered Out',
},
FLAGGED: {
variant: 'secondary',
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
label: 'Flagged',
},
}
export default function FilteringResultsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [overrideDialog, setOverrideDialog] = useState<{
id: string
currentOutcome: string
} | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
const [overrideReason, setOverrideReason] = useState('')
const perPage = 20
const { data, isLoading, refetch } = trpc.filtering.getResults.useQuery({
roundId,
outcome: outcomeFilter
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
: undefined,
page,
perPage,
}, {
staleTime: 0, // Always refetch - results change after filtering runs
})
const utils = trpc.useUtils()
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
const [showExportDialog, setShowExportDialog] = useState(false)
const handleExport = () => {
setShowExportDialog(true)
}
const handleRequestExportData = useCallback(async () => {
const result = await exportResults.refetch()
return result.data ?? undefined
}, [exportResults])
const toggleRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedRows(next)
}
const handleOverride = async () => {
if (!overrideDialog || !overrideReason.trim()) return
try {
await overrideResult.mutateAsync({
id: overrideDialog.id,
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
reason: overrideReason.trim(),
})
toast.success('Result overridden')
setOverrideDialog(null)
setOverrideReason('')
refetch()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
}
const handleReinstate = async (projectId: string) => {
try {
await reinstateProject.mutateAsync({ roundId, projectId })
toast.success('Project reinstated')
refetch()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
</div>
<Button
variant="outline"
onClick={handleExport}
disabled={exportResults.isFetching}
>
{exportResults.isFetching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
</div>
{/* Outcome Filter Tabs */}
<div className="flex gap-2">
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
<Button
key={outcome || 'all'}
variant={outcomeFilter === outcome ? 'default' : 'outline'}
size="sm"
onClick={() => {
setOutcomeFilter(outcome)
setPage(1)
}}
>
{outcome ? (
<>
{OUTCOME_BADGES[outcome].icon}
{OUTCOME_BADGES[outcome].label}
</>
) : (
'All'
)}
</Button>
))}
</div>
{/* Results Table */}
{data && data.results.length > 0 ? (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Outcome</TableHead>
<TableHead className="w-[300px]">AI Reason</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.results.map((result) => {
const isExpanded = expandedRows.has(result.id)
const effectiveOutcome =
result.finalOutcome || result.outcome
const badge = OUTCOME_BADGES[effectiveOutcome]
// Extract AI reasoning from aiScreeningJson
const aiScreening = result.aiScreeningJson as Record<string, {
meetsCriteria?: boolean
confidence?: number
reasoning?: string
qualityScore?: number
spamRisk?: boolean
}> | null
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
const aiReasoning = firstAiResult?.reasoning
return (
<>
<TableRow
key={result.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleRow(result.id)}
>
<TableCell>
<div>
<p className="font-medium">
{result.project.title}
</p>
<p className="text-sm text-muted-foreground">
{result.project.teamName}
{result.project.country && ` · ${result.project.country}`}
</p>
</div>
</TableCell>
<TableCell>
{result.project.competitionCategory ? (
<Badge variant="outline">
{result.project.competitionCategory.replace(
'_',
' '
)}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={badge?.variant || 'secondary'}>
{badge?.icon}
{badge?.label || effectiveOutcome}
</Badge>
{result.overriddenByUser && (
<p className="text-xs text-muted-foreground">
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
</p>
)}
</div>
</TableCell>
<TableCell>
{aiReasoning ? (
<div className="space-y-1">
<p className="text-sm line-clamp-2">
{aiReasoning}
</p>
{firstAiResult && (
<div className="flex gap-2 text-xs text-muted-foreground">
{firstAiResult.confidence !== undefined && (
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
)}
{firstAiResult.qualityScore !== undefined && (
<span>Quality: {firstAiResult.qualityScore}/10</span>
)}
{firstAiResult.spamRisk && (
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
)}
</div>
)}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
No AI screening
</span>
)}
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOutcome('PASSED')
setOverrideDialog({
id: result.id,
currentOutcome: effectiveOutcome,
})
}}
>
<ShieldCheck className="mr-1 h-3 w-3" />
Override
</Button>
{effectiveOutcome === 'FILTERED_OUT' && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleReinstate(result.projectId)
}
disabled={reinstateProject.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reinstate
</Button>
)}
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${result.id}-detail`}>
<TableCell colSpan={5} className="bg-muted/30">
<div className="p-4 space-y-4">
{/* Rule Results (non-AI rules only, AI shown separately) */}
<div>
<p className="text-sm font-medium mb-2">
Rule Results
</p>
{result.ruleResultsJson &&
Array.isArray(result.ruleResultsJson) ? (
<div className="space-y-2">
{(
result.ruleResultsJson as Array<{
ruleName: string
ruleType: string
passed: boolean
action: string
reasoning?: string
}>
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
<div
key={i}
className="flex items-start gap-2 text-sm"
>
{rr.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
)}
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{rr.ruleName}
</span>
<Badge variant="outline" className="text-xs">
{rr.ruleType.replace('_', ' ')}
</Badge>
</div>
{rr.reasoning && (
<p className="text-muted-foreground mt-0.5">
{rr.reasoning}
</p>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No detailed rule results available
</p>
)}
</div>
{/* AI Screening Details */}
{aiScreening && Object.keys(aiScreening).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">
AI Screening Analysis
</p>
<div className="space-y-3">
{Object.entries(aiScreening).map(([ruleId, screening]) => (
<div key={ruleId} className="p-3 bg-background rounded-lg border">
<div className="flex items-center gap-2 mb-2">
{screening.meetsCriteria ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="font-medium text-sm">
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
</span>
{screening.spamRisk && (
<Badge variant="destructive" className="text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
Spam Risk
</Badge>
)}
</div>
{screening.reasoning && (
<p className="text-sm text-muted-foreground mb-2">
{screening.reasoning}
</p>
)}
<div className="flex gap-4 text-xs text-muted-foreground">
{screening.confidence !== undefined && (
<span>
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
</span>
)}
{screening.qualityScore !== undefined && (
<span>
Quality Score: <strong>{screening.qualityScore}/10</strong>
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Override Info */}
{result.overriddenByUser && (
<div className="pt-3 border-t">
<p className="text-sm font-medium mb-1">Manual Override</p>
<p className="text-sm text-muted-foreground">
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
{result.overriddenByUser.name || result.overriddenByUser.email}
</p>
{result.overrideReason && (
<p className="text-sm text-muted-foreground mt-1">
Reason: {result.overrideReason}
</p>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</Card>
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No results found</p>
<p className="text-sm text-muted-foreground">
{outcomeFilter
? 'No results match this filter'
: 'Run filtering rules to generate results'}
</p>
</CardContent>
</Card>
)}
{/* Override Dialog */}
<Dialog
open={!!overrideDialog}
onOpenChange={(open) => {
if (!open) {
setOverrideDialog(null)
setOverrideReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Override Filtering Result</DialogTitle>
<DialogDescription>
Change the outcome for this project. This will be logged in the
audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New Outcome</Label>
<Select
value={overrideOutcome}
onValueChange={setOverrideOutcome}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Input
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="Explain why you're overriding..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOverrideDialog(null)}
>
Cancel
</Button>
<Button
onClick={handleOverride}
disabled={
overrideResult.isPending || !overrideReason.trim()
}
>
{overrideResult.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Override
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* CSV Export Dialog with Column Selection */}
<CsvExportDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
exportData={exportResults.data ?? undefined}
isLoading={exportResults.isFetching}
filename="filtering-results"
onRequestData={handleRequestExportData}
/>
</div>
)
}

View File

@@ -1,586 +0,0 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
Plus,
Trash2,
GripVertical,
Loader2,
FileCheck,
SlidersHorizontal,
Filter,
} from 'lucide-react'
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
const RULE_TYPE_LABELS: Record<RuleType, string> = {
FIELD_BASED: 'Field-Based',
DOCUMENT_CHECK: 'Document Check',
AI_SCREENING: 'AI Screening',
}
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
FIELD_BASED: <Filter className="h-4 w-4" />,
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
}
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category' },
{ value: 'foundedAt', label: 'Founded Date' },
{ value: 'country', label: 'Country' },
{ value: 'geographicZone', label: 'Geographic Zone' },
{ value: 'tags', label: 'Tags' },
{ value: 'oceanIssue', label: 'Ocean Issue' },
]
const OPERATOR_OPTIONS = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'contains', label: 'Contains' },
{ value: 'in', label: 'In (list)' },
{ value: 'not_in', label: 'Not In (list)' },
{ value: 'is_empty', label: 'Is Empty' },
{ value: 'older_than_years', label: 'Older Than (years)' },
{ value: 'newer_than_years', label: 'Newer Than (years)' },
]
export default function FilteringRulesPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const { data: rules, isLoading, refetch } =
trpc.filtering.getRules.useQuery({ roundId })
const createRule = trpc.filtering.createRule.useMutation()
const updateRule = trpc.filtering.updateRule.useMutation()
const deleteRule = trpc.filtering.deleteRule.useMutation()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [newRuleName, setNewRuleName] = useState('')
const [newRuleType, setNewRuleType] = useState<RuleType>('FIELD_BASED')
// Field-based config state
const [conditionField, setConditionField] = useState('competitionCategory')
const [conditionOperator, setConditionOperator] = useState('equals')
const [conditionValue, setConditionValue] = useState('')
const [conditionLogic, setConditionLogic] = useState<'AND' | 'OR'>('AND')
const [conditionAction, setConditionAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
// Document check config state
const [minFileCount, setMinFileCount] = useState('1')
const [docAction, setDocAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
// AI screening config state
const [criteriaText, setCriteriaText] = useState('')
const [aiAction, setAiAction] = useState<'REJECT' | 'FLAG'>('REJECT')
const [aiBatchSize, setAiBatchSize] = useState('20')
const [aiParallelBatches, setAiParallelBatches] = useState('1')
const handleCreateRule = async () => {
if (!newRuleName.trim()) return
let configJson: Record<string, unknown> = {}
if (newRuleType === 'FIELD_BASED') {
configJson = {
conditions: [
{
field: conditionField,
operator: conditionOperator,
value: conditionOperator === 'in' || conditionOperator === 'not_in'
? conditionValue.split(',').map((v) => v.trim())
: conditionOperator === 'older_than_years' ||
conditionOperator === 'newer_than_years' ||
conditionOperator === 'greater_than' ||
conditionOperator === 'less_than'
? Number(conditionValue)
: conditionValue,
},
],
logic: conditionLogic,
action: conditionAction,
}
} else if (newRuleType === 'DOCUMENT_CHECK') {
configJson = {
minFileCount: parseInt(minFileCount) || 1,
action: docAction,
}
} else if (newRuleType === 'AI_SCREENING') {
configJson = {
criteriaText,
action: aiAction,
batchSize: parseInt(aiBatchSize) || 20,
parallelBatches: parseInt(aiParallelBatches) || 1,
}
}
try {
await createRule.mutateAsync({
roundId,
name: newRuleName.trim(),
ruleType: newRuleType,
configJson,
priority: (rules?.length || 0) + 1,
})
toast.success('Rule created')
setShowCreateDialog(false)
resetForm()
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to create rule'
)
}
}
const handleToggleActive = async (ruleId: string, isActive: boolean) => {
try {
await updateRule.mutateAsync({ id: ruleId, isActive })
refetch()
} catch {
toast.error('Failed to update rule')
}
}
const handleDeleteRule = async (ruleId: string) => {
try {
await deleteRule.mutateAsync({ id: ruleId })
toast.success('Rule deleted')
refetch()
} catch {
toast.error('Failed to delete rule')
}
}
const resetForm = () => {
setNewRuleName('')
setNewRuleType('FIELD_BASED')
setConditionField('competitionCategory')
setConditionOperator('equals')
setConditionValue('')
setConditionLogic('AND')
setConditionAction('REJECT')
setMinFileCount('1')
setDocAction('REJECT')
setCriteriaText('')
setAiBatchSize('20')
setAiParallelBatches('1')
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}/filtering`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Filtering
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Rules
</h1>
<p className="text-muted-foreground">
Rules are evaluated in order of priority
</p>
</div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Filtering Rule</DialogTitle>
<DialogDescription>
Define conditions that projects must meet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Rule Name</Label>
<Input
value={newRuleName}
onChange={(e) => setNewRuleName(e.target.value)}
placeholder="e.g., Startup age check"
/>
</div>
<div className="space-y-2">
<Label>Rule Type</Label>
<Select
value={newRuleType}
onValueChange={(v) => setNewRuleType(v as RuleType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field-Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">
Document Check
</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
{/* Field-Based Config */}
{newRuleType === 'FIELD_BASED' && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Field</Label>
<Select
value={conditionField}
onValueChange={setConditionField}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operator</Label>
<Select
value={conditionOperator}
onValueChange={setConditionOperator}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{conditionOperator !== 'is_empty' && (
<div className="space-y-2">
<Label>Value</Label>
<Input
value={conditionValue}
onChange={(e) => setConditionValue(e.target.value)}
placeholder={
conditionOperator === 'in' ||
conditionOperator === 'not_in'
? 'Comma-separated values'
: 'Value'
}
/>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Logic</Label>
<Select
value={conditionLogic}
onValueChange={(v) =>
setConditionLogic(v as 'AND' | 'OR')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select
value={conditionAction}
onValueChange={(v) =>
setConditionAction(v as 'PASS' | 'REJECT' | 'FLAG')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASS">Pass</SelectItem>
<SelectItem value="REJECT">Reject</SelectItem>
<SelectItem value="FLAG">Flag</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
{/* Document Check Config */}
{newRuleType === 'DOCUMENT_CHECK' && (
<>
<div className="space-y-2">
<Label>Minimum File Count</Label>
<Input
type="number"
min="1"
value={minFileCount}
onChange={(e) => setMinFileCount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Action if not met</Label>
<Select
value={docAction}
onValueChange={(v) =>
setDocAction(v as 'PASS' | 'REJECT' | 'FLAG')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="REJECT">Reject</SelectItem>
<SelectItem value="FLAG">Flag</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{/* AI Screening Config */}
{newRuleType === 'AI_SCREENING' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Screening Criteria</Label>
<Textarea
value={criteriaText}
onChange={(e) => setCriteriaText(e.target.value)}
placeholder="Describe the criteria for AI to evaluate projects against..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Action for Non-Matching Projects</Label>
<Select value={aiAction} onValueChange={(v) => setAiAction(v as 'REJECT' | 'FLAG')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="REJECT">Auto Filter Out</SelectItem>
<SelectItem value="FLAG">Flag for Review</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{aiAction === 'REJECT'
? 'Projects that don\'t meet criteria will be automatically filtered out.'
: 'Projects that don\'t meet criteria will be flagged for human review.'}
</p>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Performance Settings</Label>
<p className="text-xs text-muted-foreground mb-3">
Adjust batch settings to balance speed vs. cost
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Batch Size</Label>
<Select value={aiBatchSize} onValueChange={setAiBatchSize}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Individual)</SelectItem>
<SelectItem value="5">5 (Small)</SelectItem>
<SelectItem value="10">10 (Medium)</SelectItem>
<SelectItem value="20">20 (Default)</SelectItem>
<SelectItem value="50">50 (Large)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Projects per API call. Smaller = more parallel potential
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">Parallel Requests</Label>
<Select value={aiParallelBatches} onValueChange={setAiParallelBatches}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Sequential)</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5 (Fast)</SelectItem>
<SelectItem value="10">10 (Maximum)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Concurrent API calls. Higher = faster but more costly
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateRule}
disabled={
createRule.isPending ||
!newRuleName.trim() ||
(newRuleType === 'AI_SCREENING' && !criteriaText.trim())
}
>
{createRule.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Rules List */}
{rules && rules.length > 0 ? (
<div className="space-y-3">
{rules.map((rule, index) => (
<Card key={rule.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex items-center gap-2 text-muted-foreground">
<GripVertical className="h-4 w-4" />
<span className="text-sm font-mono w-6 text-center">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{RULE_TYPE_ICONS[rule.ruleType as RuleType]}
<p className="font-medium">{rule.name}</p>
<Badge variant="outline">
{RULE_TYPE_LABELS[rule.ruleType as RuleType]}
</Badge>
{!rule.isActive && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{rule.ruleType === 'AI_SCREENING'
? (rule.configJson as Record<string, unknown>)
?.criteriaText
? String(
(rule.configJson as Record<string, unknown>)
.criteriaText
).slice(0, 80) + '...'
: 'AI screening rule'
: rule.ruleType === 'DOCUMENT_CHECK'
? `Min ${(rule.configJson as Record<string, unknown>)?.minFileCount || 1} file(s)`
: `${((rule.configJson as Record<string, unknown>)?.conditions as Array<Record<string, unknown>>)?.length || 0} condition(s)`}
</p>
</div>
<Switch
checked={rule.isActive}
onCheckedChange={(checked) =>
handleToggleActive(rule.id, checked)
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
disabled={deleteRule.isPending}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rules configured</p>
<p className="text-sm text-muted-foreground">
Add filtering rules to screen projects automatically
</p>
</CardContent>
</Card>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,390 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Loader2, Save, Rocket } from 'lucide-react'
import Link from 'next/link'
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
export default function NewPipelinePage() {
const router = useRouter()
const searchParams = useSearchParams()
const programId = searchParams.get('programId') ?? ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [openSection, setOpenSection] = useState(0)
const initialStateRef = useRef(JSON.stringify(state))
// Dirty tracking — warn on navigate away
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (JSON.stringify(state) !== initialStateRef.current) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [state])
const updateState = useCallback((updates: Partial<WizardState>) => {
setState((prev) => ({ ...prev, ...updates }))
}, [])
// Get stage configs from the main track
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
const updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => ({
...prev,
tracks: prev.tracks.map((track) => {
if (track.kind !== 'MAIN') return track
return {
...track,
stages: track.stages.map((stage) =>
stage.stageType === stageType ? { ...stage, configJson } : stage
),
}
}),
}))
},
[]
)
const updateMainTrackStages = useCallback(
(stages: WizardState['tracks'][0]['stages']) => {
setState((prev) => ({
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}))
},
[]
)
// Validation
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
// Mutations
const createMutation = trpc.pipeline.createStructure.useMutation({
onSuccess: (data) => {
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
toast.success('Pipeline created successfully')
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
},
onError: (err) => {
toast.error(err.message)
},
})
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => {
toast.success('Pipeline published successfully')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = async (publish: boolean) => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Open first section with errors
if (!validation.sections.basics.valid) setOpenSection(0)
else if (!validation.sections.tracks.valid) setOpenSection(2)
return
}
const result = await createMutation.mutateAsync({
programId: state.programId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
if (publish && result.pipeline.id) {
await publishMutation.mutateAsync({ id: result.pipeline.id })
}
}
const isSaving = createMutation.isPending || publishMutation.isPending
const sections = [
{
title: 'Basics',
description: 'Pipeline name, slug, and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission windows and file requirements',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: `${mainTrack?.stages.length ?? 0} stages configured`,
isValid: tracksValid,
},
{
title: 'Filtering',
description: 'Gate rules and AI screening settings',
isValid: !!filterStage,
},
{
title: 'Assignment',
description: 'Jury evaluation assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`,
isValid: true, // Awards are optional
},
{
title: 'Live Finals',
description: 'Voting, cohorts, and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications and override governance',
isValid: true, // Always valid
},
{
title: 'Review & Publish',
description: 'Validation summary and publish controls',
isValid: allValid,
},
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Configure the full pipeline structure for project evaluation
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={isSaving || !allValid}
onClick={() => handleSave(false)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Draft
</Button>
<Button
type="button"
disabled={isSaving || !allValid}
onClick={() => handleSave(true)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
Save & Publish
</Button>
</div>
</div>
{/* Wizard Sections */}
<div className="space-y-3">
{/* 0: Basics */}
<WizardSection
stepNumber={1}
title={sections[0].title}
description={sections[0].description}
isOpen={openSection === 0}
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
isValid={sections[0].isValid}
>
<BasicsSection state={state} onChange={updateState} />
</WizardSection>
{/* 1: Intake */}
<WizardSection
stepNumber={2}
title={sections[1].title}
description={sections[1].description}
isOpen={openSection === 1}
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
isValid={sections[1].isValid}
>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
{/* 2: Main Track Stages */}
<WizardSection
stepNumber={3}
title={sections[2].title}
description={sections[2].description}
isOpen={openSection === 2}
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
isValid={sections[2].isValid}
>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</WizardSection>
{/* 3: Filtering */}
<WizardSection
stepNumber={4}
title={sections[3].title}
description={sections[3].description}
isOpen={openSection === 3}
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
isValid={sections[3].isValid}
>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
{/* 4: Assignment */}
<WizardSection
stepNumber={5}
title={sections[4].title}
description={sections[4].description}
isOpen={openSection === 4}
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
isValid={sections[4].isValid}
>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
{/* 5: Awards */}
<WizardSection
stepNumber={6}
title={sections[5].title}
description={sections[5].description}
isOpen={openSection === 5}
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
isValid={sections[5].isValid}
>
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
</WizardSection>
{/* 6: Live Finals */}
<WizardSection
stepNumber={7}
title={sections[6].title}
description={sections[6].description}
isOpen={openSection === 6}
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
isValid={sections[6].isValid}
>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
{/* 7: Notifications */}
<WizardSection
stepNumber={8}
title={sections[7].title}
description={sections[7].description}
isOpen={openSection === 7}
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
isValid={sections[7].isValid}
>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</WizardSection>
{/* 8: Review */}
<WizardSection
stepNumber={9}
title={sections[8].title}
description={sections[8].description}
isOpen={openSection === 8}
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
isValid={sections[8].isValid}
>
<ReviewSection state={state} />
</WizardSection>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,666 +0,0 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
Archive,
Trash2,
Loader2,
GripVertical,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundPipeline } from '@/components/admin/round-pipeline'
type RoundData = {
id: string
name: string
status: string
roundType: string
votingStartAt: string | null
votingEndAt: string | null
_count?: {
projects: number
assignments: number
}
}
function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true,
})
if (isLoading) {
return <RoundsListSkeleton />
}
if (!programs || programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first to start managing rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{programs.map((program, index) => (
<AnimatedCard key={program.id} index={index}>
<ProgramRounds program={program} />
</AnimatedCard>
))}
</div>
)
}
function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils()
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
// Sync local state when query data refreshes (e.g. after status change)
useEffect(() => {
setRounds(program.rounds || [])
}, [program.rounds])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const reorder = trpc.round.reorder.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
toast.success('Round order updated')
},
onError: (error) => {
toast.error(error.message || 'Failed to reorder rounds')
// Reset to original order on error
setRounds(program.rounds || [])
},
})
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = rounds.findIndex((r) => r.id === active.id)
const newIndex = rounds.findIndex((r) => r.id === over.id)
const newRounds = arrayMove(rounds, oldIndex, newIndex)
setRounds(newRounds)
// Send the new order to the server
reorder.mutate({
programId: program.id,
roundIds: newRounds.map((r) => r.id),
})
}
}
// Sync local state when program.rounds changes
if (JSON.stringify(rounds.map(r => r.id)) !== JSON.stringify((program.rounds || []).map((r: RoundData) => r.id))) {
setRounds(program.rounds || [])
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{rounds.length > 0 ? (
<div className="space-y-2">
{/* Desktop: Table header */}
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div>Order</div>
<div>Round</div>
<div>Status</div>
<div>Voting Window</div>
<div>Projects</div>
<div>Reviewers</div>
<div></div>
</div>
{/* Sortable List */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 lg:space-y-1">
{rounds.map((round, index) => (
<SortableRoundRow
key={round.id}
round={round}
index={index}
totalRounds={rounds.length}
isReordering={reorder.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Pipeline visualization */}
{rounds.length > 1 && (
<div className="mt-6 pt-4 border-t">
<RoundPipeline rounds={rounds} programName={program.name} />
</div>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
)
}
function SortableRoundRow({
round,
index,
totalRounds,
isReordering,
}: {
round: RoundData
index: number
totalRounds: number
isReordering: boolean
}) {
const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => {
toast.success('Round deleted successfully')
utils.program.list.invalidate({ includeRounds: true })
},
onError: (error) => {
toast.error(error.message || 'Failed to delete round')
},
})
const getStatusBadge = () => {
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground text-sm">Not set</span>
}
const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt)
if (isFuture(start)) {
return (
<span className="text-sm">
Opens {format(start, 'MMM d')}
</span>
)
}
if (isPast(end)) {
return (
<span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d')}
</span>
)
}
return (
<span className="text-sm">
Until {format(end, 'MMM d')}
</span>
)
}
const actionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Judge Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Round
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ id: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteRound.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Desktop: Table row layout */}
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
{/* Order number with drag handle */}
<div className="flex items-center gap-1">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
{/* Round name */}
<div>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
{/* Actions */}
<div>
{actionsMenu}
</div>
</div>
{/* Mobile/Tablet: Card layout */}
<div className="lg:hidden p-4">
{/* Top row: drag handle, order, name, status badge, actions */}
<div className="flex items-start gap-3">
<div className="flex items-center gap-1 pt-0.5">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline line-clamp-1"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{getStatusBadge()}
{actionsMenu}
</div>
</div>
</div>
</div>
{/* Details row */}
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Voting Window</p>
<div className="mt-0.5">{getVotingWindow()}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Projects</p>
<div className="flex items-center gap-1.5 mt-0.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Reviewers</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
</div>
</div>
</div>
{deleteDialog}
</div>
)
}
function RoundsListSkeleton() {
return (
<div className="space-y-6">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-10 w-28" />
</div>
</CardHeader>
<CardContent>
{/* Desktop skeleton */}
<div className="hidden lg:block space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
{/* Mobile/Tablet skeleton */}
<div className="lg:hidden space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-6 w-16" />
</div>
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function RoundsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
<p className="text-muted-foreground">
Manage selection rounds and voting periods
</p>
</div>
{/* Content */}
<Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,554 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route as NextRoute } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Save,
Loader2,
ChevronRight,
Layers,
GitBranch,
Route,
Play,
} from 'lucide-react'
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
const stageTypeColors: Record<string, string> = {
INTAKE: 'text-blue-600',
FILTER: 'text-amber-600',
EVALUATION: 'text-purple-600',
SELECTION: 'text-rose-600',
LIVE_FINAL: 'text-emerald-600',
RESULTS: 'text-cyan-600',
}
type SelectedItem =
| { type: 'stage'; trackId: string; stageId: string }
| { type: 'track'; trackId: string }
| null
export default function AdvancedEditorPage() {
const params = useParams()
const pipelineId = params.id as string
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
const [configEditValue, setConfigEditValue] = useState('')
const [simulationProjectIds, setSimulationProjectIds] = useState('')
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
onSuccess: () => {
toast.success('Stage config saved')
refetch()
},
onError: (err) => toast.error(err.message),
})
const simulateMutation = trpc.pipeline.simulate.useMutation({
onSuccess: (data) => {
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
},
onError: (err) => toast.error(err.message),
})
const { data: routingRules } = trpc.routing.listRules.useQuery(
{ pipelineId },
{ enabled: !!pipelineId }
)
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-6 w-48" />
</div>
<div className="grid grid-cols-12 gap-4">
<Skeleton className="col-span-3 h-96" />
<Skeleton className="col-span-5 h-96" />
<Skeleton className="col-span-4 h-96" />
</div>
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={'/admin/rounds/pipelines' as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
</div>
</div>
)
}
const handleSelectStage = (trackId: string, stageId: string) => {
setSelectedItem({ type: 'stage', trackId, stageId })
const track = pipeline.tracks.find((t) => t.id === trackId)
const stage = track?.stages.find((s) => s.id === stageId)
setConfigEditValue(
JSON.stringify(stage?.configJson ?? {}, null, 2)
)
}
const executeSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
try {
const parsed = JSON.parse(configEditValue)
updateConfigMutation.mutate({
id: selectedItem.stageId,
configJson: parsed,
})
} catch {
toast.error('Invalid JSON in config editor')
}
}
const handleSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
// Validate JSON first
try {
JSON.parse(configEditValue)
} catch {
toast.error('Invalid JSON in config editor')
return
}
// If pipeline is active or stage has projects, require confirmation
const stage = pipeline?.tracks
.flatMap((t) => t.stages)
.find((s) => s.id === selectedItem.stageId)
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
const isActive = pipeline?.status === 'ACTIVE'
if (isActive || hasProjects) {
setShowSaveConfirm(true)
} else {
executeSaveConfig()
}
}
const handleSimulate = () => {
const ids = simulationProjectIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (ids.length === 0) {
toast.error('Enter at least one project ID')
return
}
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
}
const selectedTrack =
selectedItem?.type === 'stage'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: selectedItem?.type === 'track'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: null
const selectedStage =
selectedItem?.type === 'stage'
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
: null
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Advanced Editor</h1>
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
</div>
</div>
</div>
{/* Visualization */}
<PipelineVisualization tracks={pipeline.tracks} />
{/* Five Panel Layout */}
<div className="grid grid-cols-12 gap-4">
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
<div className="col-span-12 lg:col-span-3">
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Structure
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<div key={track.id}>
<button
type="button"
className={cn(
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
selectedItem?.type === 'track' &&
selectedItem.trackId === track.id
? 'bg-muted'
: ''
)}
onClick={() =>
setSelectedItem({ type: 'track', trackId: track.id })
}
>
<div className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3" />
<span>{track.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
{track.kind}
</Badge>
</div>
</button>
<div className="ml-4 space-y-0.5 mt-0.5">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<button
key={stage.id}
type="button"
className={cn(
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
selectedItem?.type === 'stage' &&
selectedItem.stageId === stage.id
? 'bg-muted font-medium'
: ''
)}
onClick={() =>
handleSelectStage(track.id, stage.id)
}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-[10px] font-mono',
stageTypeColors[stage.stageType] ?? ''
)}
>
{stage.stageType.slice(0, 3)}
</span>
<span className="truncate">{stage.name}</span>
{stage._count?.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[8px] h-3.5 px-1 ml-auto"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
</button>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* Panel 2 — Stage Config Editor (center) */}
<div className="col-span-12 lg:col-span-5">
<Card className="h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedStage
? `${selectedStage.name} Config`
: selectedTrack
? `${selectedTrack.name} Track`
: 'Select a stage'}
</CardTitle>
{selectedStage && (
<Button
size="sm"
variant="outline"
disabled={updateConfigMutation.isPending}
onClick={handleSaveConfig}
>
{updateConfigMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
Save
</Button>
)}
</div>
</CardHeader>
<CardContent>
{selectedStage ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-[10px]">
{selectedStage.stageType}
</Badge>
<span className="font-mono">{selectedStage.slug}</span>
</div>
<Textarea
value={configEditValue}
onChange={(e) => setConfigEditValue(e.target.value)}
className="font-mono text-xs min-h-[400px]"
placeholder="{ }"
/>
</div>
) : selectedTrack ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Kind</span>
<Badge variant="outline" className="text-xs">
{selectedTrack.kind}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Routing Mode</span>
<span className="text-xs font-mono">
{selectedTrack.routingMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Decision Mode</span>
<span className="text-xs font-mono">
{selectedTrack.decisionMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Stages</span>
<span className="font-medium">
{selectedTrack.stages.length}
</span>
</div>
{selectedTrack.specialAward && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium mb-1">Special Award</p>
<p className="text-xs text-muted-foreground">
{selectedTrack.specialAward.name} {' '}
{selectedTrack.specialAward.scoringMode}
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">
Select a track or stage from the tree to view or edit its
configuration
</p>
)}
</CardContent>
</Card>
</div>
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
<div className="col-span-12 lg:col-span-4 space-y-4">
{/* Routing Rules */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Route className="h-4 w-4" />
Routing Rules
</CardTitle>
</CardHeader>
<CardContent>
{routingRules && routingRules.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{routingRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
>
<Badge
variant={rule.isActive ? 'default' : 'secondary'}
className="text-[9px] h-4 shrink-0"
>
P{rule.priority}
</Badge>
<span className="truncate">
{rule.sourceTrack?.name ?? '—'} {' '}
{rule.destinationTrack?.name ?? '—'}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No routing rules configured
</p>
)}
</CardContent>
</Card>
{/* Transitions */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Transitions
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const allTransitions = pipeline.tracks.flatMap((track) =>
track.stages.flatMap((stage) =>
stage.transitionsFrom.map((t) => ({
id: t.id,
fromName: stage.name,
toName: t.toStage?.name ?? '?',
isDefault: t.isDefault,
}))
)
)
return allTransitions.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{allTransitions.map((t) => (
<div
key={t.id}
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
>
<span className="truncate">{t.fromName}</span>
<span className="text-muted-foreground"></span>
<span className="truncate">{t.toName}</span>
{t.isDefault && (
<Badge
variant="outline"
className="text-[8px] h-3.5 ml-auto shrink-0"
>
default
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No transitions defined
</p>
)
})()}
</CardContent>
</Card>
{/* Simulation */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Play className="h-4 w-4" />
Simulation
</CardTitle>
<CardDescription className="text-xs">
Test where projects would route
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs">Project IDs (comma-separated)</Label>
<Input
value={simulationProjectIds}
onChange={(e) => setSimulationProjectIds(e.target.value)}
placeholder="id1, id2, id3"
className="text-xs mt-1"
/>
</div>
<Button
size="sm"
className="w-full"
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
onClick={handleSimulate}
>
{simulateMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Simulation
</Button>
{simulateMutation.data?.simulations && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{simulateMutation.data.simulations.map((r, i) => (
<div
key={i}
className="text-xs py-1 border-b last:border-0"
>
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
<span className="text-muted-foreground"> </span>
<span>{r.targetTrackName ?? 'unrouted'}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Confirmation dialog for destructive config saves */}
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
<AlertDialogDescription>
This stage belongs to an active pipeline or has projects assigned to it.
Changing the configuration may affect ongoing evaluations and project processing.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowSaveConfirm(false)
executeSaveConfig()
}}
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,422 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Loader2, Save } from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import {
defaultIntakeConfig,
defaultFilterConfig,
defaultEvaluationConfig,
defaultLiveConfig,
defaultNotificationConfig,
} from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type {
WizardState,
IntakeConfig,
FilterConfig,
EvaluationConfig,
LiveFinalConfig,
WizardTrackConfig,
} from '@/types/pipeline-wizard'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function pipelineToWizardState(pipeline: any): WizardState {
const settings = (pipeline.settingsJson as Record<string, unknown>) ?? {}
return {
name: pipeline.name,
slug: pipeline.slug,
programId: pipeline.programId,
settingsJson: settings,
tracks: (pipeline.tracks ?? []).map((t: any) => ({
id: t.id,
name: t.name,
slug: t.slug,
kind: t.kind as WizardTrackConfig['kind'],
sortOrder: t.sortOrder,
routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'],
decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'],
stages: (t.stages ?? []).map((s: any) => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'],
sortOrder: s.sortOrder,
configJson: (s.configJson as Record<string, unknown>) ?? {},
})),
awardConfig: t.specialAward
? {
name: t.specialAward.name,
description: t.specialAward.description ?? undefined,
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
}
: undefined,
})),
notificationConfig:
(settings.notificationConfig as Record<string, boolean>) ??
defaultNotificationConfig(),
overridePolicy:
(settings.overridePolicy as Record<string, unknown>) ?? {
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
},
}
}
export default function EditPipelinePage() {
const router = useRouter()
const params = useParams()
const pipelineId = params.id as string
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const [state, setState] = useState<WizardState | null>(null)
const [openSection, setOpenSection] = useState(0)
const initialStateRef = useRef<string>('')
// Initialize state from pipeline data
useEffect(() => {
if (pipeline && !state) {
const wizardState = pipelineToWizardState(pipeline)
setState(wizardState)
initialStateRef.current = JSON.stringify(wizardState)
}
}, [pipeline, state])
// Dirty tracking
useEffect(() => {
if (!state) return
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (JSON.stringify(state) !== initialStateRef.current) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [state])
const updateState = useCallback((updates: Partial<WizardState>) => {
setState((prev) => (prev ? { ...prev, ...updates } : null))
}, [])
const updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => {
if (!prev) return null
return {
...prev,
tracks: prev.tracks.map((track) => {
if (track.kind !== 'MAIN') return track
return {
...track,
stages: track.stages.map((stage) =>
stage.stageType === stageType ? { ...stage, configJson } : stage
),
}
}),
}
})
},
[]
)
const updateMainTrackStages = useCallback(
(stages: WizardState['tracks'][0]['stages']) => {
setState((prev) => {
if (!prev) return null
return {
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}
})
},
[]
)
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
onSuccess: () => {
if (state) initialStateRef.current = JSON.stringify(state)
toast.success('Pipeline updated successfully')
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
},
onError: (err) => toast.error(err.message),
})
if (isLoading || !state) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
const isActive = pipeline?.status === 'ACTIVE'
const handleSave = async () => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
if (!validation.sections.basics.valid) setOpenSection(0)
else if (!validation.sections.tracks.valid) setOpenSection(2)
return
}
await updateStructureMutation.mutateAsync({
id: pipelineId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
}
const isSaving = updateStructureMutation.isPending
const sections = [
{ title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid },
{ title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage },
{ title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid },
{ title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage },
{ title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage },
{ title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true },
{ title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage },
{ title: 'Notifications', description: 'Event notifications and override governance', isValid: true },
{ title: 'Review', description: 'Validation summary', isValid: allValid },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Edit Pipeline</h1>
<p className="text-sm text-muted-foreground">
{pipeline?.name}
{isActive && ' (Active — some fields are locked)'}
</p>
</div>
</div>
<Button
type="button"
disabled={isSaving || !allValid}
onClick={handleSave}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
{/* Wizard Sections */}
<div className="space-y-3">
<WizardSection
stepNumber={1}
title={sections[0].title}
description={sections[0].description}
isOpen={openSection === 0}
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
isValid={sections[0].isValid}
>
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
</WizardSection>
<WizardSection
stepNumber={2}
title={sections[1].title}
description={sections[1].description}
isOpen={openSection === 1}
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
isValid={sections[1].isValid}
>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
<WizardSection
stepNumber={3}
title={sections[2].title}
description={sections[2].description}
isOpen={openSection === 2}
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
isValid={sections[2].isValid}
>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</WizardSection>
<WizardSection
stepNumber={4}
title={sections[3].title}
description={sections[3].description}
isOpen={openSection === 3}
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
isValid={sections[3].isValid}
>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
<WizardSection
stepNumber={5}
title={sections[4].title}
description={sections[4].description}
isOpen={openSection === 4}
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
isValid={sections[4].isValid}
>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
<WizardSection
stepNumber={6}
title={sections[5].title}
description={sections[5].description}
isOpen={openSection === 5}
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
isValid={sections[5].isValid}
>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</WizardSection>
<WizardSection
stepNumber={7}
title={sections[6].title}
description={sections[6].description}
isOpen={openSection === 6}
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
isValid={sections[6].isValid}
>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
<WizardSection
stepNumber={8}
title={sections[7].title}
description={sections[7].description}
isOpen={openSection === 7}
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
isValid={sections[7].isValid}
>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) =>
updateState({ overridePolicy })
}
/>
</WizardSection>
<WizardSection
stepNumber={9}
title={sections[8].title}
description={sections[8].description}
isOpen={openSection === 8}
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
isValid={sections[8].isValid}
>
<ReviewSection state={state} />
</WizardSection>
</div>
</div>
)
}

View File

@@ -0,0 +1,439 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Edit,
MoreHorizontal,
Rocket,
Archive,
Settings2,
Layers,
GitBranch,
Loader2,
} from 'lucide-react'
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-muted text-muted-foreground',
CLOSED: 'bg-blue-100 text-blue-700',
}
const stageTypeColors: Record<string, string> = {
INTAKE: 'bg-blue-100 text-blue-700',
FILTER: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-purple-100 text-purple-700',
SELECTION: 'bg-rose-100 text-rose-700',
LIVE_FINAL: 'bg-emerald-100 text-emerald-700',
RESULTS: 'bg-cyan-100 text-cyan-700',
}
function StagePanel({
stageId,
stageType,
configJson,
}: {
stageId: string
stageType: string
configJson: Record<string, unknown> | null
}) {
switch (stageType) {
case 'INTAKE':
return <IntakePanel stageId={stageId} configJson={configJson} />
case 'FILTER':
return <FilterPanel stageId={stageId} configJson={configJson} />
case 'EVALUATION':
return <EvaluationPanel stageId={stageId} configJson={configJson} />
case 'SELECTION':
return <SelectionPanel stageId={stageId} configJson={configJson} />
case 'LIVE_FINAL':
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
case 'RESULTS':
return <ResultsPanel stageId={stageId} configJson={configJson} />
default:
return (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unknown stage type: {stageType}
</CardContent>
</Card>
)
}
}
export default function PipelineDetailPage() {
const params = useParams()
const pipelineId = params.id as string
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
// Auto-select first track and stage
useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks[0]
setSelectedTrackId(firstTrack.id)
if (firstTrack.stages.length > 0) {
setSelectedStageId(firstTrack.stages[0].id)
}
}
}, [pipeline, selectedTrackId])
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => toast.success('Pipeline published'),
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.pipeline.update.useMutation({
onSuccess: () => toast.success('Pipeline updated'),
onError: (err) => toast.error(err.message),
})
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested pipeline does not exist
</p>
</div>
</div>
</div>
)
}
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
const selectedStage = selectedTrack?.stages.find(
(s) => s.id === selectedStageId
)
const handleTrackChange = (trackId: string) => {
setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
if (track && track.stages.length > 0) {
setSelectedStageId(track.stages[0].id)
} else {
setSelectedStageId(null)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{pipeline.name}</h1>
<Badge
variant="secondary"
className={cn(
'text-[10px]',
statusColors[pipeline.status] ?? ''
)}
>
{pipeline.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground font-mono">
{pipeline.slug}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
<Button variant="outline" size="sm">
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
</Link>
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
<Button variant="outline" size="sm">
<Settings2 className="h-4 w-4 mr-1" />
Advanced
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{pipeline.status === 'DRAFT' && (
<DropdownMenuItem
disabled={publishMutation.isPending}
onClick={() =>
publishMutation.mutate({ id: pipelineId })
}
>
{publishMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
Publish
</DropdownMenuItem>
)}
{pipeline.status === 'ACTIVE' && (
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'CLOSED',
})
}
>
Close Pipeline
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'ARCHIVED',
})
}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Pipeline Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Tracks</span>
</div>
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
<p className="text-xs text-muted-foreground">
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Stages</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
</p>
<p className="text-xs text-muted-foreground">across all tracks</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Transitions</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce(
(sum, t) =>
sum +
t.stages.reduce(
(s, stage) => s + stage.transitionsFrom.length,
0
),
0
)}
</p>
<p className="text-xs text-muted-foreground">stage connections</p>
</CardContent>
</Card>
</div>
{/* Track Tabs */}
{pipeline.tracks.length > 0 && (
<Tabs
value={selectedTrackId ?? undefined}
onValueChange={handleTrackChange}
>
<TabsList className="w-full justify-start overflow-x-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<TabsTrigger
key={track.id}
value={track.id}
className="flex items-center gap-1.5"
>
<span>{track.name}</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1"
>
{track.kind}
</Badge>
</TabsTrigger>
))}
</TabsList>
{pipeline.tracks.map((track) => (
<TabsContent key={track.id} value={track.id} className="mt-4">
{/* Track Info */}
<Card className="mb-4">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm">{track.name}</CardTitle>
<CardDescription className="font-mono text-xs">
{track.slug}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{track.routingMode && (
<Badge variant="outline" className="text-[10px]">
{track.routingMode}
</Badge>
)}
{track.decisionMode && (
<Badge variant="outline" className="text-[10px]">
{track.decisionMode}
</Badge>
)}
</div>
</div>
</CardHeader>
</Card>
{/* Stage Tabs within Track */}
{track.stages.length > 0 ? (
<Tabs
value={
selectedTrackId === track.id
? selectedStageId ?? undefined
: undefined
}
onValueChange={setSelectedStageId}
>
<TabsList className="w-full justify-start overflow-x-auto">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<TabsTrigger
key={stage.id}
value={stage.id}
className="flex items-center gap-1.5"
>
<span>{stage.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[9px] h-4 px-1',
stageTypeColors[stage.stageType] ?? ''
)}
>
{stage.stageType.replace('_', ' ')}
</Badge>
</TabsTrigger>
))}
</TabsList>
{track.stages.map((stage) => (
<TabsContent
key={stage.id}
value={stage.id}
className="mt-4"
>
<StagePanel
stageId={stage.id}
stageType={stage.stageType}
configJson={
stage.configJson as Record<string, unknown> | null
}
/>
</TabsContent>
))}
</Tabs>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No stages configured for this track
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
)}
</div>
)
}

View File

@@ -0,0 +1,206 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Layers,
GitBranch,
Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-muted text-muted-foreground',
CLOSED: 'bg-blue-100 text-blue-700',
}
export default function PipelineListPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view pipelines
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its pipelines
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Manage evaluation pipelines for {currentEdition?.name}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pipelines || pipelines.length === 0) && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Pipelines Yet</p>
<p className="text-sm text-muted-foreground mb-4">
Create your first pipeline to start managing project evaluation
</p>
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Pipeline Cards */}
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => (
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">
{pipeline.name}
</CardTitle>
<CardDescription className="font-mono text-xs">
{pipeline.slug}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0',
statusColors[pipeline.status] ?? ''
)}
>
{pipeline.status}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
<Eye className="h-4 w-4 mr-2" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Layers className="h-3.5 w-3.5" />
<span>{pipeline._count.tracks} tracks</span>
</div>
<div className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
<span>{pipeline._count.routingRules} rules</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@@ -1,431 +0,0 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
ArrowLeft,
Plus,
Pencil,
Trash2,
Copy,
Loader2,
LayoutTemplate,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
const ROUND_TYPES = [
{ value: 'EVALUATION', label: 'Evaluation' },
{ value: 'FILTERING', label: 'Filtering' },
{ value: 'LIVE_EVENT', label: 'Live Event' },
]
interface TemplateFormData {
name: string
description: string
roundType: string
programId: string
criteriaJson: string
settingsJson: string
}
const defaultForm: TemplateFormData = {
name: '',
description: '',
roundType: 'EVALUATION',
programId: '',
criteriaJson: '[]',
settingsJson: '{}',
}
export default function RoundTemplatesPage() {
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
const utils = trpc.useUtils()
const { data: templates, isLoading } = trpc.roundTemplate.list.useQuery()
const { data: programs } = trpc.program.list.useQuery()
const createMutation = trpc.roundTemplate.create.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
toast.success('Template created')
closeDialog()
},
onError: (e) => toast.error(e.message),
})
const updateMutation = trpc.roundTemplate.update.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
toast.success('Template updated')
closeDialog()
},
onError: (e) => toast.error(e.message),
})
const deleteMutation = trpc.roundTemplate.delete.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
toast.success('Template deleted')
setDeleteId(null)
},
onError: (e) => toast.error(e.message),
})
const closeDialog = () => {
setDialogOpen(false)
setEditingId(null)
setFormData(defaultForm)
}
const openEdit = (template: Record<string, unknown>) => {
setEditingId(String(template.id))
setFormData({
name: String(template.name || ''),
description: String(template.description || ''),
roundType: String(template.roundType || 'EVALUATION'),
programId: String(template.programId || ''),
criteriaJson: JSON.stringify(template.criteriaJson || [], null, 2),
settingsJson: JSON.stringify(template.settingsJson || {}, null, 2),
})
setDialogOpen(true)
}
const handleSubmit = () => {
let criteriaJson: unknown
let settingsJson: unknown
try {
criteriaJson = JSON.parse(formData.criteriaJson)
} catch {
toast.error('Invalid criteria JSON')
return
}
try {
settingsJson = JSON.parse(formData.settingsJson)
} catch {
toast.error('Invalid settings JSON')
return
}
const payload = {
name: formData.name,
description: formData.description || undefined,
roundType: formData.roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
programId: formData.programId || undefined,
criteriaJson,
settingsJson,
}
if (editingId) {
updateMutation.mutate({ id: editingId, ...payload })
} else {
createMutation.mutate(payload)
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Settings
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Round Templates</h1>
<p className="text-muted-foreground">
Create reusable templates for round configuration
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogTrigger asChild>
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
<Plus className="mr-2 h-4 w-4" />
Create Template
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
<DialogDescription>
{editingId
? 'Update the template configuration.'
: 'Define a reusable round template.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Template Name</Label>
<Input
placeholder="e.g., Standard Evaluation Round"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Template description..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
/>
</div>
<div className="space-y-2">
<Label>Round Type</Label>
<Select
value={formData.roundType}
onValueChange={(v) => setFormData({ ...formData, roundType: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Program (optional)</Label>
<Select
value={formData.programId}
onValueChange={(v) => setFormData({ ...formData, programId: v === '__none__' ? '' : v })}
>
<SelectTrigger>
<SelectValue placeholder="Global (all programs)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Global (all programs)</SelectItem>
{(programs as Array<{ id: string; name: string }> | undefined)?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Criteria (JSON)</Label>
<Textarea
value={formData.criteriaJson}
onChange={(e) => setFormData({ ...formData, criteriaJson: e.target.value })}
rows={5}
className="font-mono text-sm"
placeholder='[{"name":"Innovation","maxScore":10,"weight":1}]'
/>
</div>
<div className="space-y-2">
<Label>Settings (JSON)</Label>
<Textarea
value={formData.settingsJson}
onChange={(e) => setFormData({ ...formData, settingsJson: e.target.value })}
rows={3}
className="font-mono text-sm"
placeholder='{"requiredReviews":3}'
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!formData.name || isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Templates list */}
{isLoading ? (
<TemplatesSkeleton />
) : templates && (templates as unknown[]).length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden md:table-cell">Round Type</TableHead>
<TableHead className="hidden md:table-cell">Program</TableHead>
<TableHead className="hidden lg:table-cell">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(templates as Array<Record<string, unknown>>).map((template) => (
<TableRow key={String(template.id)}>
<TableCell>
<div>
<p className="font-medium">{String(template.name)}</p>
{!!template.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{String(template.description)}
</p>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant="secondary">
{String(template.roundType || 'EVALUATION')}
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">
{template.program
? String((template.program as Record<string, unknown>).name)
: 'Global'}
</TableCell>
<TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
{template.createdAt ? formatDate(template.createdAt as string | Date) : ''}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(template)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteId(String(template.id))}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No templates yet</p>
<p className="text-sm text-muted-foreground">
Create a template to reuse round configurations across programs.
</p>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this template? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function TemplatesSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-16 ml-auto" />
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -82,7 +82,7 @@ export default function ApplicantDocumentsPage() {
)
}
const { project, openRounds } = data
const { project, openStages } = data
const isDraft = !project.submittedAt
return (
@@ -98,23 +98,23 @@ export default function ApplicantDocumentsPage() {
</p>
</div>
{/* Per-round upload sections */}
{openRounds.length > 0 && (
{/* Per-stage upload sections */}
{openStages.length > 0 && (
<div className="space-y-6">
{openRounds.map((round) => {
{openStages.map((stage) => {
const now = new Date()
const isLate = round.votingStartAt && now > new Date(round.votingStartAt)
const hasDeadline = !!round.submissionDeadline
const deadlinePassed = hasDeadline && now > new Date(round.submissionDeadline!)
const hasDeadline = !!stage.windowCloseAt
const deadlinePassed = hasDeadline && now > new Date(stage.windowCloseAt!)
const isLate = deadlinePassed
return (
<Card key={round.id}>
<Card key={stage.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{round.name}</CardTitle>
<CardTitle className="text-lg">{stage.name}</CardTitle>
<CardDescription>
Upload documents for this round
Upload documents for this stage
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -127,7 +127,7 @@ export default function ApplicantDocumentsPage() {
{hasDeadline && !deadlinePassed && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Due {new Date(round.submissionDeadline!).toLocaleDateString()}
Due {new Date(stage.windowCloseAt!).toLocaleDateString()}
</Badge>
)}
</div>
@@ -136,7 +136,7 @@ export default function ApplicantDocumentsPage() {
<CardContent>
<RequirementUploadList
projectId={project.id}
roundId={round.id}
stageId={stage.id}
disabled={false}
/>
</CardContent>
@@ -146,27 +146,6 @@ export default function ApplicantDocumentsPage() {
</div>
)}
{/* Original round upload (if not already in openRounds) */}
{project.roundId && !openRounds.some((r) => r.id === project.roundId) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{project.round?.name || 'Submission Documents'}
</CardTitle>
<CardDescription>
Documents uploaded with your original application
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={project.id}
roundId={project.roundId}
disabled={!isDraft}
/>
</CardContent>
</Card>
)}
{/* Uploaded files list */}
<Card>
<CardHeader>
@@ -184,7 +163,7 @@ export default function ApplicantDocumentsPage() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
return (
<div
@@ -218,13 +197,13 @@ export default function ApplicantDocumentsPage() {
</CardContent>
</Card>
{/* No open rounds message */}
{openRounds.length === 0 && !project.roundId && (
{/* No open stages message */}
{openStages.length === 0 && project.files.length === 0 && (
<Card className="bg-muted/50">
<CardContent className="p-6 text-center">
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground">
No rounds are currently open for document submissions.
No stages are currently open for document submissions.
</p>
</CardContent>
</Card>

View File

@@ -98,10 +98,10 @@ export default function ApplicantDashboardPage() {
)
}
const { project, timeline, currentStatus, openRounds } = data
const { project, timeline, currentStatus, openStages } = data
const isDraft = !project.submittedAt
const programYear = project.round?.program?.year
const roundName = project.round?.name
const programYear = project.program?.year
const programName = project.program?.name
return (
<div className="space-y-6">
@@ -117,7 +117,7 @@ export default function ApplicantDashboardPage() {
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div>
</div>
@@ -221,7 +221,7 @@ export default function ApplicantDashboardPage() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
{openStages.length > 0 ? `${openStages.length} stage(s) open` : 'View uploads'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
@@ -347,10 +347,10 @@ export default function ApplicantDashboardPage() {
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
{project.round?.submissionDeadline && (
{openStages.length > 0 && openStages[0].windowCloseAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{new Date(project.round.submissionDeadline).toLocaleDateString()}</span>
<span>{new Date(openStages[0].windowCloseAt).toLocaleDateString()}</span>
</div>
)}
</CardContent>

View File

@@ -0,0 +1,167 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
AlertCircle,
Clock,
FileText,
Upload,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
export default function StageDocumentsPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const { data: requirements, isLoading: reqLoading } =
trpc.stage.getRequirements.useQuery(
{ stageId, projectId },
{ enabled: !!projectId }
)
const isWindowOpen = requirements?.windowStatus?.isOpen ?? false
const isLate = requirements?.windowStatus?.isLate ?? false
const closeAt = requirements?.windowStatus?.closesAt
? new Date(requirements.windowStatus.closesAt)
: null
if (reqLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/applicant/pipeline" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Documents</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Upload required documents for this stage
</p>
</div>
<StageWindowBadge
windowOpenAt={requirements?.deadlineInfo?.windowOpenAt}
windowCloseAt={requirements?.deadlineInfo?.windowCloseAt}
/>
</div>
{/* Deadline info */}
{closeAt && isWindowOpen && (
<Card>
<CardContent className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Submission deadline</span>
</div>
<CountdownTimer deadline={closeAt} label="Closes in" />
</CardContent>
</Card>
)}
{/* Late submission warning */}
{isLate && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
The submission window has passed. Late submissions may be accepted at the discretion of the administrators.
</p>
</CardContent>
</Card>
)}
{/* Window closed */}
{!isWindowOpen && !isLate && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
The document submission window for this stage is closed.
</p>
</CardContent>
</Card>
)}
{/* File requirements */}
{requirements?.fileRequirements && requirements.fileRequirements.length > 0 ? (
<RequirementUploadList
projectId={projectId}
stageId={stageId}
disabled={!isWindowOpen && !isLate}
/>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No document requirements</p>
<p className="text-sm text-muted-foreground mt-1">
There are no specific document requirements for this stage.
</p>
</CardContent>
</Card>
)}
{/* Uploaded files summary */}
{requirements?.uploadedFiles && requirements.uploadedFiles.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{requirements.uploadedFiles.map((file: { id: string; fileName: string; size: number; createdAt: string | Date }) => (
<div key={file.id} className="flex items-center gap-3 text-sm">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{file.fileName}</span>
<span className="text-xs text-muted-foreground shrink-0">
{(file.size / (1024 * 1024)).toFixed(1)}MB
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
CheckCircle2,
XCircle,
Clock,
ArrowRight,
AlertCircle,
} from 'lucide-react'
const stateLabels: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Not Selected',
COMPLETED: 'Completed',
WAITING: 'Waiting',
}
const stateColors: Record<string, string> = {
PASSED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
COMPLETED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
REJECTED: 'text-destructive bg-destructive/5 border-destructive/30',
IN_PROGRESS: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/30 dark:border-blue-900',
PENDING: 'text-muted-foreground bg-muted border-muted',
WAITING: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/30 dark:border-amber-900',
}
export default function StageStatusPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const programId = project?.program?.id ?? ''
const { data: pipelineView } =
trpc.pipeline.getApplicantView.useQuery(
{ programId, projectId },
{ enabled: !!programId && !!projectId }
)
const { data: timeline, isLoading } =
trpc.stage.getApplicantTimeline.useQuery(
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
{ enabled: !!projectId && !!pipelineView?.pipelineId }
)
// Find the specific stage
const stageData = timeline?.find((item) => item.stageId === stageId)
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/applicant/pipeline" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
</div>
{stageData ? (
<>
{/* Stage state card */}
<Card className={`border ${stateColors[stageData.state] ?? ''}`}>
<CardContent className="py-8 text-center">
<div className="flex flex-col items-center gap-3">
{stageData.state === 'PASSED' || stageData.state === 'COMPLETED' ? (
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
) : stageData.state === 'REJECTED' ? (
<XCircle className="h-12 w-12 text-destructive" />
) : (
<Clock className="h-12 w-12 text-blue-600" />
)}
<div>
<h2 className="text-xl font-bold">{stageData.stageName}</h2>
<Badge className="mt-2 text-sm">
{stateLabels[stageData.state] ?? stageData.state}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Decision details */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Stage Type</p>
<p className="text-sm font-medium capitalize">
{stageData.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
</div>
{stageData.enteredAt && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Entered</p>
<p className="text-sm font-medium">
{new Date(stageData.enteredAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
)}
{stageData.exitedAt && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Exited</p>
<p className="text-sm font-medium">
{new Date(stageData.exitedAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Next steps */}
{(stageData.state === 'IN_PROGRESS' || stageData.state === 'PENDING') && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ArrowRight className="h-4 w-4" />
Next Steps
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{stageData.stageType === 'INTAKE' && (
<>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Make sure all required documents are uploaded before the deadline.
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
You will be notified once reviewers complete their evaluation.
</li>
</>
)}
{stageData.stageType === 'EVALUATION' && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Your project is being reviewed by jury members. Results will be shared once evaluation is complete.
</li>
)}
{stageData.stageType === 'LIVE_FINAL' && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Prepare for the live presentation. Check your email for schedule and logistics details.
</li>
)}
{!['INTAKE', 'EVALUATION', 'LIVE_FINAL'].includes(stageData.stageType) && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Your project is progressing through this stage. Updates will appear here.
</li>
)}
</ul>
</CardContent>
</Card>
)}
{/* Full timeline */}
{timeline && timeline.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Full Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-0">
{timeline.map((item, index) => (
<div key={item.stageId} className="relative flex gap-4">
{index < timeline.length - 1 && (
<div className={`absolute left-[11px] top-[24px] h-full w-0.5 ${
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: 'bg-muted'
}`} />
)}
<div className="relative z-10 flex h-6 w-6 shrink-0 items-center justify-center">
<div className={`h-3 w-3 rounded-full ${
item.stageId === stageId
? 'ring-2 ring-brand-blue ring-offset-2 bg-brand-blue'
: item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: item.state === 'REJECTED'
? 'bg-destructive'
: item.isCurrent
? 'bg-blue-500'
: 'bg-muted-foreground/30'
}`} />
</div>
<div className="flex-1 pb-6">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium ${
item.stageId === stageId ? 'text-brand-blue dark:text-brand-teal' : ''
}`}>
{item.stageName}
</p>
<Badge
variant={
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'success'
: item.state === 'REJECTED'
? 'destructive'
: 'secondary'
}
className="text-xs"
>
{stateLabels[item.state] ?? item.state}
</Badge>
</div>
{item.enteredAt && (
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(item.enteredAt).toLocaleDateString()}
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Stage not found</p>
<p className="text-sm text-muted-foreground mt-1">
Your project has not entered this stage yet.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Upload,
Users,
MessageSquare,
ArrowRight,
FileText,
Clock,
CheckCircle2,
XCircle,
Layers,
} from 'lucide-react'
import { StageTimeline } from '@/components/shared/stage-timeline'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
const stateLabels: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Not Selected',
COMPLETED: 'Completed',
WAITING: 'Waiting',
}
const stateVariants: Record<string, 'success' | 'destructive' | 'warning' | 'secondary' | 'info'> = {
PENDING: 'secondary',
IN_PROGRESS: 'info',
PASSED: 'success',
REJECTED: 'destructive',
COMPLETED: 'success',
WAITING: 'warning',
}
export default function ApplicantPipelinePage() {
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const programId = project?.program?.id ?? ''
const { data: pipelineView, isLoading: pipelineLoading } =
trpc.pipeline.getApplicantView.useQuery(
{ programId, projectId },
{ enabled: !!programId && !!projectId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.stage.getApplicantTimeline.useQuery(
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
{ enabled: !!projectId && !!pipelineView?.pipelineId }
)
const isLoading = pipelineLoading || timelineLoading
if (!project && !isLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No project found</p>
<p className="text-sm text-muted-foreground mt-1">
You don&apos;t have a project in the current edition yet.
</p>
</CardContent>
</Card>
</div>
)
}
if (isLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
// Build timeline items for StageTimeline
const timelineItems = timeline?.map((item) => ({
id: item.stageId,
name: item.stageName,
stageType: item.stageType,
isCurrent: item.isCurrent,
state: item.state,
enteredAt: item.enteredAt,
})) ?? []
// Find current stage
const currentStage = timeline?.find((item) => item.isCurrent)
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
{/* Project title + status */}
<Card>
<CardContent className="py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{project?.title}</h2>
<p className="text-sm text-muted-foreground">{(project as { teamName?: string } | undefined)?.teamName}</p>
</div>
{currentStage && (
<Badge variant={stateVariants[currentStage.state] ?? 'secondary'}>
{stateLabels[currentStage.state] ?? currentStage.state}
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Stage Timeline visualization */}
{timelineItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Pipeline Progress</CardTitle>
</CardHeader>
<CardContent>
<StageTimeline stages={timelineItems} orientation="horizontal" />
</CardContent>
</Card>
)}
{/* Current stage details */}
{currentStage && (
<Card className="border-brand-blue/30 dark:border-brand-teal/30">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Current Stage</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<h3 className="font-semibold">{currentStage.stageName}</h3>
<p className="text-sm text-muted-foreground capitalize">
{currentStage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
</div>
{currentStage.enteredAt && (
<p className="text-xs text-muted-foreground">
Entered {new Date(currentStage.enteredAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
)}
</CardContent>
</Card>
)}
{/* Decision history */}
{timeline && timeline.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{timeline.map((item) => (
<div
key={item.stageId}
className="flex items-center justify-between py-2 border-b last:border-0"
>
<div className="flex items-center gap-3">
<div className={cn(
'h-2 w-2 rounded-full',
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: item.state === 'REJECTED'
? 'bg-destructive'
: item.isCurrent
? 'bg-blue-500'
: 'bg-muted-foreground'
)} />
<div>
<p className="text-sm font-medium">{item.stageName}</p>
{item.enteredAt && (
<p className="text-xs text-muted-foreground">
{new Date(item.enteredAt).toLocaleDateString()}
</p>
)}
</div>
</div>
<Badge variant={stateVariants[item.state] ?? 'secondary'} className="text-xs">
{stateLabels[item.state] ?? item.state}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="grid gap-3 sm:grid-cols-3">
{currentStage && (
<Link
href={`/applicant/pipeline/${currentStage.stageId}/documents` as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-950/40">
<Upload className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="font-semibold text-sm">Upload Documents</p>
<p className="text-xs text-muted-foreground">Submit required files</p>
</div>
</Link>
)}
<Link
href={"/applicant/team" as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 dark:bg-teal-950/40">
<Users className="h-4 w-4 text-brand-teal" />
</div>
<div>
<p className="font-semibold text-sm">View Team</p>
<p className="text-xs text-muted-foreground">Team members</p>
</div>
</Link>
<Link
href={"/applicant/messages" as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="font-semibold text-sm">Contact Mentor</p>
<p className="text-xs text-muted-foreground">Send a message</p>
</div>
</Link>
</div>
</div>
)
}

View File

@@ -26,7 +26,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
Dialog,
DialogContent,
@@ -402,23 +401,7 @@ export default function ApplicantTeamPage() {
</CardContent>
</Card>
{/* Team Documents */}
{teamData?.roundId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Team Documents</CardTitle>
<CardDescription>
Upload required documents for your project. Any team member can upload files.
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={projectId}
roundId={teamData.roundId}
/>
</CardContent>
</Card>
)}
{/* Team Documents - visible via applicant documents page */}
{/* Info Card */}
<Card className="bg-muted/50">

View File

@@ -1,480 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
CheckCircle2,
Clock,
FileText,
ExternalLink,
AlertCircle,
} from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import { cn, formatDate, truncate } from '@/lib/utils'
function getCriteriaProgress(evaluation: {
criterionScoresJson: unknown
form: { criteriaJson: unknown }
} | null): { completed: number; total: number } | null {
if (!evaluation || !evaluation.form?.criteriaJson) return null
const criteria = evaluation.form.criteriaJson as Array<{ id: string; type?: string }>
// Only count scoreable criteria (exclude section_header)
const scoreable = criteria.filter((c) => c.type !== 'section_header')
const total = scoreable.length
if (total === 0) return null
const scores = (evaluation.criterionScoresJson || {}) as Record<string, unknown>
const completed = scoreable.filter((c) => scores[c.id] != null && scores[c.id] !== '').length
return { completed, total }
}
function getDeadlineUrgency(deadline: Date | null): { label: string; className: string } | null {
if (!deadline) return null
const now = new Date()
const diff = deadline.getTime() - now.getTime()
const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (daysLeft < 0) return { label: 'Overdue', className: 'text-muted-foreground' }
if (daysLeft <= 2) return { label: `${daysLeft}d left`, className: 'text-red-600 font-semibold' }
if (daysLeft <= 7) return { label: `${daysLeft}d left`, className: 'text-amber-600 font-medium' }
return { label: `${daysLeft}d left`, className: 'text-muted-foreground' }
}
async function AssignmentsContent({
roundId,
}: {
roundId?: string
}) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
// Get assignments, optionally filtered by round
const assignments = await prisma.assignment.findMany({
where: {
userId,
...(roundId ? { roundId } : {}),
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
files: {
select: {
id: true,
fileType: true,
},
},
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
year: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
updatedAt: true,
criterionScoresJson: true,
form: {
select: {
criteriaJson: true,
},
},
},
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' },
],
})
if (assignments.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments found</p>
<p className="text-sm text-muted-foreground">
{roundId
? 'No projects assigned to you for this round'
: "You don't have any project assignments yet"}
</p>
</CardContent>
</Card>
)
}
const now = new Date()
const completedCount = assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length
const inProgressCount = assignments.filter(a => a.evaluation?.status === 'DRAFT').length
const pendingCount = assignments.filter(a => !a.evaluation).length
const overallProgress = assignments.length > 0 ? Math.round((completedCount / assignments.length) * 100) : 0
return (
<div className="space-y-6">
{/* Progress Summary */}
<Card className="bg-muted/30">
<CardContent className="py-4">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
<div>
<p className="text-2xl font-bold">{inProgressCount}</p>
<p className="text-xs text-muted-foreground">In Progress</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
<div className="ml-auto">
<Progress value={overallProgress} className="h-2 w-32" gradient />
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
</div>
</div>
</CardContent>
</Card>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Deadline</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<TableRow key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm">
<TableCell>
<Link
href={`/jury/projects/${assignment.project.id}`}
className="block group"
>
<p className="font-medium group-hover:text-primary group-hover:underline transition-colors">
{truncate(assignment.project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
</Link>
</TableCell>
<TableCell>
<div>
<p>{assignment.round.name}</p>
<p className="text-sm text-muted-foreground">
{assignment.round.program.year} Edition
</p>
</div>
</TableCell>
<TableCell>
{assignment.round.votingEndAt ? (
<div>
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
) : (
<span className="text-muted-foreground">No deadline</span>
)}
</TableCell>
<TableCell>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
) : isDraft ? (
<div className="space-y-1.5">
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
{(() => {
const progress = getCriteriaProgress(assignment.evaluation)
if (!progress) return null
const pct = Math.round((progress.completed / progress.total) * 100)
return (
<div className="flex items-center gap-2">
<Progress value={pct} className="h-1.5 w-16" />
<span className="text-xs text-muted-foreground">{progress.completed}/{progress.total}</span>
</div>
)
})()}
</div>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isCompleted ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${assignment.project.id}`}>
View
</Link>
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<Card key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Link
href={`/jury/projects/${assignment.project.id}`}
className="space-y-1 group"
>
<CardTitle className="text-base group-hover:text-primary group-hover:underline transition-colors">
{assignment.project.title}
</CardTitle>
<CardDescription>
{assignment.project.teamName}
</CardDescription>
</Link>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Done
</Badge>
) : isDraft ? (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
Draft
</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{assignment.round.name}</span>
</div>
{assignment.round.votingEndAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<div className="text-right">
<span>{formatDate(assignment.round.votingEndAt)}</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
</div>
)}
{isDraft && (() => {
const progress = getCriteriaProgress(assignment.evaluation)
if (!progress) return null
const pct = Math.round((progress.completed / progress.total) * 100)
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="text-xs">{progress.completed}/{progress.total} criteria</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)
})()}
<div className="pt-2">
{isCompleted ? (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View Evaluation
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" className="w-full" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link href={`/jury/projects/${assignment.project.id}`}>
View Project
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function AssignmentsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default async function JuryAssignmentsPage({
searchParams,
}: {
searchParams: Promise<{ round?: string }>
}) {
const params = await searchParams
const roundId = params.round
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Assignments</h1>
<p className="text-muted-foreground">
Projects assigned to you for evaluation
</p>
</div>
{/* Content */}
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentsContent roundId={roundId} />
</Suspense>
</div>
)
}

View File

@@ -1,719 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertCircle,
ArrowLeft,
GitCompare,
MapPin,
Users,
FileText,
CheckCircle2,
Clock,
XCircle,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Criterion = {
id: string
label: string
description?: string
scale?: string
weight?: number
type?: string
}
type ComparisonItem = {
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
assignmentId: string
}
type ComparisonData = {
items: ComparisonItem[]
criteria: Criterion[] | null
scales: Record<string, { min: number; max: number }> | null
}
function getScoreColor(score: number, max: number): string {
const ratio = score / max
if (ratio >= 0.8) return 'bg-green-500'
if (ratio >= 0.6) return 'bg-emerald-400'
if (ratio >= 0.4) return 'bg-amber-400'
if (ratio >= 0.2) return 'bg-orange-400'
return 'bg-red-400'
}
function getScoreTextColor(score: number, max: number): string {
const ratio = score / max
if (ratio >= 0.8) return 'text-green-600 dark:text-green-400'
if (ratio >= 0.6) return 'text-emerald-600 dark:text-emerald-400'
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400'
return 'text-red-600 dark:text-red-400'
}
function ScoreRing({ score, max }: { score: number; max: number }) {
const pct = Math.round((score / max) * 100)
const circumference = 2 * Math.PI * 36
const offset = circumference - (pct / 100) * circumference
return (
<div className="relative inline-flex items-center justify-center">
<svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
<circle
cx="40" cy="40" r="36"
className="stroke-muted"
strokeWidth="6" fill="none"
/>
<circle
cx="40" cy="40" r="36"
className={cn(
'transition-all duration-500',
pct >= 80 ? 'stroke-green-500' :
pct >= 60 ? 'stroke-emerald-400' :
pct >= 40 ? 'stroke-amber-400' : 'stroke-red-400'
)}
strokeWidth="6" fill="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-lg font-bold tabular-nums">{score}</span>
<span className="text-[10px] text-muted-foreground">/{max}</span>
</div>
</div>
)
}
function ScoreBar({ score, max, isHighest }: { score: number; max: number; isHighest: boolean }) {
const pct = Math.round((score / max) * 100)
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', getScoreColor(score, max))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn(
'text-sm font-medium tabular-nums w-8 text-right',
isHighest && 'font-bold',
getScoreTextColor(score, max)
)}>
{score}
</span>
</div>
)
}
export default function CompareProjectsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [comparing, setComparing] = useState(false)
const { data: assignments, isLoading: loadingAssignments } =
trpc.assignment.myAssignments.useQuery({})
const rounds = useMemo(() => {
if (!assignments) return []
const roundMap = new Map<string, { id: string; name: string }>()
for (const a of assignments as Array<{ round: { id: string; name: string } }>) {
if (a.round && !roundMap.has(a.round.id)) {
roundMap.set(a.round.id, { id: a.round.id, name: String(a.round.name) })
}
}
return Array.from(roundMap.values())
}, [assignments])
const activeRoundId = selectedRoundId || (rounds.length > 0 ? rounds[0].id : '')
const roundProjects = useMemo(() => {
if (!assignments || !activeRoundId) return []
return (assignments as Array<{
project: Record<string, unknown>
round: { id: string; name: string }
evaluation?: Record<string, unknown>
}>)
.filter((a) => a.round.id === activeRoundId)
.map((a) => ({
...a.project,
roundName: a.round.name,
evaluation: a.evaluation,
}))
}, [assignments, activeRoundId])
const { data: comparisonData, isLoading: loadingComparison } =
trpc.evaluation.getMultipleForComparison.useQuery(
{ projectIds: selectedIds, roundId: activeRoundId },
{ enabled: comparing && selectedIds.length >= 2 && !!activeRoundId }
)
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
if (prev.includes(projectId)) {
return prev.filter((id) => id !== projectId)
}
if (prev.length >= 3) return prev
return [...prev, projectId]
})
setComparing(false)
}
const handleCompare = () => {
if (selectedIds.length >= 2) setComparing(true)
}
const handleReset = () => {
setComparing(false)
setSelectedIds([])
}
const handleRoundChange = (roundId: string) => {
setSelectedRoundId(roundId)
setSelectedIds([])
setComparing(false)
}
if (loadingAssignments) return <CompareSkeleton />
const data = comparisonData as ComparisonData | undefined
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Compare Projects</h1>
<p className="text-muted-foreground">
Select 2-3 projects from the same round to compare side by side
</p>
</div>
<div className="flex items-center gap-2">
{comparing && (
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
)}
{!comparing && (
<Button onClick={handleCompare} disabled={selectedIds.length < 2}>
<GitCompare className="mr-2 h-4 w-4" />
Compare ({selectedIds.length})
</Button>
)}
</div>
</div>
{/* Round selector */}
{rounds.length > 1 && !comparing && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">Round:</span>
<Select value={activeRoundId} onValueChange={handleRoundChange}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Project selector */}
{!comparing && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roundProjects.map((project: Record<string, unknown>) => {
const projectId = project.id as string
const isSelected = selectedIds.includes(projectId)
const isDisabled = !isSelected && selectedIds.length >= 3
return (
<Card
key={projectId}
className={cn(
'cursor-pointer transition-all',
isSelected
? 'border-primary ring-2 ring-primary/20'
: isDisabled
? 'opacity-50'
: 'hover:border-primary/50'
)}
onClick={() => !isDisabled && toggleProject(projectId)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleProject(projectId)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{String(project.title || 'Untitled')}
</p>
<p className="text-sm text-muted-foreground truncate">
{String(project.teamName || '')}
</p>
</div>
{project.evaluation ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{roundProjects.length === 0 && !loadingAssignments && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitCompare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects assigned</p>
<p className="text-sm text-muted-foreground">
You need at least 2 assigned projects to use the comparison feature.
</p>
</CardContent>
</Card>
)}
{/* Comparison view */}
{comparing && loadingComparison && <CompareSkeleton />}
{comparing && data && (
<div className="space-y-6">
{/* Project summary cards */}
<div
className={cn(
'grid gap-4 grid-cols-1',
data.items.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{data.items.map((item) => (
<ComparisonCard
key={String(item.project.id)}
project={item.project}
evaluation={item.evaluation}
criteria={data.criteria}
scales={data.scales}
/>
))}
</div>
{/* Side-by-side criterion comparison table */}
{data.criteria && data.criteria.filter((c) => c.type !== 'section_header').length > 0 && (
<CriterionComparisonTable
items={data.items}
criteria={data.criteria}
scales={data.scales}
/>
)}
{/* Divergence Summary */}
{data.criteria && (() => {
const scCriteria = data.criteria.filter((c) => c.type !== 'section_header')
const getMaxForCriterion = (criterion: Criterion) => {
if (criterion.scale && data.scales && data.scales[criterion.scale]) return data.scales[criterion.scale].max
return 10
}
const getScoreForItem = (item: ComparisonItem, criterionId: string): number | null => {
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
if (!scores) return null
const val = scores[criterionId]
if (val == null) return null
const num = Number(val)
return isNaN(num) ? null : num
}
const divergentCount = scCriteria.filter(criterion => {
const scores = data.items.map(item => getScoreForItem(item, criterion.id)).filter((s): s is number => s !== null)
if (scores.length < 2) return false
const max = Math.max(...scores)
const min = Math.min(...scores)
const range = getMaxForCriterion(criterion)
return range > 0 && (max - min) / range >= 0.4
}).length
if (divergentCount === 0) return null
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-amber-600" />
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{divergentCount} criterion{divergentCount > 1 ? 'a' : ''} with significant score divergence ({'>'}40% range difference)
</p>
</div>
</div>
)
})()}
</div>
)}
</div>
)
}
function ComparisonCard({
project,
evaluation,
criteria,
scales,
}: {
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
criteria: Criterion[] | null
scales: Record<string, { min: number; max: number }> | null
}) {
const tags = Array.isArray(project.tags) ? project.tags : []
const files = Array.isArray(project.files) ? project.files : []
const scores = (evaluation?.criterionScoresJson || evaluation?.scores) as Record<string, unknown> | undefined
const globalScore = evaluation?.globalScore as number | null | undefined
const binaryDecision = evaluation?.binaryDecision as boolean | null | undefined
// Build a criterion label lookup
const criterionLabels = useMemo(() => {
const map: Record<string, { label: string; scale?: string }> = {}
if (criteria) {
for (const c of criteria) {
map[c.id] = { label: c.label, scale: c.scale }
}
}
return map
}, [criteria])
const getMax = (criterionId: string) => {
const scale = criterionLabels[criterionId]?.scale
if (scale && scales && scales[scale]) return scales[scale].max
return 10
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-lg">{String(project.title || 'Untitled')}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1">
<Users className="h-3 w-3" />
{String(project.teamName || 'N/A')}
</CardDescription>
</div>
{/* Global score ring */}
{evaluation && globalScore != null && (
<ScoreRing score={globalScore} max={10} />
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Binary decision */}
{evaluation && binaryDecision != null && (
<div className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium',
binaryDecision
? 'bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
)}>
{binaryDecision ? (
<><ThumbsUp className="h-4 w-4" /> Recommended to advance</>
) : (
<><ThumbsDown className="h-4 w-4" /> Not recommended</>
)}
</div>
)}
{/* Country */}
{!!project.country && (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
{String(project.country)}
</div>
)}
{/* Description */}
{!!project.description && (
<p className="text-sm text-muted-foreground line-clamp-3">{String(project.description)}</p>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag: unknown, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">
{String(tag)}
</Badge>
))}
</div>
)}
{/* Files */}
{files.length > 0 && (
<div className="text-sm text-muted-foreground flex items-center gap-1">
<FileText className="h-3 w-3" />
{files.length} file{files.length > 1 ? 's' : ''} attached
</div>
)}
<Separator />
{/* Evaluation scores with bars */}
{evaluation && scores && typeof scores === 'object' ? (
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Criterion Scores</p>
{Object.entries(scores).map(([criterionId, score]) => {
const numScore = Number(score)
if (isNaN(numScore)) return null
const label = criterionLabels[criterionId]?.label || criterionId
const max = getMax(criterionId)
return (
<div key={criterionId} className="space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate">{label}</span>
</div>
<ScoreBar score={numScore} max={max} isHighest={false} />
</div>
)
})}
</div>
) : evaluation ? (
<Badge variant="outline">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : (
<Badge variant="outline">
<Clock className="mr-1 h-3 w-3" />
Not yet evaluated
</Badge>
)}
</CardContent>
</Card>
)
}
function CriterionComparisonTable({
items,
criteria,
scales,
}: {
items: ComparisonItem[]
criteria: Criterion[]
scales: Record<string, { min: number; max: number }> | null
}) {
const scoreableCriteria = criteria.filter((c) => c.type !== 'section_header')
const getMax = (criterion: Criterion) => {
if (criterion.scale && scales && scales[criterion.scale]) return scales[criterion.scale].max
return 10
}
// Build score matrix
const getScore = (item: ComparisonItem, criterionId: string): number | null => {
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
if (!scores) return null
const val = scores[criterionId]
if (val == null) return null
const num = Number(val)
return isNaN(num) ? null : num
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criterion-by-Criterion Comparison</CardTitle>
<CardDescription>
Scores compared side by side. Highest score per criterion is highlighted.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Criterion</TableHead>
{items.map((item) => (
<TableHead key={item.assignmentId} className="text-center min-w-[150px]">
{String((item.project as Record<string, unknown>).title || 'Untitled')}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{scoreableCriteria.map((criterion) => {
const max = getMax(criterion)
const itemScores = items.map((item) => getScore(item, criterion.id))
const validScores = itemScores.filter((s): s is number => s !== null)
const highestScore = validScores.length > 0 ? Math.max(...validScores) : null
const minScore = validScores.length > 0 ? Math.min(...validScores) : null
const divergence = highestScore !== null && minScore !== null ? highestScore - minScore : 0
const maxPossibleDivergence = max
const isDivergent = validScores.length >= 2 && maxPossibleDivergence > 0 && (divergence / maxPossibleDivergence) >= 0.4
return (
<TableRow key={criterion.id} className={cn(isDivergent && 'bg-amber-50 dark:bg-amber-950/20')}>
<TableCell className="font-medium">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm">{criterion.label}</span>
{criterion.weight && criterion.weight > 1 && (
<span className="text-xs text-muted-foreground ml-1">
(x{criterion.weight})
</span>
)}
{isDivergent && <Badge variant="outline" className="text-[10px] ml-1.5 text-amber-600 border-amber-300">Divergent</Badge>}
</div>
</TableCell>
{items.map((item, idx) => {
const score = itemScores[idx]
const isHighest = score !== null && score === highestScore && validScores.filter((s) => s === highestScore).length < validScores.length
return (
<TableCell key={item.assignmentId} className="text-center">
{score !== null ? (
<div className="flex flex-col items-center gap-1">
<span className={cn(
'text-sm font-medium tabular-nums',
isHighest && 'text-green-600 dark:text-green-400 font-bold',
getScoreTextColor(score, max)
)}>
{score}/{max}
</span>
<div className="w-full max-w-[80px] h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getScoreColor(score, max))}
style={{ width: `${(score / max) * 100}%` }}
/>
</div>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
)
})}
{/* Global score row */}
<TableRow className="border-t-2 font-semibold">
<TableCell>Overall Score</TableCell>
{items.map((item) => {
const globalScore = item.evaluation?.globalScore as number | null | undefined
return (
<TableCell key={item.assignmentId} className="text-center">
{globalScore != null ? (
<span className={cn(
'text-base font-bold tabular-nums',
getScoreTextColor(globalScore, 10)
)}>
{globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
{/* Binary decision row */}
<TableRow>
<TableCell>Advance Decision</TableCell>
{items.map((item) => {
const decision = item.evaluation?.binaryDecision as boolean | null | undefined
return (
<TableCell key={item.assignmentId} className="text-center">
{decision != null ? (
decision ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Yes
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" /> No
</Badge>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
function CompareSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,377 +0,0 @@
'use client'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
import type { LiveVotingCriterion } from '@/types/round-settings'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data - reduced polling since SSE handles real-time
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId },
{ refetchInterval: 10000 }
)
const votingMode = data?.session.votingMode || 'simple'
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setCriterionScores({})
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Vote mutation
const vote = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote recorded')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Set selected score from existing vote
useEffect(() => {
if (data?.userVote) {
setSelectedScore(data.userVote.score)
// Restore criterion scores if available
if (data.userVote.criterionScoresJson) {
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
}
} else {
setSelectedScore(null)
setCriterionScores({})
}
}, [data?.userVote, data?.currentProject?.id])
// Initialize criterion scores with mid-values when criteria change
useEffect(() => {
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
const initial: Record<string, number> = {}
for (const c of criteria) {
initial[c.id] = Math.ceil(c.scale / 2)
}
setCriterionScores(initial)
}
}, [votingMode, criteria, criterionScores])
const handleSimpleVote = (score: number) => {
if (!data?.currentProject) return
setSelectedScore(score)
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
})
}
const handleCriteriaVote = () => {
if (!data?.currentProject) return
// Compute a rough overall score for the `score` field
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score: computedScore,
criterionScores,
})
}
const computeWeightedScore = (): number => {
if (criteria.length === 0) return 0
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
}
if (isLoading) {
return <JuryVotingSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
const isVoting = data.session.status === 'IN_PROGRESS'
const hasVoted = !!data.userVote
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-6 w-6 text-primary" />
<CardTitle>Live Voting</CardTitle>
</div>
<CardDescription>
{data.round.program.name} - {data.round.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Voting UI - Simple mode */}
{votingMode === 'simple' && (
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleSimpleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
)}
{/* Voting UI - Criteria mode */}
{votingMode === 'criteria' && criteria.length > 0 && (
<div className="space-y-4">
<p className="text-sm font-medium text-center">Score Each Criterion</p>
{criteria.map((c) => (
<div key={c.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.label}</p>
{c.description && (
<p className="text-xs text-muted-foreground truncate">
{c.description}
</p>
)}
</div>
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
{criterionScores[c.id] || 1}/{c.scale}
</span>
</div>
<Slider
min={1}
max={c.scale}
step={1}
value={[criterionScores[c.id] || 1]}
onValueChange={([val]) => {
setCriterionScores((prev) => ({
...prev,
[c.id]: val,
}))
}}
disabled={vote.isPending || countdown === 0}
/>
</div>
))}
{/* Computed weighted score */}
<div className="flex items-center justify-between border-t pt-3">
<p className="text-sm font-medium">Weighted Score</p>
<span className="text-2xl font-bold text-primary">
{computeWeightedScore().toFixed(1)}
</span>
</div>
<Button
className="w-full"
onClick={handleCriteriaVote}
disabled={vote.isPending || countdown === 0}
>
<Send className="mr-2 h-4 w-4" />
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
)}
{/* Vote status */}
{hasVoted && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'The admin will start voting for the next project.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Mobile-friendly footer */}
<div className="flex items-center justify-center gap-2 mt-4">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-400" />
) : (
<WifiOff className="h-3 w-3 text-red-400" />
)}
<p className="text-white/60 text-sm">
MOPC Live Voting {isConnected ? '- Connected' : '- Reconnecting...'}
</p>
</div>
</div>
)
}
function JuryVotingSkeleton() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Skeleton className="h-6 w-32 mx-auto" />
<Skeleton className="h-4 w-48 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function JuryLiveVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <JuryVotingContent sessionId={sessionId} />
}

View File

@@ -60,17 +60,25 @@ async function JuryDashboardContent() {
country: true,
},
},
round: {
stage: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
windowOpenAt: true,
windowCloseAt: true,
track: {
select: {
name: true,
year: true,
pipeline: {
select: {
program: {
select: {
name: true,
year: true,
},
},
},
},
},
},
},
@@ -88,7 +96,7 @@ async function JuryDashboardContent() {
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ stage: { windowCloseAt: 'asc' } },
{ createdAt: 'asc' },
],
}),
@@ -98,7 +106,7 @@ async function JuryDashboardContent() {
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
stageId: true,
extendedUntil: true,
},
}),
@@ -118,49 +126,49 @@ async function JuryDashboardContent() {
const completionRate =
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
// Group assignments by round
const assignmentsByRound = assignments.reduce(
// Group assignments by stage
const assignmentsByStage = assignments.reduce(
(acc, assignment) => {
const roundId = assignment.round.id
if (!acc[roundId]) {
acc[roundId] = {
round: assignment.round,
const stageId = assignment.stage.id
if (!acc[stageId]) {
acc[stageId] = {
stage: assignment.stage,
assignments: [],
}
}
acc[roundId].assignments.push(assignment)
acc[stageId].assignments.push(assignment)
return acc
},
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
{} as Record<string, { stage: (typeof assignments)[0]['stage']; assignments: typeof assignments }>
)
const graceByRound = new Map<string, Date>()
const graceByStage = new Map<string, Date>()
for (const gp of gracePeriods) {
const existing = graceByRound.get(gp.roundId)
const existing = graceByStage.get(gp.stageId)
if (!existing || gp.extendedUntil > existing) {
graceByRound.set(gp.roundId, gp.extendedUntil)
graceByStage.set(gp.stageId, gp.extendedUntil)
}
}
// Active rounds (voting window open)
// Active stages (voting window open)
const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter(
({ round }) =>
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const activeStages = Object.values(assignmentsByStage).filter(
({ stage }) =>
stage.status === 'STAGE_ACTIVE' &&
stage.windowOpenAt &&
stage.windowCloseAt &&
new Date(stage.windowOpenAt) <= now &&
new Date(stage.windowCloseAt) >= now
)
// Find next unevaluated assignment in an active round
// Find next unevaluated assignment in an active stage
const nextUnevaluated = assignments.find((a) => {
const isActive =
a.round.status === 'ACTIVE' &&
a.round.votingStartAt &&
a.round.votingEndAt &&
new Date(a.round.votingStartAt) <= now &&
new Date(a.round.votingEndAt) >= now
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT'
return isActive && isIncomplete
})
@@ -168,14 +176,14 @@ async function JuryDashboardContent() {
// Recent assignments for the quick list (latest 5)
const recentAssignments = assignments.slice(0, 6)
// Get active round remaining count
// Get active stage remaining count
const activeRemaining = assignments.filter((a) => {
const isActive =
a.round.status === 'ACTIVE' &&
a.round.votingStartAt &&
a.round.votingEndAt &&
new Date(a.round.votingStartAt) <= now &&
new Date(a.round.votingEndAt) >= now
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
@@ -233,7 +241,7 @@ async function JuryDashboardContent() {
</div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
<Link
href="/jury/assignments"
href="/jury/stages"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
>
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
@@ -245,7 +253,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/compare"
href="/jury/stages"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
@@ -285,7 +293,7 @@ async function JuryDashboardContent() {
</div>
</div>
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
<Link href={`/jury/projects/${nextUnevaluated.project.id}/evaluate`}>
<Link href={`/jury/stages/${nextUnevaluated.stage.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -355,7 +363,7 @@ async function JuryDashboardContent() {
<CardTitle className="text-lg">My Assignments</CardTitle>
</div>
<Button variant="ghost" size="sm" asChild className="text-brand-teal hover:text-brand-blue">
<Link href="/jury/assignments">
<Link href="/jury/stages">
View all
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
@@ -370,11 +378,11 @@ async function JuryDashboardContent() {
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
assignment.stage.status === 'STAGE_ACTIVE' &&
assignment.stage.windowOpenAt &&
assignment.stage.windowCloseAt &&
new Date(assignment.stage.windowOpenAt) <= now &&
new Date(assignment.stage.windowCloseAt) >= now
return (
<div
@@ -386,7 +394,7 @@ async function JuryDashboardContent() {
)}
>
<Link
href={`/jury/projects/${assignment.project.id}`}
href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}
className="flex-1 min-w-0 group"
>
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
@@ -397,7 +405,7 @@ async function JuryDashboardContent() {
{assignment.project.teamName}
</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
{assignment.round.name}
{assignment.stage.name}
</Badge>
</div>
</Link>
@@ -417,19 +425,19 @@ async function JuryDashboardContent() {
)}
{isCompleted ? (
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
<Link href={`/jury/projects/${assignment.project.id}/evaluation`}>
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluation`}>
View
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluate`}>
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
<Link href={`/jury/projects/${assignment.project.id}`}>
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}>
View
</Link>
</Button>
@@ -470,7 +478,7 @@ async function JuryDashboardContent() {
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href="/jury/assignments"
href="/jury/stages"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
>
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
@@ -482,7 +490,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/compare"
href="/jury/stages"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
@@ -501,8 +509,8 @@ async function JuryDashboardContent() {
{/* Right column */}
<div className="lg:col-span-5 space-y-4">
{/* Active Rounds */}
{activeRounds.length > 0 && (
{/* Active Stages */}
{activeStages.length > 0 && (
<AnimatedCard index={8}>
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
@@ -512,28 +520,29 @@ async function JuryDashboardContent() {
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
</div>
<div>
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
<CardDescription className="mt-0.5">
Rounds currently open for evaluation
Stages currently open for evaluation
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{activeRounds.map(({ round, assignments: roundAssignments }) => {
const roundCompleted = roundAssignments.filter(
{activeStages.map(({ stage, assignments: stageAssignments }) => {
const stageCompleted = stageAssignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const roundTotal = roundAssignments.length
const roundProgress =
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
const isAlmostDone = roundProgress >= 80
const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null)
const stageTotal = stageAssignments.length
const stageProgress =
stageTotal > 0 ? (stageCompleted / stageTotal) * 100 : 0
const isAlmostDone = stageProgress >= 80
const deadline = graceByStage.get(stage.id) ?? (stage.windowCloseAt ? new Date(stage.windowCloseAt) : null)
const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
const program = stage.track.pipeline.program
return (
<div
key={round.id}
key={stage.id}
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent
@@ -543,9 +552,9 @@ async function JuryDashboardContent() {
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{stage.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{round.program.name} &middot; {round.program.year}
{program.name} &middot; {program.year}
</p>
</div>
{isAlmostDone ? (
@@ -559,13 +568,13 @@ async function JuryDashboardContent() {
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-semibold tabular-nums">
{roundCompleted}/{roundTotal}
{stageCompleted}/{stageTotal}
</span>
</div>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${roundProgress}%` }}
style={{ width: `${stageProgress}%` }}
/>
</div>
</div>
@@ -576,16 +585,16 @@ async function JuryDashboardContent() {
deadline={deadline}
label="Deadline:"
/>
{round.votingEndAt && (
{stage.windowCloseAt && (
<span className="text-xs text-muted-foreground">
({formatDateOnly(round.votingEndAt)})
({formatDateOnly(stage.windowCloseAt)})
</span>
)}
</div>
)}
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/assignments?round=${round.id}`}>
<Link href={`/jury/stages/${stage.id}/assignments`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -598,15 +607,15 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* No active rounds */}
{activeRounds.length === 0 && (
{/* No active stages */}
{activeStages.length === 0 && (
<AnimatedCard index={8}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
<Clock className="h-6 w-6 text-brand-teal/70" />
</div>
<p className="font-semibold text-sm">No active voting rounds</p>
<p className="font-semibold text-sm">No active voting stages</p>
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
Check back later when a voting window opens
</p>
@@ -615,8 +624,8 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* Completion Summary by Round */}
{Object.keys(assignmentsByRound).length > 0 && (
{/* Completion Summary by Stage */}
{Object.keys(assignmentsByStage).length > 0 && (
<AnimatedCard index={9}>
<Card>
<CardHeader className="pb-3">
@@ -624,18 +633,18 @@ async function JuryDashboardContent() {
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-lg">Round Summary</CardTitle>
<CardTitle className="text-lg">Stage Summary</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => {
const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
const total = roundAssignments.length
{Object.values(assignmentsByStage).map(({ stage, assignments: stageAssignments }) => {
const done = stageAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
const total = stageAssignments.length
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return (
<div key={round.id} className="space-y-2">
<div key={stage.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{round.name}</span>
<span className="font-medium truncate">{stage.name}</span>
<div className="flex items-baseline gap-1 shrink-0 ml-2">
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
<span className="text-xs text-muted-foreground">({done}/{total})</span>

View File

@@ -1,366 +0,0 @@
'use client'
import { useState } from 'react'
import { useParams, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
BarChart3,
MessageSquare,
Send,
Loader2,
Lock,
User,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, cn, getInitials } from '@/lib/utils'
export default function DiscussionPage() {
const params = useParams()
const searchParams = useSearchParams()
const projectId = params.id as string
const roundId = searchParams.get('roundId') || ''
const [commentText, setCommentText] = useState('')
const utils = trpc.useUtils()
// Fetch peer summary
const { data: peerSummary, isLoading: loadingSummary } =
trpc.evaluation.getPeerSummary.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Fetch discussion thread
const { data: discussion, isLoading: loadingDiscussion } =
trpc.evaluation.getDiscussion.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Add comment mutation
const addCommentMutation = trpc.evaluation.addComment.useMutation({
onSuccess: () => {
utils.evaluation.getDiscussion.invalidate({ projectId, roundId })
toast.success('Comment added')
setCommentText('')
},
onError: (e) => toast.error(e.message),
})
const handleSubmitComment = () => {
if (!commentText.trim()) {
toast.error('Please enter a comment')
return
}
addCommentMutation.mutate({
projectId,
roundId,
content: commentText.trim(),
})
}
const isLoading = loadingSummary || loadingDiscussion
if (!roundId) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No round specified</p>
<p className="text-sm text-muted-foreground">
Please access the discussion from your assignments page.
</p>
</CardContent>
</Card>
</div>
)
}
if (isLoading) {
return <DiscussionSkeleton />
}
// Parse peer summary data
const summary = peerSummary as Record<string, unknown> | undefined
const averageScore = summary ? Number(summary.averageScore || 0) : 0
const scoreRange = summary?.scoreRange as { min: number; max: number } | undefined
const evaluationCount = summary ? Number(summary.evaluationCount || 0) : 0
const individualScores = (summary?.scores || summary?.individualScores) as
| Array<number>
| undefined
// Parse discussion data
const discussionData = discussion as Record<string, unknown> | undefined
const comments = (discussionData?.comments || []) as Array<{
id: string
user: { id: string; name: string | null; email: string }
content: string
createdAt: string
}>
const discussionStatus = String(discussionData?.status || 'OPEN')
const isClosed = discussionStatus === 'CLOSED'
const closedAt = discussionData?.closedAt as string | undefined
const closedBy = discussionData?.closedBy as Record<string, unknown> | undefined
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Project Discussion
</h1>
<p className="text-muted-foreground">
Peer review discussion and anonymized score summary
</p>
</div>
{/* Peer Summary Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Peer Summary
</CardTitle>
<CardDescription>
Anonymized scoring overview across all evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{averageScore.toFixed(1)}</p>
<p className="text-xs text-muted-foreground">Average Score</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">
{scoreRange
? `${scoreRange.min.toFixed(1)} - ${scoreRange.max.toFixed(1)}`
: '--'}
</p>
<p className="text-xs text-muted-foreground">Score Range</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{evaluationCount}</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
</div>
{/* Anonymized score bars */}
{individualScores && individualScores.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Anonymized Individual Scores</p>
<div className="flex items-end gap-2 h-24">
{individualScores.map((score, i) => {
const maxPossible = scoreRange?.max || 10
const height =
maxPossible > 0
? Math.max((score / maxPossible) * 100, 4)
: 4
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-1"
>
<span className="text-[10px] font-medium tabular-nums">
{score.toFixed(1)}
</span>
<div
className={cn(
'w-full rounded-t transition-all',
score >= averageScore
? 'bg-primary/60'
: 'bg-muted-foreground/30'
)}
style={{ height: `${height}%` }}
/>
<span className="text-[10px] text-muted-foreground">
#{i + 1}
</span>
</div>
)
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* Discussion Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Discussion
</CardTitle>
{isClosed && (
<Badge variant="secondary" className="flex items-center gap-1">
<Lock className="h-3 w-3" />
Closed
{closedAt && (
<span className="ml-1">- {formatDate(closedAt)}</span>
)}
</Badge>
)}
</div>
<CardDescription>
{isClosed
? 'This discussion has been closed.'
: 'Share your thoughts with fellow jurors about this project.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Comments */}
{comments.length > 0 ? (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{/* Avatar */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{comment.user?.name
? getInitials(comment.user.name)
: <User className="h-4 w-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{comment.user?.name || 'Anonymous Juror'}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-1 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-2 text-sm text-muted-foreground">
No comments yet. Be the first to start the discussion.
</p>
</div>
)}
{/* Comment input */}
{!isClosed ? (
<div className="space-y-2 border-t pt-4">
<Textarea
placeholder="Write your comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={3}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmitComment}
disabled={
addCommentMutation.isPending || !commentText.trim()
}
>
{addCommentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Post Comment
</Button>
</div>
</div>
) : (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground text-center">
This discussion is closed and no longer accepts new comments.
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function DiscussionSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40 mt-2" />
</div>
{/* Peer summary skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
{/* Discussion skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,349 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
// Define the criterion type for the evaluation form
interface Criterion {
id: string
label: string
description?: string
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
scale?: number
weight?: number
required?: boolean
maxLength?: number
placeholder?: string
trueLabel?: string
falseLabel?: string
condition?: {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
sectionId?: string
}
async function EvaluateContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
_count: {
select: { files: true },
},
},
})
if (!project) {
notFound()
}
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const round = assignment.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed = round.votingEndAt && isPast(new Date(round.votingEndAt))
// Check for grace period
const gracePeriod = await prisma.gracePeriod.findFirst({
where: {
roundId: round.id,
userId,
OR: [{ projectId: null }, { projectId }],
extendedUntil: { gte: now },
},
})
const hasGracePeriod = !!gracePeriod
const effectiveVotingOpen = isVotingOpen || hasGracePeriod
// Check if already submitted
const evaluation = assignment.evaluation
const isSubmitted =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
if (isSubmitted) {
redirect(`/jury/projects/${projectId}/evaluation`)
}
// Check COI status
const coiRecord = await prisma.conflictOfInterest.findUnique({
where: { assignmentId: assignment.id },
})
const coiStatus = coiRecord
? { hasConflict: coiRecord.hasConflict, declared: true }
: { hasConflict: false, declared: false }
// Get evaluation form criteria
const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">Evaluation Form Not Available</p>
<p className="text-sm text-muted-foreground">
The evaluation criteria for this round have not been configured yet.
Please check back later.
</p>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from JSON
const criteria: Criterion[] = (evaluationForm.criteriaJson as unknown as Criterion[]) || []
// Handle voting not open
if (!effectiveVotingOpen) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">
{isVotingUpcoming ? 'Voting Not Yet Open' : 'Voting Period Closed'}
</p>
<p className="text-sm text-muted-foreground">
{isVotingUpcoming
? 'The voting window for this round has not started yet.'
: 'The voting window for this round has ended.'}
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Back button and project summary */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-xl font-semibold">Evaluate: {project.title}</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
</div>
{/* Quick file access */}
{project.files.length > 0 && (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
</span>
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${projectId}`}>View Files</Link>
</Button>
</div>
)}
</div>
{/* Grace period notice */}
{hasGracePeriod && gracePeriod && (
<Card className="border-amber-500 bg-amber-500/5">
<CardContent className="py-3">
<div className="flex items-center gap-2 text-amber-600">
<Clock className="h-4 w-4" />
<span className="text-sm font-medium">
You have a grace period extension until{' '}
{new Date(gracePeriod.extendedUntil).toLocaleString()}
</span>
</div>
</CardContent>
</Card>
)}
{/* Project Files */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files || 0}
/>
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
criteria={criteria}
initialData={
evaluation
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number | string | boolean
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
feedbackText: evaluation.feedbackText,
status: evaluation.status,
}
: undefined
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
coiStatus={coiStatus}
/>
</div>
)
}
function EvaluateSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-80" />
<Skeleton className="h-4 w-32" />
</div>
<Card>
<CardContent className="p-6 space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluatePage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluateSkeleton />}>
<EvaluateContent projectId={id} />
</Suspense>
)
}

View File

@@ -1,439 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
ThumbsUp,
ThumbsDown,
Calendar,
Users,
Star,
AlertCircle,
} from 'lucide-react'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { format } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
interface Criterion {
id: string
label: string
description?: string
scale: number
weight?: number
required?: boolean
}
async function EvaluationContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
teamName: true,
_count: { select: { files: true } },
},
})
if (!project) {
notFound()
}
// Find next unevaluated project for "Next Project" navigation
const now = new Date()
const nextAssignment = await prisma.assignment.findFirst({
where: {
userId,
id: { not: assignment?.id ?? undefined },
round: {
status: 'ACTIVE',
votingStartAt: { lte: now },
votingEndAt: { gte: now },
},
OR: [
{ evaluation: null },
{ evaluation: { status: { in: ['NOT_STARTED', 'DRAFT'] } } },
],
},
select: {
project: { select: { id: true, title: true } },
},
orderBy: { round: { votingEndAt: 'asc' } },
})
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const evaluation = assignment.evaluation
if (!evaluation || evaluation.status === 'NOT_STARTED') {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Evaluation Found</p>
<p className="text-sm text-muted-foreground">
You haven&apos;t submitted an evaluation for this project yet.
</p>
<Button asChild className="mt-4">
<Link href={`/jury/projects/${projectId}/evaluate`}>
Start Evaluation
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from the evaluation form
const criteria: Criterion[] =
(evaluation.form.criteriaJson as unknown as Criterion[]) || []
const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = assignment.round
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
My Evaluation: {project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<Badge
variant="default"
className="w-fit bg-green-600 hover:bg-green-700"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{evaluation.status === 'LOCKED' ? 'Locked' : 'Submitted'}
</Badge>
</div>
{evaluation.submittedAt && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Submitted on {format(new Date(evaluation.submittedAt), 'PPP')} at{' '}
{format(new Date(evaluation.submittedAt), 'p')}
</div>
)}
</div>
<Separator />
{/* Project Documents */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files ?? 0}
/>
{/* Criteria scores */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criteria Scores</CardTitle>
<CardDescription>
Your ratings for each evaluation criterion
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{criteria.map((criterion) => {
const score = criterionScores[criterion.id]
return (
<div key={criterion.id} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{criterion.label}</p>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{score}</span>
<span className="text-muted-foreground">
/ {criterion.scale}
</span>
</div>
</div>
{/* Visual score bar */}
<div className="flex gap-1">
{Array.from(
{ length: criterion.scale },
(_, i) => i + 1
).map((num) => (
<div
key={num}
className={`h-2 flex-1 rounded-full ${
num <= score
? 'bg-primary'
: 'bg-muted'
}`}
/>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<span className="text-3xl font-bold text-primary">
{evaluation.globalScore}
</span>
</div>
<div>
<p className="text-lg font-medium">out of 10</p>
<p className="text-sm text-muted-foreground">
{evaluation.globalScore && evaluation.globalScore >= 8
? 'Excellent'
: evaluation.globalScore && evaluation.globalScore >= 6
? 'Good'
: evaluation.globalScore && evaluation.globalScore >= 4
? 'Average'
: 'Below Average'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recommendation */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
</CardHeader>
<CardContent>
<div
className={`flex items-center gap-3 rounded-lg p-4 ${
evaluation.binaryDecision
? 'bg-green-500/10 text-green-700 dark:text-green-400'
: 'bg-red-500/10 text-red-700 dark:text-red-400'
}`}
>
{evaluation.binaryDecision ? (
<>
<ThumbsUp className="h-8 w-8" />
<div>
<p className="font-semibold">Recommended to Advance</p>
<p className="text-sm opacity-80">
You voted YES for this project to advance to the next round
</p>
</div>
</>
) : (
<>
<ThumbsDown className="h-8 w-8" />
<div>
<p className="font-semibold">Not Recommended</p>
<p className="text-sm opacity-80">
You voted NO for this project to advance
</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4">
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
</div>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" asChild>
<Link href={`/jury/projects/${projectId}`}>View Project Details</Link>
</Button>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/jury/assignments">All Assignments</Link>
</Button>
{nextAssignment && (
<Button asChild>
<Link href={`/jury/projects/${nextAssignment.project.id}/evaluate`}>
Next Project
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
</div>
</div>
</div>
)
}
function EvaluationSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-px w-full" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-16" />
</div>
<Skeleton className="h-2 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-32 rounded-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluationViewPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluationSkeleton />}>
<EvaluationContent projectId={id} />
</Suspense>
)
}

View File

@@ -1,507 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { FileViewerSkeleton } from '@/components/shared/file-viewer'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import {
ArrowLeft,
ArrowRight,
Users,
Calendar,
Clock,
CheckCircle2,
Edit3,
Tag,
FileText,
AlertCircle,
} from 'lucide-react'
import { formatDistanceToNow, format, isPast, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
async function ProjectContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: true,
round: {
include: {
program: {
select: { name: true, year: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
},
})
if (!project) {
notFound()
}
if (!assignment) {
// User is not assigned to this project
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
<Button asChild className="mt-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</CardContent>
</Card>
)
}
const evaluation = assignment.evaluation
const round = assignment.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed =
round.votingEndAt && isPast(new Date(round.votingEndAt))
// Determine evaluation status
const getEvaluationStatus = () => {
if (!evaluation)
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
switch (evaluation.status) {
case 'DRAFT':
return { label: 'In Progress', variant: 'secondary' as const, icon: Edit3 }
case 'SUBMITTED':
return { label: 'Submitted', variant: 'default' as const, icon: CheckCircle2 }
case 'LOCKED':
return { label: 'Locked', variant: 'default' as const, icon: CheckCircle2 }
default:
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
}
}
const status = getEvaluationStatus()
const StatusIcon = status.icon
const canEvaluate =
isVotingOpen &&
evaluation?.status !== 'SUBMITTED' &&
evaluation?.status !== 'LOCKED'
const canViewEvaluation =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Project Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.year} Edition</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Badge variant={status.variant} className="w-fit">
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
{round.votingEndAt && (
<DeadlineDisplay
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
)}
</div>
</div>
{/* Tags */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
<Tag className="mr-1 h-3 w-3" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-3">
{canEvaluate && (
<Button asChild>
<Link href={`/jury/projects/${project.id}/evaluate`}>
{evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
{canViewEvaluation && (
<Button variant="secondary" asChild>
<Link href={`/jury/projects/${project.id}/evaluation`}>
View My Evaluation
</Link>
</Button>
)}
{!isVotingOpen && !canViewEvaluation && (
<Button disabled>
{isVotingUpcoming
? 'Voting Not Yet Open'
: isVotingClosed
? 'Voting Closed'
: 'Evaluation Unavailable'}
</Button>
)}
</div>
<Separator />
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Description - takes 2 columns on large screens */}
<div className="lg:col-span-2 space-y-6">
{/* Description */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Description
</CardTitle>
</CardHeader>
<CardContent>
{project.description ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
) : (
<p className="text-muted-foreground italic">
No description provided
</p>
)}
</CardContent>
</Card>
{/* Files */}
<Suspense fallback={<FileViewerSkeleton />}>
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
</Suspense>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Round Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Calendar className="h-4 w-4 text-blue-500" />
</div>
Round Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Round</span>
<span className="text-sm font-medium">{round.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Program</span>
<span className="text-sm font-medium">{round.program.name}</span>
</div>
<Separator />
{round.votingStartAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Opens</span>
<span className="text-sm">
{format(new Date(round.votingStartAt), 'PPp')}
</span>
</div>
)}
{round.votingEndAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Closes</span>
<span className="text-sm">
{format(new Date(round.votingEndAt), 'PPp')}
</span>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<RoundStatusBadge
status={round.status}
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
</div>
</CardContent>
</Card>
{/* Evaluation Progress */}
{evaluation && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
</div>
Your Evaluation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={status.variant}>
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</div>
{evaluation.status === 'DRAFT' && (
<p className="text-sm text-muted-foreground">
Last saved{' '}
{formatDistanceToNow(new Date(evaluation.updatedAt), {
addSuffix: true,
})}
</p>
)}
{evaluation.status === 'SUBMITTED' && evaluation.submittedAt && (
<p className="text-sm text-muted-foreground">
Submitted{' '}
{formatDistanceToNow(new Date(evaluation.submittedAt), {
addSuffix: true,
})}
</p>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function DeadlineDisplay({
votingStartAt,
votingEndAt,
}: {
votingStartAt: Date | null
votingEndAt: Date
}) {
const now = new Date()
const endDate = new Date(votingEndAt)
const startDate = votingStartAt ? new Date(votingStartAt) : null
if (startDate && isFuture(startDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
Opens {format(startDate, 'PPp')}
</div>
)
}
if (isPast(endDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
Closed {formatDistanceToNow(endDate, { addSuffix: true })}
</div>
)
}
const daysRemaining = Math.ceil(
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
)
const isUrgent = daysRemaining <= 3
return (
<div
className={`flex items-center gap-1 text-sm ${
isUrgent ? 'text-amber-600 font-medium' : 'text-muted-foreground'
}`}
>
<Clock className="h-3 w-3" />
{daysRemaining <= 0
? `Due ${formatDistanceToNow(endDate, { addSuffix: true })}`
: `${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining`}
</div>
)
}
function RoundStatusBadge({
status,
votingStartAt,
votingEndAt,
}: {
status: string
votingStartAt: Date | null
votingEndAt: Date | null
}) {
const now = new Date()
const isVotingOpen =
status === 'ACTIVE' &&
votingStartAt &&
votingEndAt &&
new Date(votingStartAt) <= now &&
new Date(votingEndAt) >= now
if (isVotingOpen) {
return <Badge variant="default">Voting Open</Badge>
}
if (status === 'ACTIVE' && votingStartAt && isFuture(new Date(votingStartAt))) {
return <Badge variant="secondary">Upcoming</Badge>
}
if (status === 'ACTIVE' && votingEndAt && isPast(new Date(votingEndAt))) {
return <Badge variant="outline">Voting Closed</Badge>
}
return <Badge variant="secondary">{status}</Badge>
}
function ProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2 sm:items-end">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-px w-full" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
<FileViewerSkeleton />
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
)
}
export default async function ProjectDetailPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<ProjectSkeleton />}>
<ProjectContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,368 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
CheckCircle2,
Clock,
ArrowLeft,
FileEdit,
Eye,
ShieldAlert,
AlertCircle,
ClipboardList,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
// Type for assignment with included relations from stageAssignment.myAssignments
type AssignmentWithRelations = {
id: string
projectId: string
stageId: string
isCompleted: boolean
project: {
id: string
title: string
teamName: string | null
country: string | null
tags: string[]
description: string | null
}
evaluation?: {
id: string
status: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | null
} | null
conflictOfInterest?: {
id: string
hasConflict: boolean
conflictType: string | null
reviewAction: string | null
} | null
stage?: {
id: string
name: string
track: {
name: string
pipeline: { id: string; name: string }
}
}
}
function getAssignmentStatus(assignment: {
evaluation?: { status: string } | null
conflictOfInterest?: { id: string } | null
}) {
if (assignment.conflictOfInterest) return 'COI'
if (!assignment.evaluation) return 'NOT_STARTED'
return assignment.evaluation.status
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case 'SUBMITTED':
return (
<Badge variant="success" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
)
case 'DRAFT':
return (
<Badge variant="warning" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
)
case 'COI':
return (
<Badge variant="destructive" className="text-xs">
<ShieldAlert className="mr-1 h-3 w-3" />
COI Declared
</Badge>
)
default:
return (
<Badge variant="secondary" className="text-xs">
Not Started
</Badge>
)
}
}
export default function StageAssignmentsPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const { data: stageInfo, isLoading: stageLoading } =
trpc.stage.getForJury.useQuery({ id: stageId })
const { data: rawAssignments, isLoading: assignmentsLoading } =
trpc.stageAssignment.myAssignments.useQuery({ stageId })
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
const { data: windowStatus } =
trpc.evaluation.checkStageWindow.useQuery({ stageId })
const isWindowOpen = windowStatus?.isOpen ?? false
const isLoading = stageLoading || assignmentsLoading
const totalAssignments = assignments?.length ?? 0
const completedCount = assignments?.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length ?? 0
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
const pendingCount = totalAssignments - completedCount - coiCount
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<div className="grid gap-3 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/jury/stages" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Stage header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{stageInfo?.name ?? 'Stage Assignments'}
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground mt-0.5">
{stageInfo.track.name} &middot; {stageInfo.track.pipeline.name}
</p>
)}
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold">{totalAssignments}</p>
<p className="text-xs text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
<p className="text-xs text-muted-foreground">COI Declared</p>
</CardContent>
</Card>
</div>
{/* Assignments table */}
{assignments && assignments.length > 0 ? (
<Card>
<CardContent className="p-0">
{/* Desktop table */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Country</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => {
const status = getAssignmentStatus(assignment)
return (
<TableRow key={assignment.id}>
<TableCell className="font-medium">
<Link
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
>
{assignment.project.title}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{assignment.project.teamName}
</TableCell>
<TableCell className="text-muted-foreground">
{assignment.project.country ?? '—'}
</TableCell>
<TableCell>
<StatusBadge status={status} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{status === 'SUBMITTED' ? (
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
) : status === 'COI' ? (
<Button variant="ghost" size="sm" disabled>
<ShieldAlert className="mr-1 h-3 w-3" />
COI
</Button>
) : (
<Button
size="sm"
asChild
disabled={!isWindowOpen}
className={cn(
'bg-brand-blue hover:bg-brand-blue-light',
!isWindowOpen && 'opacity-50 pointer-events-none'
)}
>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
<FileEdit className="mr-1 h-3 w-3" />
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Mobile card list */}
<div className="md:hidden divide-y">
{assignments.map((assignment) => {
const status = getAssignmentStatus(assignment)
return (
<div key={assignment.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
className="font-medium text-sm hover:text-brand-blue transition-colors"
>
{assignment.project.title}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">
{assignment.project.teamName}
{assignment.project.country && ` · ${assignment.project.country}`}
</p>
</div>
<StatusBadge status={status} />
</div>
<div className="mt-3 flex justify-end">
{status === 'SUBMITTED' ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
View Evaluation
</Link>
</Button>
) : status !== 'COI' && isWindowOpen ? (
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : null}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No assignments in this stage</p>
<p className="text-sm text-muted-foreground mt-1">
Assignments will appear here once an administrator assigns projects to you.
</p>
</CardContent>
</Card>
)}
{/* Window closed notice */}
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,311 @@
'use client'
import { use, useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
GitCompare,
Star,
CheckCircle2,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { cn } from '@/lib/utils'
// Type for assignment with included relations from stageAssignment.myAssignments
type AssignmentWithRelations = {
id: string
projectId: string
stageId: string
isCompleted: boolean
project: {
id: string
title: string
teamName: string | null
country: string | null
tags: string[]
description: string | null
}
evaluation?: {
id: string
status: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | null
} | null
conflictOfInterest?: {
id: string
hasConflict: boolean
conflictType: string | null
reviewAction: string | null
} | null
}
export default function StageComparePage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { data: rawAssignments, isLoading: assignmentsLoading } =
trpc.stageAssignment.myAssignments.useQuery({ stageId })
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: evaluations } =
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const criteria = stageForm?.criteriaJson?.filter(
(c: { type?: string }) => c.type !== 'section_header'
) ?? []
// Map evaluations by project ID
const evalByProject = useMemo(() => {
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
evaluations?.forEach((e) => {
if (e.assignment?.projectId) {
map.set(e.assignment.projectId, e)
}
})
return map
}, [evaluations])
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else if (next.size < 4) {
next.add(projectId)
}
return next
})
}
const selectedAssignments = assignments?.filter((a) =>
selectedIds.has(a.project.id)
) ?? []
if (assignmentsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
const submittedAssignments = assignments?.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
) ?? []
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<GitCompare className="h-6 w-6" />
Compare Projects
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Select 2-4 evaluated projects to compare side-by-side
</p>
</div>
{submittedAssignments.length < 2 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Not enough evaluations</p>
<p className="text-sm text-muted-foreground mt-1">
You need at least 2 submitted evaluations to compare projects.
</p>
</CardContent>
</Card>
) : (
<>
{/* Project selector */}
<Card>
<CardHeader>
<CardTitle className="text-lg">
Select Projects ({selectedIds.size}/4)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2">
{submittedAssignments.map((assignment) => {
const isSelected = selectedIds.has(assignment.project.id)
const eval_ = evalByProject.get(assignment.project.id)
return (
<div
key={assignment.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
isSelected
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
: 'hover:bg-muted/50',
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
)}
onClick={() => toggleProject(assignment.project.id)}
>
<Checkbox
checked={isSelected}
disabled={selectedIds.size >= 4 && !isSelected}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{assignment.project.title}
</p>
<p className="text-xs text-muted-foreground">
{assignment.project.teamName}
</p>
</div>
{eval_?.globalScore != null && (
<Badge variant="outline" className="tabular-nums">
<Star className="mr-1 h-3 w-3 text-amber-500" />
{eval_.globalScore.toFixed(1)}
</Badge>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Comparison table */}
{selectedAssignments.length >= 2 && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]">Criterion</TableHead>
{selectedAssignments.map((a) => (
<TableHead key={a.id} className="text-center min-w-[120px]">
<div className="truncate max-w-[120px]" title={a.project.title}>
{a.project.title}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* Criterion rows */}
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
<TableRow key={criterion.id}>
<TableCell className="font-medium text-sm">
{criterion.label}
</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
const score = scores?.[criterion.id]
return (
<TableCell key={a.id} className="text-center">
{criterion.type === 'boolean' ? (
score ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
) : (
<span className="text-muted-foreground"></span>
)
) : criterion.type === 'text' ? (
<span className="text-xs truncate max-w-[100px] block">
{String(score ?? '—')}
</span>
) : (
<span className="tabular-nums font-semibold">
{typeof score === 'number' ? score.toFixed(1) : '—'}
</span>
)}
</TableCell>
)
})}
</TableRow>
))}
{/* Global score row */}
<TableRow className="bg-muted/50 font-semibold">
<TableCell>Global Score</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
return (
<TableCell key={a.id} className="text-center">
<span className="tabular-nums text-lg">
{eval_?.globalScore?.toFixed(1) ?? '—'}
</span>
</TableCell>
)
})}
</TableRow>
{/* Decision row */}
<TableRow>
<TableCell className="font-medium">Decision</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
return (
<TableCell key={a.id} className="text-center">
{eval_?.binaryDecision != null ? (
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
{eval_.binaryDecision ? 'Yes' : 'No'}
</Badge>
) : (
'—'
)}
</TableCell>
)
})}
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageJuryLivePage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
// Get live cursor for this stage
const { data: cursorData } = trpc.live.getCursor.useQuery(
{ stageId },
{ enabled: !!stageId }
)
const sessionId = cursorData?.sessionId ?? null
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Reset vote state when active project changes
const activeProjectId = activeProject?.id
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Vote submitted!')
setHasVoted(true)
setSelectedScore(null)
setLastVotedProjectId(activeProjectId ?? null)
},
onError: (err) => {
toast.error(err.message)
},
})
const handleVote = () => {
if (!sessionId || !activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
if (!cursorData && !stageInfo) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!sessionId) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No live session active</p>
<p className="text-sm text-muted-foreground mt-1">
A live presentation session has not been started for this stage.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header with connection status */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
<div className="flex items-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Connected
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
{!isConnected && (
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
</CardContent>
</Card>
)}
{/* Paused overlay */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Pause className="h-12 w-12 text-amber-600 mb-3" />
<p className="text-lg font-semibold">Session Paused</p>
<p className="text-sm text-muted-foreground mt-1">
The session administrator has paused voting. Please wait...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card>
<CardHeader>
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500" />
Cast Your Vote
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{hasVoted ? (
<div className="flex flex-col items-center py-6 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
<p className="font-semibold">Vote submitted!</p>
<p className="text-sm text-muted-foreground mt-1">
Waiting for the next project...
</p>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-12 text-lg font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full bg-brand-blue hover:bg-brand-blue-light"
size="lg"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Waiting for next project...</p>
<p className="text-sm text-muted-foreground mt-1">
The session administrator will advance to the next project.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,199 @@
'use client'
import { use, useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, AlertCircle } from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
export default function StageEvaluatePage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
// Fetch assignment details
const { data: assignment, isLoading: assignmentLoading } =
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
// Fetch stage info for breadcrumb
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
// Fetch or create evaluation draft
const startEval = trpc.evaluation.startStage.useMutation()
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
// State for the evaluation returned by the mutation
const [evaluation, setEvaluation] = useState<{
id: string
status: string
criterionScoresJson?: unknown
globalScore?: number | null
binaryDecision?: boolean | null
feedbackText?: string | null
} | null>(null)
// Start evaluation on first load if we have the assignment
useEffect(() => {
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
startEval.mutate(
{ assignmentId: assignment.id, stageId },
{ onSuccess: (data) => setEvaluation(data) }
)
}
}, [assignment?.id, windowStatus?.isOpen])
const isLoading = assignmentLoading || startEval.isPending
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!assignment) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
<p className="font-medium text-destructive">Assignment not found</p>
<p className="text-sm text-muted-foreground mt-1">
You don&apos;t have an assignment for this project in this stage.
</p>
</CardContent>
</Card>
</div>
)
}
const isWindowOpen = windowStatus?.isOpen ?? false
const criteria = stageForm?.criteriaJson ?? []
// Get COI status from assignment
const coiStatus = {
hasConflict: !!assignment.conflictOfInterest,
declared: !!assignment.conflictOfInterest,
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Project title + stage window */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{assignment.project.title}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{assignment.project.teamName}
{assignment.project.country && ` · ${assignment.project.country}`}
</p>
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Grace period notice */}
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
You are in a grace period. Please submit your evaluation before{' '}
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
</p>
</CardContent>
</Card>
)}
{/* Window closed notice */}
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
</p>
</CardContent>
</Card>
)}
{/* Project files */}
<CollapsibleFilesSection
projectId={projectId}
fileCount={assignment.project.files?.length ?? 0}
stageId={stageId}
/>
{/* Evaluation form */}
{isWindowOpen || windowStatus?.hasGracePeriod ? (
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id ?? null}
projectTitle={assignment.project.title}
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
initialData={
evaluation
? {
criterionScoresJson:
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
globalScore: evaluation.globalScore ?? null,
binaryDecision: evaluation.binaryDecision ?? null,
feedbackText: evaluation.feedbackText ?? null,
status: evaluation.status,
}
: undefined
}
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
deadline={
windowStatus?.graceExpiresAt
? new Date(windowStatus.graceExpiresAt)
: stageInfo?.windowCloseAt
? new Date(stageInfo.windowCloseAt)
: null
}
coiStatus={coiStatus}
/>
) : null}
</div>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft,
CheckCircle2,
Star,
MessageSquare,
Clock,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
export default function ViewStageEvaluationPage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
const { data: evaluations, isLoading } =
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const evaluation = evaluations?.[0] // Most recent evaluation
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!evaluation) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No evaluation found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t submitted an evaluation for this project yet.
</p>
</CardContent>
</Card>
</div>
)
}
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{evaluation.assignment?.project?.title ?? 'Evaluation'}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Submitted evaluation read only
</p>
</div>
<Badge variant="success" className="self-start">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
</div>
{/* Submission info */}
{evaluation.submittedAt && (
<Card>
<CardContent className="flex items-center gap-2 py-3">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Submitted on{' '}
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</CardContent>
</Card>
)}
{/* Criterion scores */}
{criteria.length > 0 && criterionScores && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criterion Scores</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{criteria.map((criterion) => {
const score = criterionScores[criterion.id]
if (criterion.type === 'section_header') {
return (
<div key={criterion.id} className="pt-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{criterion.label}
</h3>
<Separator className="mt-2" />
</div>
)
}
return (
<div key={criterion.id} className="flex items-center justify-between py-2">
<span className="text-sm font-medium">{criterion.label}</span>
<div className="flex items-center gap-2">
{criterion.type === 'boolean' ? (
<Badge variant={score ? 'success' : 'secondary'}>
{score ? 'Yes' : 'No'}
</Badge>
) : criterion.type === 'text' ? (
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
{String(score ?? '—')}
</span>
) : (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="font-semibold tabular-nums">
{typeof score === 'number' ? score.toFixed(1) : '—'}
</span>
{criterion.scale && (
<span className="text-xs text-muted-foreground">
/ {criterion.scale}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Global score + Decision */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500" />
Global Score
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl font-bold tabular-nums">
{evaluation.globalScore?.toFixed(1) ?? '—'}
</p>
</CardContent>
</Card>
{evaluation.binaryDecision !== null && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Decision</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
className="text-lg px-4 py-2"
>
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
</Badge>
</CardContent>
</Card>
)}
</div>
{/* Feedback */}
{evaluation.feedbackText && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Feedback
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,217 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
FileEdit,
Eye,
Users,
MapPin,
Tag,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
function EvalStatusCard({
status,
stageId,
projectId,
isWindowOpen,
}: {
status: string
stageId: string
projectId: string
isWindowOpen: boolean
}) {
const isSubmitted = status === 'SUBMITTED'
const isDraft = status === 'DRAFT'
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge
variant={
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
}
>
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
</Badge>
<div className="flex gap-2">
{isSubmitted ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
<Eye className="mr-1 h-3 w-3" />
View Evaluation
</Link>
</Button>
) : isWindowOpen ? (
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
<FileEdit className="mr-1 h-3 w-3" />
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
Window Closed
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}
export default function StageProjectDetailPage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
const { data: assignment, isLoading: assignmentLoading } =
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
const isWindowOpen = windowStatus?.isOpen ?? false
if (assignmentLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!assignment) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
<p className="font-medium text-destructive">Assignment not found</p>
</CardContent>
</Card>
</div>
)
}
const project = assignment.project
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Project header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
{project.teamName && (
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{project.teamName}
</span>
)}
{project.country && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{project.country}
</span>
)}
</div>
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Project description */}
{project.description && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</CardContent>
</Card>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="h-4 w-4 text-muted-foreground" />
{project.tags.map((tag: string) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Evaluation status */}
<EvalStatusCard
status={evalStatus}
stageId={stageId}
projectId={projectId}
isWindowOpen={isWindowOpen}
/>
{/* Project files */}
<CollapsibleFilesSection
projectId={projectId}
fileCount={project.files?.length ?? 0}
stageId={stageId}
/>
</div>
)
}

View File

@@ -0,0 +1,247 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import {
ClipboardList,
CheckCircle2,
Clock,
ArrowRight,
BarChart3,
Target,
Layers,
} from 'lucide-react'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
export default function JuryStagesDashboard() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? ''
const { data: stages, isLoading: stagesLoading } =
trpc.stageAssignment.myStages.useQuery(
{ programId },
{ enabled: !!programId }
)
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
const totalPending = totalAssignments - totalCompleted - totalInProgress
const completionRate = totalAssignments > 0
? Math.round((totalCompleted / totalAssignments) * 100)
: 0
const stats = [
{
label: 'Total Assignments',
value: totalAssignments,
icon: ClipboardList,
accentColor: 'border-l-blue-500',
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
iconColor: 'text-blue-600 dark:text-blue-400',
},
{
label: 'Completed',
value: totalCompleted,
icon: CheckCircle2,
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
iconColor: 'text-emerald-600 dark:text-emerald-400',
},
{
label: 'In Progress',
value: totalInProgress,
icon: Clock,
accentColor: 'border-l-amber-500',
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
iconColor: 'text-amber-600 dark:text-amber-400',
},
{
label: 'Pending',
value: totalPending,
icon: Target,
accentColor: 'border-l-slate-400',
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
iconColor: 'text-slate-500 dark:text-slate-400',
},
]
if (stagesLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted">
<CardContent className="flex items-center gap-4 py-5 px-5">
<Skeleton className="h-11 w-11 rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-7 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardContent className="py-5">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (!stages || stages.length === 0) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
<Layers className="h-8 w-8 text-brand-teal/60" />
</div>
<p className="text-lg font-semibold">No stage assignments yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
Your stage-based assignments will appear here once an administrator assigns projects to you.
</p>
<Button variant="outline" asChild className="mt-4">
<Link href="/jury">
Back to Dashboard
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
{/* Stats row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat) => (
<Card
key={stat.label}
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
>
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className={cn('rounded-xl p-3', stat.iconBg)}>
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
</div>
<div>
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
</div>
</CardContent>
</Card>
))}
<Card className="border-l-4 border-l-brand-teal">
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
</div>
<div className="flex-1 min-w-0">
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
{completionRate}%
</p>
<Progress value={completionRate} className="h-1.5 mt-1" />
</div>
</CardContent>
</Card>
</div>
{/* Stage cards */}
<div className="space-y-3">
{stages.map((stage) => {
const stageProgress = stage.stats.total > 0
? Math.round((stage.stats.completed / stage.stats.total) * 100)
: 0
return (
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
<StageWindowBadge
windowOpenAt={stage.windowOpenAt}
windowCloseAt={stage.windowCloseAt}
status={stage.status}
/>
</div>
<p className="text-sm text-muted-foreground">
{stage.track.name} &middot; {stage.track.pipeline.name}
</p>
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-semibold tabular-nums">
{stage.stats.completed}/{stage.stats.total}
</span>
</div>
<Progress value={stageProgress} className="h-2" />
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
<div className="flex gap-2 flex-wrap">
{stage.stats.completed > 0 && (
<Badge variant="success" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
{stage.stats.completed} done
</Badge>
)}
{stage.stats.inProgress > 0 && (
<Badge variant="warning" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
{stage.stats.inProgress} in progress
</Badge>
)}
</div>
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -273,12 +273,12 @@ export default function MentorDashboard() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round?.program?.year} Edition
{project.program?.year} Edition
</span>
{project.round && (
{project.program && (
<>
<span>-</span>
<span>{project.round.name}</span>
<span>{project.program.name}</span>
</>
)}
</div>

View File

@@ -134,7 +134,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
const mentorAssignmentId = project.mentorAssignment?.id
const programId = project.round?.program?.id
const programId = project.program?.id
return (
<div className="space-y-6">
@@ -158,12 +158,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round?.program?.year} Edition
{project.program?.year} Edition
</span>
{project.round && (
{project.program && (
<>
<span>-</span>
<span>{project.round.name}</span>
<span>{project.program.name}</span>
</>
)}
</div>

View File

@@ -94,12 +94,12 @@ export default function MentorProjectsPage() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.round?.program?.year} Edition
{project.program?.year} Edition
</span>
{project.round && (
{project.program && (
<>
<span></span>
<span>{project.round.name}</span>
<span>{project.program.name}</span>
</>
)}
</div>

View File

@@ -47,32 +47,32 @@ import {
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
CrossRoundComparisonChart,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
return { stageId: value }
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programName: `${p.year} Edition`,
}))
) || []
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
@@ -100,8 +100,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
)
}
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'STAGE_ACTIVE').length
const totalPrograms = programs?.length || 0
return (
@@ -113,10 +113,10 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<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-sm font-medium text-muted-foreground">Total Stages</p>
<p className="text-2xl font-bold mt-1">{stages.length}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRounds} active
{activeStages} active
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -134,7 +134,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<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>
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
@@ -149,8 +149,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<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-sm font-medium text-muted-foreground">Active Stages</p>
<p className="text-2xl font-bold mt-1">{activeStages}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
@@ -251,48 +251,48 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
</>
)}
{/* Rounds Table - Desktop */}
{/* Stages Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardDescription>Progress overview for each round</CardDescription>
<CardTitle>Stage Reports</CardTitle>
<CardDescription>Progress overview for each stage</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rounds.map((round) => (
<TableRow key={round.id}>
{stages.map((stage) => (
<TableRow key={stage.id}>
<TableCell>
<div>
<p className="font-medium">{round.name}</p>
{round.votingEndAt && (
<p className="font-medium">{stage.name}</p>
{stage.windowCloseAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{round.programName}</TableCell>
<TableCell>{round._count?.projects || '-'}</TableCell>
<TableCell>{stage.programName}</TableCell>
<TableCell>{stage._count?.projects || '-'}</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
stage.status === 'STAGE_ACTIVE'
? 'default'
: round.status === 'CLOSED'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</TableCell>
</TableRow>
@@ -302,34 +302,34 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
</CardContent>
</Card>
{/* Rounds Cards - Mobile */}
{/* Stages Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Round Reports</h2>
{rounds.map((round) => (
<Card key={round.id}>
<h2 className="text-lg font-semibold">Stage Reports</h2>
{stages.map((stage) => (
<Card key={stage.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium">{round.name}</p>
<p className="font-medium">{stage.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
stage.status === 'STAGE_ACTIVE'
? 'default'
: round.status === 'CLOSED'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{round.programName}</p>
{round.votingEndAt && (
<p className="text-sm text-muted-foreground">{stage.programName}</p>
{stage.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
<div className="text-sm">
<span>{round._count?.projects || 0} projects</span>
<span>{stage._count?.projects || 0} projects</span>
</div>
</CardContent>
</Card>
@@ -341,7 +341,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -455,26 +455,26 @@ function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
)
}
function CrossRoundTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
const stages = programs?.flatMap(p =>
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
)
}
@@ -484,35 +484,35 @@ function CrossRoundTab() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardDescription>Choose at least 2 rounds</CardDescription>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>Choose at least 2 stages</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{rounds.map((round) => (
{stages.map((stage) => (
<Badge
key={round.id}
variant={selectedRoundIds.includes(round.id) ? 'default' : 'outline'}
key={stage.id}
variant={selectedStageIds.includes(stage.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(round.id)}
onClick={() => toggleStage(stage.id)}
>
{round.programName} - {round.name}
{stage.programName} - {stage.name}
</Badge>
))}
</div>
{selectedRoundIds.length < 2 && (
{selectedStageIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 rounds to enable comparison
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparisonLoading && selectedStageIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossRoundComparisonChart data={comparison as Array<{
roundId: string; roundName: string; projectCount: number; evaluationCount: number
<CrossStageComparisonChart data={comparison as Array<{
stageId: string; stageName: string; projectCount: number; evaluationCount: number
completionRate: number; averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
@@ -523,7 +523,7 @@ function CrossRoundTab() {
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: consistency, isLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -551,7 +551,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
function DiversityTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: diversity, isLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -579,23 +579,23 @@ function DiversityTab({ selectedValue }: { selectedValue: string }) {
export default function ObserverReportsPage() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
// Set default selected round
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
// Set default selected stage
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const hasSelection = !!selectedValue
const selectedRound = rounds.find((r) => r.id === selectedValue)
const selectedStage = stages.find((s) => s.id === selectedValue)
return (
<div className="space-y-6">
@@ -607,31 +607,31 @@ export default function ObserverReportsPage() {
</p>
</div>
{/* Round Selector */}
{/* Stage Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Round:</label>
{roundsLoading ? (
<label className="text-sm font-medium">Select Stage:</label>
{stagesLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : rounds.length > 0 ? (
) : stages.length > 0 ? (
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
{p.year} Edition All Stages
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No rounds available</p>
<p className="text-sm text-muted-foreground">No stages available</p>
)}
</div>
@@ -647,7 +647,7 @@ export default function ObserverReportsPage() {
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-round" className="gap-2">
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
@@ -662,9 +662,9 @@ export default function ObserverReportsPage() {
</TabsList>
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton
roundId={selectedValue}
roundName={selectedRound?.name}
programName={selectedRound?.programName}
stageId={selectedValue}
roundName={selectedStage?.name}
programName={selectedStage?.programName}
/>
)}
</div>
@@ -689,8 +689,8 @@ export default function ObserverReportsPage() {
)}
</TabsContent>
<TabsContent value="cross-round">
<CrossRoundTab />
<TabsContent value="cross-stage">
<CrossStageTab />
</TabsContent>
<TabsContent value="consistency">

View File

@@ -8,13 +8,13 @@ import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function RoundApplyPage() {
export default function StageApplyPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug, mode: 'round' },
{ slug, mode: 'stage' },
{ retry: false }
)
@@ -30,7 +30,7 @@ export default function RoundApplyPage() {
)
}
if (error || !config || config.mode !== 'round') {
if (error || !config || config.mode !== 'stage') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
@@ -45,17 +45,17 @@ export default function RoundApplyPage() {
return (
<ApplyWizardDynamic
mode="round"
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
roundId={config.round.id}
isOpen={config.round.isOpen}
submissionDeadline={config.round.submissionEndDate}
stageId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'round',
roundId: config.round.id,
mode: 'stage',
stageId: config.stage.id,
data: data as any,
})
}}

View File

@@ -0,0 +1,267 @@
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}

View File

@@ -20,7 +20,6 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
ArrowLeft,
FileText,
@@ -151,7 +150,7 @@ export function SubmissionDetailClient() {
</Badge>
</div>
<p className="text-muted-foreground">
{project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
</p>
</div>
</div>
@@ -322,17 +321,6 @@ export function SubmissionDetailClient() {
{/* Documents Tab */}
<TabsContent value="documents">
{/* File Requirements Upload Slots */}
{project.roundId && (
<div className="mb-4">
<RequirementUploadList
projectId={project.id}
roundId={project.roundId}
disabled={!isDraft}
/>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
@@ -349,7 +337,7 @@ export function SubmissionDetailClient() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
return (
<div

View File

@@ -27,7 +27,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
Dialog,
DialogContent,
@@ -410,23 +409,7 @@ export default function TeamManagementPage() {
</CardContent>
</Card>
{/* Team Documents */}
{teamData?.roundId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Team Documents</CardTitle>
<CardDescription>
Upload required documents for your project. Any team member can upload files.
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={projectId}
roundId={teamData.roundId}
/>
</CardContent>
</Card>
)}
{/* Team Documents - available via documents page */}
{/* Info Card */}
<Card className="bg-muted/50">

View File

@@ -133,8 +133,8 @@ export function MySubmissionClient() {
<div className="space-y-4">
{submissions.map((project) => {
const projectStatus = project.status ?? 'SUBMITTED'
const roundName = project.round?.name
const programYear = project.round?.program?.year
const programName = project.program?.name
const programYear = project.program?.year
return (
<Card key={project.id}>
@@ -143,7 +143,7 @@ export function MySubmissionClient() {
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</CardDescription>
</div>
<Badge variant={statusColors[projectStatus] || 'secondary'}>

View File

@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>

View File

@@ -0,0 +1,215 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,216 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const POLL_INTERVAL_MS = 2000
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const { sessionId } = await params
// Validate session exists
const cursor = await prisma.liveProgressCursor.findUnique({
where: { sessionId },
})
if (!cursor) {
return new Response('Session not found', { status: 404 })
}
// Manually fetch related data since LiveProgressCursor doesn't have these relations
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
const stageInfo = await prisma.stage.findUnique({
where: { id: cursor.stageId },
select: { id: true, name: true },
})
const encoder = new TextEncoder()
let intervalId: ReturnType<typeof setInterval> | null = null
const stream = new ReadableStream({
start(controller) {
// Send initial state
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
where: { stageId: string }
include: { projects: { select: { projectId: true } } }
}>>>
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
.findMany({
where: { stageId: cursor.stageId },
include: {
projects: {
select: { projectId: true },
},
},
})
.then((cohorts) => {
const initData = {
activeProject,
isPaused: cursor.isPaused,
stageInfo,
openCohorts: cohorts.map((c) => ({
id: c.id,
name: c.name,
isOpen: c.isOpen,
projectIds: c.projects.map((p) => p.projectId),
})),
}
controller.enqueue(
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
)
return cohorts
})
.catch((): CohortWithProjects => {
// Ignore errors on init
return []
})
cohortPromise.then((initialCohorts: CohortWithProjects) => {
// Poll for updates
let lastActiveProjectId = cursor.activeProjectId
let lastIsPaused = cursor.isPaused
let lastCohortState = JSON.stringify(
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
id: c.id,
isOpen: c.isOpen,
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
}))
)
intervalId = setInterval(async () => {
try {
const updated = await prisma.liveProgressCursor.findUnique({
where: { sessionId },
})
if (!updated) {
controller.enqueue(
encoder.encode(
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
)
)
controller.close()
if (intervalId) clearInterval(intervalId)
return
}
// Check for cursor changes
if (
updated.activeProjectId !== lastActiveProjectId ||
updated.isPaused !== lastIsPaused
) {
// Fetch updated active project if changed
let updatedActiveProject = null
if (updated.activeProjectId) {
updatedActiveProject = await prisma.project.findUnique({
where: { id: updated.activeProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
controller.enqueue(
encoder.encode(
`event: cursor.updated\ndata: ${JSON.stringify({
activeProject: updatedActiveProject,
isPaused: updated.isPaused,
})}\n\n`
)
)
// Check pause/resume transitions
if (updated.isPaused && !lastIsPaused) {
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
} else if (!updated.isPaused && lastIsPaused) {
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
}
lastActiveProjectId = updated.activeProjectId
lastIsPaused = updated.isPaused
}
// Poll cohort changes
const currentCohorts = await prisma.cohort.findMany({
where: { stageId: cursor.stageId },
select: {
id: true,
name: true,
isOpen: true,
windowOpenAt: true,
windowCloseAt: true,
projects: { select: { projectId: true } },
},
})
const currentCohortState = JSON.stringify(
currentCohorts.map((c) => ({
id: c.id,
isOpen: c.isOpen,
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
}))
)
if (currentCohortState !== lastCohortState) {
controller.enqueue(
encoder.encode(
`event: cohort.window.changed\ndata: ${JSON.stringify({
openCohorts: currentCohorts.map((c) => ({
id: c.id,
name: c.name,
isOpen: c.isOpen,
projectIds: c.projects.map((p) => p.projectId),
})),
})}\n\n`
)
)
lastCohortState = currentCohortState
}
// Send heartbeat to keep connection alive
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
} catch {
// Connection may be closed, ignore errors
}
}, POLL_INTERVAL_MS)
})
},
cancel() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
},
})
// Check if client disconnected
request.signal.addEventListener('abort', () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
}