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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user