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,121 @@
'use client'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { ArrowRight } from 'lucide-react'
type StageNode = {
id?: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type TrackLane = {
id?: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineVisualizationProps = {
tracks: TrackLane[]
className?: string
}
const stageColors: Record<string, string> = {
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
}
const trackKindBadge: Record<string, string> = {
MAIN: 'bg-blue-100 text-blue-700',
AWARD: 'bg-amber-100 text-amber-700',
SHOWCASE: 'bg-purple-100 text-purple-700',
}
export function PipelineVisualization({
tracks,
className,
}: PipelineVisualizationProps) {
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div className={cn('space-y-4', className)}>
{sortedTracks.map((track) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
return (
<Card key={track.id ?? track.name} className="p-4">
{/* Track header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">{track.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[10px] h-5',
trackKindBadge[track.kind] ?? ''
)}
>
{track.kind}
</Badge>
</div>
{/* Stage flow */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{sortedStages.map((stage, index) => (
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
<div
className={cn(
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
)}
>
<span className="text-xs font-medium text-center leading-tight">
{stage.name}
</span>
<span className="text-[10px] opacity-70 mt-0.5">
{stage.stageType.replace('_', ' ')}
</span>
{stage._count?.projectStageStates !== undefined &&
stage._count.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[9px] h-4 px-1 mt-1"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
{index < sortedStages.length - 1 && (
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
</div>
))}
{sortedStages.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No stages
</span>
)}
</div>
</Card>
)
})}
{tracks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No tracks to visualize
</p>
)}
</div>
)
}

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

View File

@@ -0,0 +1,136 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Users, ClipboardList, BarChart3 } from 'lucide-react'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type EvaluationPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function EvaluationPanel({ stageId, configJson }: EvaluationPanelProps) {
const config = configJson as unknown as EvaluationConfig | null
const { data: coverage, isLoading: coverageLoading } =
trpc.stageAssignment.getCoverageReport.useQuery({ stageId })
const { data: projectStates, isLoading: statesLoading } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const totalProjects = projectStates?.items.length ?? 0
const requiredReviews = config?.requiredReviews ?? 3
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Required Reviews</span>
</div>
<p className="text-2xl font-bold mt-1">{requiredReviews}</p>
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Juror Load</span>
</div>
<p className="text-lg font-bold mt-1">
{config?.minLoadPerJuror ?? 5}{config?.maxLoadPerJuror ?? 20}
</p>
<p className="text-xs text-muted-foreground">projects per juror</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Projects</span>
</div>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in stage</p>
</CardContent>
</Card>
</div>
{/* Coverage Report */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Assignment Coverage</CardTitle>
</CardHeader>
<CardContent>
{coverageLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : coverage ? (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Coverage</span>
<span className="font-medium">
{coverage.fullyCoveredProjects}/{coverage.totalProjectsInStage} projects
</span>
</div>
<Progress
value={
coverage.totalProjectsInStage > 0
? (coverage.fullyCoveredProjects / coverage.totalProjectsInStage) * 100
: 0
}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{coverage.fullyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Fully Covered</p>
</div>
<div>
<p className="font-bold text-amber-600">{coverage.partiallyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</div>
<div>
<p className="font-bold text-destructive">{coverage.uncoveredProjects}</p>
<p className="text-xs text-muted-foreground">Unassigned</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-3 text-center">
No coverage data available
</p>
)}
</CardContent>
</Card>
{/* Overflow Policy */}
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overflow Policy</span>
<Badge variant="outline" className="text-xs capitalize">
{config?.overflowPolicy ?? 'queue'}
</Badge>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm font-medium">Availability Weighting</span>
<Badge variant="outline" className="text-xs">
{config?.availabilityWeighting ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, 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 { toast } from 'sonner'
import { Filter, Play, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
import type { FilterConfig } from '@/types/pipeline-wizard'
type FilterPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
const config = configJson as unknown as FilterConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 50,
})
const runFiltering = trpc.stageFiltering.runStageFiltering.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering complete: ${data.passedCount} passed, ${data.rejectedCount} filtered`
)
},
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending = projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{totalProjects}</p>
<p className="text-xs text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<CheckCircle2 className="h-3 w-3" /> Passed
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<XCircle className="h-3 w-3" /> Filtered
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<AlertTriangle className="h-3 w-3" /> Pending
</p>
</CardContent>
</Card>
</div>
{/* Rules Summary */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtering Rules
</CardTitle>
<Button
size="sm"
variant="outline"
disabled={runFiltering.isPending || pending === 0}
onClick={() => runFiltering.mutate({ stageId })}
>
{runFiltering.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Filtering
</Button>
</div>
</CardHeader>
<CardContent>
{config?.rules && config.rules.length > 0 ? (
<div className="space-y-1">
{config.rules.map((rule, i) => (
<div
key={i}
className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
>
<Badge variant="outline" className="text-[10px] font-mono">
{rule.field}
</Badge>
<span className="text-muted-foreground">{rule.operator}</span>
<span className="font-mono text-xs">{String(rule.value)}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-3">
No deterministic rules configured.
{config?.aiRubricEnabled ? ' AI screening is enabled.' : ''}
</p>
)}
{config?.aiRubricEnabled && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs text-muted-foreground">
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
</p>
</div>
)}
</CardContent>
</Card>
{/* Projects List */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Projects in Stage</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span className="truncate">{ps.project.title}</span>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Upload, Clock, AlertTriangle } from 'lucide-react'
import type { IntakeConfig } from '@/types/pipeline-wizard'
type IntakePanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
const config = configJson as unknown as IntakeConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 10,
})
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Submission Window</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Late Policy</span>
</div>
<p className="text-xs text-muted-foreground mt-1 capitalize">
{config?.lateSubmissionPolicy ?? 'Not set'}
{config?.lateSubmissionPolicy === 'flag' && ` (${config.lateGraceHours}h grace)`}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">File Requirements</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.fileRequirements?.length ?? 0} requirements
</p>
</CardContent>
</Card>
</div>
{/* File Requirements List */}
{config?.fileRequirements && config.fileRequirements.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">File Requirements</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{config.fileRequirements.map((req, i) => (
<div
key={i}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span>{req.name}</span>
{req.isRequired && (
<Badge variant="secondary" className="text-[10px]">
Required
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{req.maxSizeMB ? `${req.maxSizeMB} MB max` : 'No limit'}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Projects */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Recent Submissions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage yet
</p>
) : (
<div className="space-y-1">
{projectStates.items.map((ps) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Play, Users, Vote, Radio, Loader2, Layers } from 'lucide-react'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function LiveFinalPanel({ stageId, configJson }: LiveFinalPanelProps) {
const config = configJson as unknown as LiveFinalConfig | null
const { data: projectStates } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const { data: cohorts, isLoading: cohortsLoading } = trpc.cohort.list.useQuery({
stageId,
})
const startSession = trpc.live.start.useMutation({
onSuccess: () => toast.success('Live session started'),
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const totalCohorts = cohorts?.length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Vote className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Jury Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.juryVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Audience Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{config?.audienceVotingEnabled && (
<p className="text-xs text-muted-foreground mt-1">
Weight: {Math.round((config.audienceVoteWeight ?? 0.2) * 100)}%
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Reveal Policy</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.revealPolicy ?? 'ceremony'}
</p>
</CardContent>
</Card>
</div>
{/* Cohorts */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Cohorts
</CardTitle>
<Badge variant="secondary" className="text-xs">
{totalCohorts} cohort{totalCohorts !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent>
{cohortsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : !cohorts?.length ? (
<p className="text-sm text-muted-foreground py-3 text-center">
No cohorts configured.{' '}
{config?.cohortSetupMode === 'auto'
? 'Cohorts will be auto-generated when the session starts.'
: 'Create cohorts manually to organize presentations.'}
</p>
) : (
<div className="space-y-1">
{cohorts.map((cohort) => (
<div
key={cohort.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span>{cohort.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">
{cohort._count?.projects ?? 0} projects
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${
cohort.isOpen
? 'border-emerald-500 text-emerald-600'
: ''
}`}
>
{cohort.isOpen ? 'OPEN' : 'CLOSED'}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Live Session Controls */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Live Session</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
{totalProjects} project{totalProjects !== 1 ? 's' : ''} ready for
presentation
</p>
<p className="text-xs text-muted-foreground">
Cohort mode: {config?.cohortSetupMode ?? 'auto'}
</p>
</div>
<Button
size="sm"
disabled={startSession.isPending || totalProjects === 0}
onClick={() =>
startSession.mutate({
stageId,
projectOrder: projectStates?.items.map((p) => p.project.id) ?? [],
})
}
>
{startSession.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Start Session
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy, Medal, FileText } from 'lucide-react'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function ResultsPanel({ stageId, configJson }: ResultsPanelProps) {
const config = configJson as unknown as ResultsConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const winners =
projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Winners</span>
</div>
<p className="text-2xl font-bold mt-1">{winners}</p>
<p className="text-xs text-muted-foreground">
of {totalProjects} finalists
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Publication</span>
</div>
<Badge variant="outline" className="mt-1 text-xs capitalize">
{config?.publicationMode ?? 'manual'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Medal className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Rankings</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.showRankings ? 'Visible' : 'Hidden'}
</Badge>
</CardContent>
</Card>
</div>
{/* Final Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Final Results</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No results available yet
</p>
) : (
<div className="space-y-1 max-h-80 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
{ps.state === 'PASSED' && index < 3 ? (
<span className="text-lg">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
</span>
) : (
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
)}
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-amber-500 text-amber-600'
: ''
}`}
>
{ps.state === 'PASSED' ? 'Winner' : ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Trophy, Users, ArrowUpDown } from 'lucide-react'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
const config = configJson as unknown as SelectionConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending =
projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
const finalistTarget = config?.finalistCount ?? 6
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Finalist Target</span>
</div>
<p className="text-2xl font-bold mt-1">{finalistTarget}</p>
<p className="text-xs text-muted-foreground">to be selected</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Candidates</span>
</div>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in selection pool</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Ranking Mode</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.rankingMethod ?? 'score_average'}
</p>
<p className="text-xs text-muted-foreground">
{config?.tieBreaker ?? 'admin_decides'} tiebreak
</p>
</CardContent>
</Card>
</div>
{/* Selection Progress */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Selection Progress</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Selected</span>
<span className="font-medium">
{passed}/{finalistTarget} finalists
</span>
</div>
<Progress
value={finalistTarget > 0 ? (passed / finalistTarget) * 100 : 0}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground">Selected</p>
</div>
<div>
<p className="font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground">Eliminated</p>
</div>
<div>
<p className="font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Project Rankings</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in selection stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Badge } from '@/components/ui/badge'
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
type WizardSectionProps = {
title: string
description?: string
stepNumber: number
isOpen: boolean
onToggle: () => void
isValid: boolean
hasErrors?: boolean
children: React.ReactNode
}
export function WizardSection({
title,
description,
stepNumber,
isOpen,
onToggle,
isValid,
hasErrors,
children,
}: WizardSectionProps) {
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<Badge
variant={isValid ? 'default' : 'outline'}
className={cn(
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
isValid
? 'bg-emerald-500 text-white hover:bg-emerald-500'
: hasErrors
? 'border-destructive text-destructive'
: ''
)}
>
{isValid ? (
<CheckCircle2 className="h-4 w-4" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4" />
) : (
stepNumber
)}
</Badge>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold">{title}</h3>
{description && !isOpen && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{description}
</p>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}