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:
120
src/components/admin/pipeline/sections/assignment-section.tsx
Normal file
120
src/components/admin/pipeline/sections/assignment-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
src/components/admin/pipeline/sections/awards-section.tsx
Normal file
241
src/components/admin/pipeline/sections/awards-section.tsx
Normal 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 "{track.name}" 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>
|
||||
)
|
||||
}
|
||||
92
src/components/admin/pipeline/sections/basics-section.tsx
Normal file
92
src/components/admin/pipeline/sections/basics-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
src/components/admin/pipeline/sections/filtering-section.tsx
Normal file
203
src/components/admin/pipeline/sections/filtering-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
src/components/admin/pipeline/sections/intake-section.tsx
Normal file
196
src/components/admin/pipeline/sections/intake-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
src/components/admin/pipeline/sections/live-finals-section.tsx
Normal file
137
src/components/admin/pipeline/sections/live-finals-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
src/components/admin/pipeline/sections/main-track-section.tsx
Normal file
208
src/components/admin/pipeline/sections/main-track-section.tsx
Normal 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 "Add Stage" to begin.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
src/components/admin/pipeline/sections/notifications-section.tsx
Normal file
161
src/components/admin/pipeline/sections/notifications-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/components/admin/pipeline/sections/review-section.tsx
Normal file
173
src/components/admin/pipeline/sections/review-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user