Fix pipeline config crashes, settings UX, invite roles, seed expertise tags
- Fix critical crash when clicking Edit on INTAKE stage configs: normalize DB fileRequirements shape (type/required → acceptedMimeTypes/isRequired), add null guard in getActiveCategoriesFromMimeTypes - Fix config summary display for all stage types to handle seed data key mismatches (votingEnabled→juryVotingEnabled, minAssignmentsPerJuror→ minLoadPerJuror, deterministic.rules→rules, etc.) - Add AWARD_MASTER role to invite page dropdown and user router validations - Restructure settings sidebar: Tags and Webhooks as direct links instead of nested tabs, remove redundant Quick Links section - Seed 38 expertise tags across 7 categories (Marine Science, Technology, Policy, Conservation, Business, Education, Engineering) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -262,7 +262,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<FileTypePicker
|
||||
value={req.acceptedMimeTypes}
|
||||
value={req.acceptedMimeTypes ?? []}
|
||||
onChange={(mimeTypes) =>
|
||||
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { EditableCard } from '@/components/ui/editable-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
Inbox,
|
||||
Filter,
|
||||
@@ -78,8 +78,8 @@ function ConfigSummary({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Late Policy:</span>
|
||||
<span className="capitalize">{config.lateSubmissionPolicy}</span>
|
||||
{config.lateGraceHours > 0 && (
|
||||
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
|
||||
{(config.lateGraceHours ?? 0) > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.lateGraceHours}h grace)
|
||||
</span>
|
||||
@@ -94,32 +94,26 @@ function ConfigSummary({
|
||||
}
|
||||
|
||||
case 'FILTER': {
|
||||
const config = configJson as unknown as FilterConfig
|
||||
const raw = configJson as Record<string, unknown>
|
||||
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as unknown[] | undefined
|
||||
const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0
|
||||
const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai)
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Rules:</span>
|
||||
<span>{config.rules?.length ?? 0} eligibility rules</span>
|
||||
<span>{ruleCount} eligibility rules</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">AI Screening:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
|
||||
{aiEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.aiRubricEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Confidence:</span>
|
||||
<span>
|
||||
High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '}
|
||||
{config.aiConfidenceThresholds?.medium ?? 0.6}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Manual Queue:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
|
||||
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,23 +121,27 @@ function ConfigSummary({
|
||||
}
|
||||
|
||||
case 'EVALUATION': {
|
||||
const config = configJson as unknown as EvaluationConfig
|
||||
const raw = configJson as Record<string, unknown>
|
||||
const reviews = (raw.requiredReviews as number) ?? 3
|
||||
const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5
|
||||
const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20
|
||||
const overflow = (raw.overflowPolicy as string) ?? 'queue'
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Required Reviews:</span>
|
||||
<span>{config.requiredReviews ?? 3}</span>
|
||||
<span>{reviews}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Load per Juror:</span>
|
||||
<span>
|
||||
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
|
||||
{minLoad} - {maxLoad}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Overflow Policy:</span>
|
||||
<span className="capitalize">
|
||||
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
|
||||
{overflow.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,29 +180,32 @@ function ConfigSummary({
|
||||
}
|
||||
|
||||
case 'LIVE_FINAL': {
|
||||
const config = configJson as unknown as LiveFinalConfig
|
||||
const raw = configJson as Record<string, unknown>
|
||||
const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false
|
||||
const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false
|
||||
const audienceWeight = (raw.audienceVoteWeight as number) ?? 0
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Jury Voting:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
{juryEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Audience Voting:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
{audienceEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{config.audienceVotingEnabled && (
|
||||
{audienceEnabled && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.audienceVoteWeight}% weight)
|
||||
({Math.round(audienceWeight * 100)}% weight)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Reveal:</span>
|
||||
<span className="capitalize">{config.revealPolicy ?? 'ceremony'}</span>
|
||||
<span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -260,10 +261,21 @@ export function StageConfigEditor({
|
||||
const renderEditor = () => {
|
||||
switch (stageType) {
|
||||
case 'INTAKE': {
|
||||
const config = {
|
||||
const rawConfig = {
|
||||
...defaultIntakeConfig(),
|
||||
...(localConfig as object),
|
||||
} as IntakeConfig
|
||||
// Deep-normalize fileRequirements to handle DB shape mismatches
|
||||
const config: IntakeConfig = {
|
||||
...rawConfig,
|
||||
fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({
|
||||
name: req.name ?? '',
|
||||
description: req.description ?? '',
|
||||
acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'],
|
||||
maxSizeMB: req.maxSizeMB ?? 50,
|
||||
isRequired: req.isRequired ?? (req as Record<string, unknown>).required === true,
|
||||
})),
|
||||
}
|
||||
return (
|
||||
<IntakeSection
|
||||
config={config}
|
||||
@@ -272,10 +284,21 @@ export function StageConfigEditor({
|
||||
)
|
||||
}
|
||||
case 'FILTER': {
|
||||
const config = {
|
||||
const raw = localConfig as Record<string, unknown>
|
||||
// Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds
|
||||
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as FilterConfig['rules'] | undefined
|
||||
const seedBands = raw.confidenceBands as Record<string, Record<string, number>> | undefined
|
||||
const config: FilterConfig = {
|
||||
...defaultFilterConfig(),
|
||||
...(localConfig as object),
|
||||
} as FilterConfig
|
||||
...raw,
|
||||
rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules,
|
||||
aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai,
|
||||
aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? {
|
||||
high: seedBands.high?.threshold ?? 0.85,
|
||||
medium: seedBands.medium?.threshold ?? 0.6,
|
||||
low: seedBands.low?.threshold ?? 0.4,
|
||||
} : defaultFilterConfig().aiConfidenceThresholds),
|
||||
}
|
||||
return (
|
||||
<FilteringSection
|
||||
config={config}
|
||||
@@ -284,10 +307,15 @@ export function StageConfigEditor({
|
||||
)
|
||||
}
|
||||
case 'EVALUATION': {
|
||||
const config = {
|
||||
const raw = localConfig as Record<string, unknown>
|
||||
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
|
||||
const config: EvaluationConfig = {
|
||||
...defaultEvaluationConfig(),
|
||||
...(localConfig as object),
|
||||
} as EvaluationConfig
|
||||
...raw,
|
||||
requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews,
|
||||
minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror,
|
||||
maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror,
|
||||
}
|
||||
return (
|
||||
<AssignmentSection
|
||||
config={config}
|
||||
@@ -296,10 +324,15 @@ export function StageConfigEditor({
|
||||
)
|
||||
}
|
||||
case 'LIVE_FINAL': {
|
||||
const config = {
|
||||
const raw = localConfig as Record<string, unknown>
|
||||
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
|
||||
const config: LiveFinalConfig = {
|
||||
...defaultLiveConfig(),
|
||||
...(localConfig as object),
|
||||
} as LiveFinalConfig
|
||||
...raw,
|
||||
juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true,
|
||||
audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false,
|
||||
audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0,
|
||||
}
|
||||
return (
|
||||
<LiveFinalsSection
|
||||
config={config}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Webhook,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AISettingsForm } from './ai-settings-form'
|
||||
import { AIUsageCard } from './ai-usage-card'
|
||||
@@ -199,10 +198,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
||||
<Link href="/admin/settings/tags" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
</Link>
|
||||
<TabsTrigger value="analytics" className="gap-2 shrink-0">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
@@ -213,6 +212,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Webhook className="h-4 w-4" />
|
||||
Webhooks
|
||||
</Link>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="lg:flex lg:gap-8">
|
||||
@@ -279,10 +284,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Link href="/admin/settings/tags" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</Link>
|
||||
<TabsTrigger value="analytics" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
@@ -298,6 +304,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<Webhook className="h-4 w-4" />
|
||||
Webhooks
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -325,40 +336,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="tags">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5" />
|
||||
Expertise Tags
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage tags used for jury expertise, project categorization, and AI-powered matching
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expertise tags are used across the platform to:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>Categorize jury members by their areas of expertise</li>
|
||||
<li>Tag projects for better organization and filtering</li>
|
||||
<li>Power AI-based project tagging</li>
|
||||
<li>Enable smart jury-project matching</li>
|
||||
</ul>
|
||||
<Button asChild>
|
||||
<Link href="/admin/settings/tags">
|
||||
<Tags className="mr-2 h-4 w-4" />
|
||||
Manage Expertise Tags
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="branding">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
@@ -528,31 +505,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</div>{/* end lg:flex */}
|
||||
</Tabs>
|
||||
|
||||
{/* Quick Links to sub-pages */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{isSuperAdmin && (
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4" />
|
||||
Webhooks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure webhook endpoints for platform events
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link href="/admin/settings/webhooks">
|
||||
<Webhook className="mr-2 h-4 w-4" />
|
||||
Manage Webhooks
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user