UI/UX audit fixes: clickable pipelines, broken links, isActive locking
Build and Push Docker Image / build (push) Successful in 10m55s Details

- Make pipeline cards clickable on list page (navigate to detail view)
- Fix broken nav link: applicant /messages → /mentor
- Fix broken nav link: mentor /messages → /projects
- Add isActive field locking to all 7 wizard sections (intake, main-track,
  filtering, assignment, awards, live-finals, notifications)
- Add minLoad ≤ maxLoad cross-field validation in assignment section
- Add duplicate stage slug detection in main track section
- Add active pipeline warning banners in intake and main track sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-13 20:50:22 +01:00
parent 31225b099e
commit 7d1c87e938
11 changed files with 149 additions and 79 deletions

View File

@ -309,6 +309,7 @@ export default function EditPipelinePage() {
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
@ -323,6 +324,7 @@ export default function EditPipelinePage() {
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
isActive={isActive}
/>
</WizardSection>
@ -339,6 +341,7 @@ export default function EditPipelinePage() {
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
@ -355,6 +358,7 @@ export default function EditPipelinePage() {
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
@ -369,6 +373,7 @@ export default function EditPipelinePage() {
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
isActive={isActive}
/>
</WizardSection>
@ -385,6 +390,7 @@ export default function EditPipelinePage() {
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
@ -403,6 +409,7 @@ export default function EditPipelinePage() {
onOverridePolicyChange={(overridePolicy) =>
updateState({ overridePolicy })
}
isActive={isActive}
/>
</WizardSection>

View File

@ -133,7 +133,12 @@ export default function PipelineListPage() {
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => (
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow">
<Link
key={pipeline.id}
href={`/admin/rounds/pipeline/${pipeline.id}` as Route}
className="block"
>
<Card className="group hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
@ -160,6 +165,7 @@ export default function PipelineListPage() {
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.preventDefault()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -198,6 +204,7 @@ export default function PipelineListPage() {
</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}

View File

@ -250,7 +250,7 @@ export default function ApplicantPipelinePage() {
</div>
</Link>
<Link
href={"/applicant/messages" as Route}
href={"/applicant/mentor" as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">

View File

@ -189,7 +189,7 @@ export default function MentorDashboard() {
{/* Quick Actions */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={'/mentor/messages' as Route}>
<Link href={'/mentor/projects' as Route}>
<Mail className="mr-2 h-4 w-4" />
Messages
</Link>

View File

@ -15,9 +15,10 @@ import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
@ -32,6 +33,7 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
min={1}
max={20}
value={config.requiredReviews}
disabled={isActive}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
@ -48,6 +50,7 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
min={1}
max={100}
value={config.maxLoadPerJuror}
disabled={isActive}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
@ -64,6 +67,7 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
min={0}
max={50}
value={config.minLoadPerJuror}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
@ -74,6 +78,12 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
</div>
</div>
{config.minLoadPerJuror > config.maxLoadPerJuror && (
<p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror.
</p>
)}
<div className="flex items-center justify-between">
<div>
<Label>Availability Weighting</Label>
@ -86,6 +96,7 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
@ -98,6 +109,7 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />

View File

@ -31,6 +31,7 @@ import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
isActive?: boolean
}
function slugify(name: string): string {
@ -40,7 +41,7 @@ function slugify(name: string): string {
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
@ -72,7 +73,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
<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}>
<Button type="button" variant="outline" size="sm" onClick={addAward} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
@ -100,6 +101,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@ -129,6 +131,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
@ -151,6 +154,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
@ -178,6 +182,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
@ -203,6 +208,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
},
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />

View File

@ -19,9 +19,10 @@ import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
isActive?: boolean
}
export function FilteringSection({ config, onChange }: FilteringSectionProps) {
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
@ -57,7 +58,7 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
Deterministic rules that projects must pass. Applied in order.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule}>
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
@ -72,11 +73,13 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
placeholder="Field name"
value={rule.field}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { field: e.target.value })}
/>
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
@ -94,6 +97,7 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
placeholder="Value"
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
</div>
@ -103,6 +107,7 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@ -130,6 +135,7 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
@ -196,6 +202,7 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
disabled={isActive}
/>
</div>
</div>

View File

@ -18,9 +18,10 @@ import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizar
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
@ -54,6 +55,12 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -68,6 +75,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
@ -83,6 +91,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
@ -115,7 +124,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
<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}>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
@ -183,6 +192,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>

View File

@ -15,9 +15,10 @@ import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
@ -36,6 +37,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
@ -52,6 +54,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
@ -91,6 +94,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
@ -115,6 +119,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />

View File

@ -27,6 +27,7 @@ import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
@ -45,7 +46,7 @@ function slugify(name: string): string {
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
@ -95,15 +96,22 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
Drag to reorder. Minimum 2 stages required.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage}>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
@ -115,7 +123,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === 0}
disabled={isActive || index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
@ -126,7 +134,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === stages.length - 1}
disabled={isActive || index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
@ -143,12 +151,16 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
<Input
placeholder="Stage name"
value={stage.name}
className="h-8 text-sm"
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
@ -158,6 +170,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@ -186,7 +199,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={stages.length <= 2}
disabled={isActive || stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -10,6 +10,7 @@ type NotificationsSectionProps = {
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
const NOTIFICATION_EVENTS = [
@ -60,6 +61,7 @@ export function NotificationsSection({
onChange,
overridePolicy,
onOverridePolicyChange,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
@ -88,6 +90,7 @@ export function NotificationsSection({
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
disabled={isActive}
/>
</div>
</CardContent>