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

@@ -0,0 +1,120 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
}
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>Required Reviews per Project</Label>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
/>
<p className="text-xs text-muted-foreground">
Minimum number of jury evaluations per project
</p>
</div>
<div className="space-y-2">
<Label>Max Load per Juror</Label>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
/>
<p className="text-xs text-muted-foreground">
Maximum projects assigned to one juror
</p>
</div>
<div className="space-y-2">
<Label>Min Load per Juror</Label>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Availability Weighting</Label>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
/>
</div>
<div className="space-y-2">
<Label>Overflow Policy</Label>
<Select
value={config.overflowPolicy}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="queue">
Queue Hold unassigned projects for manual assignment
</SelectItem>
<SelectItem value="expand_pool">
Expand Pool Invite additional jurors automatically
</SelectItem>
<SelectItem value="reduce_reviews">
Reduce Reviews Lower required reviews to fit available jurors
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react'
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
const addAward = () => {
const newTrack = defaultAwardTrack(awardTracks.length)
newTrack.sortOrder = tracks.length
onChange([...tracks, newTrack])
}
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
const updated = [...tracks]
const awardIndex = tracks.findIndex(
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
)
if (awardIndex >= 0) {
updated[awardIndex] = { ...updated[awardIndex], ...updates }
onChange(updated)
}
}
const removeAward = (index: number) => {
const toRemove = awardTracks[index]
onChange(tracks.filter((t) => t !== toRemove))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Configure special award tracks that run alongside the main competition.
</p>
<Button type="button" variant="outline" size="sm" onClick={addAward}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
</div>
{awardTracks.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
No award tracks configured. Awards are optional.
</div>
)}
{awardTracks.map((track, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
Award Track {index + 1}
</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the &quot;{track.name}&quot; award track and all
its stages. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAward(index)}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Award Name</Label>
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Routing Mode</Label>
<Select
value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PARALLEL">
Parallel Runs alongside main track
</SelectItem>
<SelectItem value="EXCLUSIVE">
Exclusive Projects enter only this track
</SelectItem>
<SelectItem value="POST_MAIN">
Post-Main After main track completes
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Decision Mode</Label>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
placeholder="Brief description of this award..."
value={track.awardConfig?.description ?? ''}
rows={2}
className="text-sm"
onChange={(e) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
description: e.target.value,
},
})
}
/>
</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard'
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
const { data: programs, isLoading } = trpc.program.list.useQuery({})
// Auto-generate slug from name
useEffect(() => {
if (state.name && !state.slug) {
onChange({ slug: slugify(state.name) })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="pipeline-name">Pipeline Name</Label>
<Input
id="pipeline-name"
placeholder="e.g., MOPC 2026"
value={state.name}
onChange={(e) => {
const name = e.target.value
onChange({ name, slug: slugify(name) })
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pipeline-slug">Slug</Label>
<Input
id="pipeline-slug"
placeholder="e.g., mopc-2026"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
pattern="^[a-z0-9-]+$"
disabled={isActive}
/>
<p className="text-xs text-muted-foreground">
{isActive
? 'Slug cannot be changed on active pipelines'
: 'Lowercase letters, numbers, and hyphens only'}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="pipeline-program">Program</Label>
<Select
value={state.programId}
onValueChange={(value) => onChange({ programId: value })}
>
<SelectTrigger id="pipeline-program">
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.year})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
}
export function FilteringSection({ config, onChange }: FilteringSectionProps) {
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...config.rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...config.rules,
{ field: '', operator: 'equals', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: config.rules.filter((_, i) => i !== index) })
}
return (
<div className="space-y-6">
{/* Deterministic Gate Rules */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Gate Rules</Label>
<p className="text-xs text-muted-foreground">
Deterministic rules that projects must pass. Applied in order.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
{config.rules.map((rule, index) => (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
<Input
placeholder="Field name"
value={rule.field}
className="h-8 text-sm"
onChange={(e) => updateRule(index, { field: e.target.value })}
/>
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="notEquals">Not Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="greaterThan">Greater Than</SelectItem>
<SelectItem value="lessThan">Less Than</SelectItem>
<SelectItem value="exists">Exists</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Value"
value={String(rule.value)}
className="h-8 text-sm"
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
{config.rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No gate rules configured. All projects will pass through.
</p>
)}
</div>
{/* AI Rubric */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>AI Screening</Label>
<p className="text-xs text-muted-foreground">
Use AI to evaluate projects against rubric criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="space-y-2">
<Label className="text-xs">High Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.aiConfidenceThresholds.high * 100]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.high * 100)}%
</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.aiConfidenceThresholds.medium * 100]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.medium * 100)}%
</span>
</div>
</div>
</div>
)}
</div>
{/* Manual Queue */}
<div className="flex items-center justify-between">
<div>
<Label>Manual Review Queue</Label>
<p className="text-xs text-muted-foreground">
Projects below medium confidence go to manual review
</p>
</div>
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Plus, Trash2, FileText } from 'lucide-react'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
}
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...config.fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...config.fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = config.fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Submission Window</Label>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Late Submission Policy</Label>
<Select
value={config.lateSubmissionPolicy}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject late submissions</SelectItem>
<SelectItem value="flag">Accept but flag as late</SelectItem>
<SelectItem value="accept">Accept normally</SelectItem>
</SelectContent>
</Select>
</div>
{config.lateSubmissionPolicy === 'flag' && (
<div className="space-y-2">
<Label>Grace Period (hours)</Label>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours}
onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
}
/>
</div>
)}
</div>
{/* File Requirements */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>File Requirements</Label>
<Button type="button" variant="outline" size="sm" onClick={addFileReq}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{config.fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files.
</p>
)}
{config.fileRequirements.map((req, index) => (
<Card key={index}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
<div className="flex-1 grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">File Name</Label>
<Input
placeholder="e.g., Executive Summary"
value={req.name}
onChange={(e) => updateFileReq(index, { name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
max={500}
value={req.maxSizeMB ?? ''}
onChange={(e) =>
updateFileReq(index, {
maxSizeMB: parseInt(e.target.value) || undefined,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Input
placeholder="Brief description of this requirement"
value={req.description ?? ''}
onChange={(e) =>
updateFileReq(index, { description: e.target.value })
}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={req.isRequired}
onCheckedChange={(checked) =>
updateFileReq(index, { isRequired: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
}
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Jury Voting</Label>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Audience Voting</Label>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
/>
</div>
{config.audienceVotingEnabled && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<Label className="text-xs">Audience Vote Weight</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.audienceVoteWeight * 100]}
onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 })
}
min={0}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.audienceVoteWeight * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground">
Percentage weight of audience votes in the final score
</p>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label>Cohort Setup Mode</Label>
<Select
value={config.cohortSetupMode}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
Manual Admin creates cohorts and assigns projects
</SelectItem>
<SelectItem value="auto">
Auto System creates cohorts from pipeline results
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Result Reveal Policy</Label>
<Select
value={config.revealPolicy}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Immediate Results shown after each vote
</SelectItem>
<SelectItem value="delayed">
Delayed Results hidden until admin reveals
</SelectItem>
<SelectItem value="ceremony">
Ceremony Results revealed in dramatic sequence
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import {
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
]
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
updated[index] = { ...updated[index], ...updates }
onChange(updated)
},
[stages, onChange]
)
const addStage = () => {
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
onChange([
...stages,
{
name: '',
slug: '',
stageType: 'EVALUATION',
sortOrder: maxOrder + 1,
configJson: {},
},
])
}
const removeStage = (index: number) => {
if (stages.length <= 2) return // Minimum 2 stages
const updated = stages.filter((_, i) => i !== index)
// Re-number sortOrder
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
const moveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= stages.length) return
const updated = [...stages]
const temp = updated[index]
updated[index] = updated[newIndex]
updated[newIndex] = temp
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3">
{/* Reorder */}
<div className="flex flex-col shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Order number */}
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
{index + 1}
</span>
{/* Stage name */}
<div className="flex-1 min-w-0">
<Input
placeholder="Stage name"
value={stage.name}
className="h-8 text-sm"
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STAGE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type badge */}
<Badge
variant="secondary"
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
>
{typeInfo?.label}
</Badge>
{/* Remove */}
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
{stages.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
No stages configured. Click &quot;Add Stage&quot; to begin.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,161 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
}
const NOTIFICATION_EVENTS = [
{
key: 'stage.transitioned',
label: 'Stage Transitioned',
description: 'When a stage changes status (draft → active → closed)',
},
{
key: 'filtering.completed',
label: 'Filtering Completed',
description: 'When batch filtering finishes processing',
},
{
key: 'assignment.generated',
label: 'Assignments Generated',
description: 'When jury assignments are created or updated',
},
{
key: 'routing.executed',
label: 'Routing Executed',
description: 'When projects are routed into tracks/stages',
},
{
key: 'live.cursor.updated',
label: 'Live Cursor Updated',
description: 'When the live presentation moves to next project',
},
{
key: 'cohort.window.changed',
label: 'Cohort Window Changed',
description: 'When a cohort voting window opens or closes',
},
{
key: 'decision.overridden',
label: 'Decision Overridden',
description: 'When an admin overrides an automated decision',
},
{
key: 'award.winner.finalized',
label: 'Award Winner Finalized',
description: 'When a special award winner is selected',
},
]
export function NotificationsSection({
config,
onChange,
overridePolicy,
onOverridePolicyChange,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
</div>
<div className="space-y-2">
{NOTIFICATION_EVENTS.map((event) => (
<Card key={event.key}>
<CardContent className="py-3 px-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Label className="text-sm font-medium">{event.label}</Label>
<p className="text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* Override Governance */}
<div className="space-y-3 pt-2 border-t">
<Label>Override Governance</Label>
<p className="text-xs text-muted-foreground">
Who can override automated decisions in this pipeline?
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
}
disabled
/>
<Label className="text-sm">Super Admins (always enabled)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('PROGRAM_ADMIN')) {
roles.push('PROGRAM_ADMIN')
} else if (!checked) {
const idx = roles.indexOf('PROGRAM_ADMIN')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Program Admins</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('AWARD_MASTER')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('AWARD_MASTER')) {
roles.push('AWARD_MASTER')
} else if (!checked) {
const idx = roles.indexOf('AWARD_MASTER')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Award Masters</Label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
type ReviewSectionProps = {
state: WizardState
}
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
if (result.valid && result.warnings.length === 0) {
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
}
if (result.valid && result.warnings.length > 0) {
return <AlertTriangle className="h-4 w-4 text-amber-500" />
}
return <AlertCircle className="h-4 w-4 text-destructive" />
}
function ValidationSection({
label,
result,
}: {
label: string
result: ValidationResult
}) {
return (
<div className="flex items-start gap-3 py-2">
<ValidationStatusIcon result={result} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{label}</p>
{result.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive mt-0.5">
{err}
</p>
))}
{result.warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-600 mt-0.5">
{warn}
</p>
))}
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
)}
</div>
</div>
)
}
export function ReviewSection({ state }: ReviewSectionProps) {
const validation = validateAll(state)
const totalTracks = state.tracks.length
const mainTracks = state.tracks.filter((t) => t.kind === 'MAIN').length
const awardTracks = state.tracks.filter((t) => t.kind === 'AWARD').length
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
const totalTransitions = state.tracks.reduce(
(sum, t) => sum + Math.max(0, t.stages.length - 1),
0
)
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
return (
<div className="space-y-6">
{/* Overall Status */}
<div
className={cn(
'rounded-lg border p-4',
validation.valid
? 'border-emerald-200 bg-emerald-50'
: 'border-destructive/30 bg-destructive/5'
)}
>
<div className="flex items-center gap-2">
{validation.valid ? (
<>
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="font-medium text-emerald-800">
Pipeline is ready to be saved
</p>
</>
) : (
<>
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="font-medium text-destructive">
Pipeline has validation errors that must be fixed
</p>
</>
)}
</div>
</div>
{/* Validation Checks */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Validation Checks</CardTitle>
</CardHeader>
<CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} />
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
<ValidationSection label="Notifications" result={validation.sections.notifications} />
</CardContent>
</Card>
{/* Structure Summary */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Structure Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<Layers className="h-3 w-3" />
Tracks
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalStages}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GitBranch className="h-3 w-3" />
Stages
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalTransitions}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<ArrowRight className="h-3 w-3" />
Transitions
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{enabledNotifications}</p>
<p className="text-xs text-muted-foreground">Notifications</p>
</div>
</div>
{/* Track breakdown */}
<div className="mt-4 space-y-2">
{state.tracks.map((track, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
track.kind === 'MAIN'
? 'bg-blue-100 text-blue-700'
: track.kind === 'AWARD'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-700'
)}
>
{track.kind}
</Badge>
<span>{track.name || '(unnamed)'}</span>
</div>
<span className="text-muted-foreground text-xs">
{track.stages.length} stages
</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}