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>
)
}