Fix pipeline config crashes, settings UX, invite roles, seed expertise tags
Build and Push Docker Image / build (push) Successful in 9m51s
Details
Build and Push Docker Image / build (push) Successful in 9m51s
Details
- 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:
parent
ae0ac58547
commit
c88f540633
|
|
@ -136,6 +136,80 @@ async function main() {
|
||||||
}
|
}
|
||||||
console.log(` Created ${settings.length} settings`)
|
console.log(` Created ${settings.length} settings`)
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 1b. Expertise Tags
|
||||||
|
// ==========================================================================
|
||||||
|
console.log('\n🏷️ Creating expertise tags...')
|
||||||
|
|
||||||
|
const expertiseTags = [
|
||||||
|
// Marine Science
|
||||||
|
{ name: 'Marine Biology', description: 'Study of marine organisms and ecosystems', category: 'Marine Science', color: '#0284c7', sortOrder: 0 },
|
||||||
|
{ name: 'Oceanography', description: 'Physical, chemical, and biological ocean science', category: 'Marine Science', color: '#0284c7', sortOrder: 1 },
|
||||||
|
{ name: 'Coral Reef Ecology', description: 'Coral reef ecosystems, health, and restoration', category: 'Marine Science', color: '#0284c7', sortOrder: 2 },
|
||||||
|
{ name: 'Marine Biodiversity', description: 'Species diversity and conservation in marine environments', category: 'Marine Science', color: '#0284c7', sortOrder: 3 },
|
||||||
|
{ name: 'Ocean Acidification', description: 'Chemical changes in ocean pH and their impacts', category: 'Marine Science', color: '#0284c7', sortOrder: 4 },
|
||||||
|
{ name: 'Deep Sea Research', description: 'Exploration and study of deep ocean environments', category: 'Marine Science', color: '#0284c7', sortOrder: 5 },
|
||||||
|
|
||||||
|
// Technology
|
||||||
|
{ name: 'Ocean Sensors & IoT', description: 'Sensor networks and IoT for ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 10 },
|
||||||
|
{ name: 'AI & Machine Learning', description: 'AI applications for ocean data analysis and prediction', category: 'Technology', color: '#7c3aed', sortOrder: 11 },
|
||||||
|
{ name: 'Robotics & AUVs', description: 'Autonomous underwater vehicles and marine robotics', category: 'Technology', color: '#7c3aed', sortOrder: 12 },
|
||||||
|
{ name: 'Satellite Remote Sensing', description: 'Earth observation and satellite-based ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 13 },
|
||||||
|
{ name: 'Marine Biotechnology', description: 'Biotechnological solutions from marine organisms', category: 'Technology', color: '#7c3aed', sortOrder: 14 },
|
||||||
|
{ name: 'Desalination', description: 'Water desalination and purification technologies', category: 'Technology', color: '#7c3aed', sortOrder: 15 },
|
||||||
|
|
||||||
|
// Policy
|
||||||
|
{ name: 'Maritime Law', description: 'International maritime regulations and legal frameworks', category: 'Policy', color: '#053d57', sortOrder: 20 },
|
||||||
|
{ name: 'Ocean Governance', description: 'International ocean policy and governance frameworks', category: 'Policy', color: '#053d57', sortOrder: 21 },
|
||||||
|
{ name: 'Marine Protected Areas', description: 'MPA design, management, and policy', category: 'Policy', color: '#053d57', sortOrder: 22 },
|
||||||
|
{ name: 'Climate Policy', description: 'Climate change mitigation and adaptation policy', category: 'Policy', color: '#053d57', sortOrder: 23 },
|
||||||
|
{ name: 'Sustainable Development Goals', description: 'SDG 14 (Life Below Water) and related goals', category: 'Policy', color: '#053d57', sortOrder: 24 },
|
||||||
|
|
||||||
|
// Conservation
|
||||||
|
{ name: 'Habitat Restoration', description: 'Restoration of mangroves, seagrass, and coastal habitats', category: 'Conservation', color: '#059669', sortOrder: 30 },
|
||||||
|
{ name: 'Species Protection', description: 'Endangered marine species conservation programs', category: 'Conservation', color: '#059669', sortOrder: 31 },
|
||||||
|
{ name: 'Pollution Reduction', description: 'Marine pollution prevention and cleanup', category: 'Conservation', color: '#059669', sortOrder: 32 },
|
||||||
|
{ name: 'Plastic Waste', description: 'Plastic pollution reduction and circular solutions', category: 'Conservation', color: '#059669', sortOrder: 33 },
|
||||||
|
{ name: 'Blue Carbon', description: 'Carbon sequestration in coastal and marine ecosystems', category: 'Conservation', color: '#059669', sortOrder: 34 },
|
||||||
|
{ name: 'Coastal Resilience', description: 'Building resilience in coastal communities and ecosystems', category: 'Conservation', color: '#059669', sortOrder: 35 },
|
||||||
|
|
||||||
|
// Business
|
||||||
|
{ name: 'Blue Economy', description: 'Sustainable economic use of ocean resources', category: 'Business', color: '#557f8c', sortOrder: 40 },
|
||||||
|
{ name: 'Impact Investing', description: 'Investment strategies for ocean-positive outcomes', category: 'Business', color: '#557f8c', sortOrder: 41 },
|
||||||
|
{ name: 'Startup Scaling', description: 'Scaling ocean-focused startups and ventures', category: 'Business', color: '#557f8c', sortOrder: 42 },
|
||||||
|
{ name: 'Sustainable Aquaculture', description: 'Sustainable fish farming and aquaculture practices', category: 'Business', color: '#557f8c', sortOrder: 43 },
|
||||||
|
{ name: 'Sustainable Shipping', description: 'Green shipping, fuel alternatives, and port operations', category: 'Business', color: '#557f8c', sortOrder: 44 },
|
||||||
|
{ name: 'Circular Economy', description: 'Circular models for marine industries and products', category: 'Business', color: '#557f8c', sortOrder: 45 },
|
||||||
|
|
||||||
|
// Education
|
||||||
|
{ name: 'Ocean Literacy', description: 'Public education and awareness about ocean health', category: 'Education', color: '#ea580c', sortOrder: 50 },
|
||||||
|
{ name: 'Community Engagement', description: 'Engaging coastal communities in conservation', category: 'Education', color: '#ea580c', sortOrder: 51 },
|
||||||
|
{ name: 'Citizen Science', description: 'Public participation in ocean data collection', category: 'Education', color: '#ea580c', sortOrder: 52 },
|
||||||
|
{ name: 'Capacity Building', description: 'Training and skill development for ocean stewardship', category: 'Education', color: '#ea580c', sortOrder: 53 },
|
||||||
|
|
||||||
|
// Engineering
|
||||||
|
{ name: 'Renewable Ocean Energy', description: 'Wave, tidal, and offshore wind energy systems', category: 'Engineering', color: '#be185d', sortOrder: 60 },
|
||||||
|
{ name: 'Coastal Engineering', description: 'Infrastructure design for coastal protection', category: 'Engineering', color: '#be185d', sortOrder: 61 },
|
||||||
|
{ name: 'Water Treatment', description: 'Wastewater treatment and water quality engineering', category: 'Engineering', color: '#be185d', sortOrder: 62 },
|
||||||
|
{ name: 'Marine Materials', description: 'Biodegradable and sustainable materials for marine use', category: 'Engineering', color: '#be185d', sortOrder: 63 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tag of expertiseTags) {
|
||||||
|
await prisma.expertiseTag.upsert({
|
||||||
|
where: { name: tag.name },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: tag.name,
|
||||||
|
description: tag.description,
|
||||||
|
category: tag.category,
|
||||||
|
color: tag.color,
|
||||||
|
sortOrder: tag.sortOrder,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(` Created ${expertiseTags.length} expertise tags across ${new Set(expertiseTags.map(t => t.category)).size} categories`)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 2. Admin/Staff Users
|
// 2. Admin/Staff Users
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ import {
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
projectId: string
|
projectId: string
|
||||||
|
|
@ -104,6 +104,7 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
SUPER_ADMIN: 'Super Admin',
|
SUPER_ADMIN: 'Super Admin',
|
||||||
PROGRAM_ADMIN: 'Program Admin',
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
|
AWARD_MASTER: 'Award Master',
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
MENTOR: 'Mentor',
|
MENTOR: 'Mentor',
|
||||||
OBSERVER: 'Observer',
|
OBSERVER: 'Observer',
|
||||||
|
|
@ -276,6 +277,7 @@ export default function MemberInvitePage() {
|
||||||
// Fetch current user to check role
|
// Fetch current user to check role
|
||||||
const { data: currentUser } = trpc.user.me.useQuery()
|
const { data: currentUser } = trpc.user.me.useQuery()
|
||||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||||
|
const isAdmin = isSuperAdmin || currentUser?.role === 'PROGRAM_ADMIN'
|
||||||
|
|
||||||
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -406,14 +408,16 @@ export default function MemberInvitePage() {
|
||||||
? 'SUPER_ADMIN'
|
? 'SUPER_ADMIN'
|
||||||
: rawRole === 'PROGRAM_ADMIN'
|
: rawRole === 'PROGRAM_ADMIN'
|
||||||
? 'PROGRAM_ADMIN'
|
? 'PROGRAM_ADMIN'
|
||||||
: rawRole === 'MENTOR'
|
: rawRole === 'AWARD_MASTER'
|
||||||
? 'MENTOR'
|
? 'AWARD_MASTER'
|
||||||
: rawRole === 'OBSERVER'
|
: rawRole === 'MENTOR'
|
||||||
? 'OBSERVER'
|
? 'MENTOR'
|
||||||
: 'JURY_MEMBER'
|
: rawRole === 'OBSERVER'
|
||||||
|
? 'OBSERVER'
|
||||||
|
: 'JURY_MEMBER'
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = email ? seenEmails.has(email) : false
|
const isDuplicate = email ? seenEmails.has(email) : false
|
||||||
const isUnauthorizedAdmin = (role === 'PROGRAM_ADMIN' || role === 'SUPER_ADMIN') && !isSuperAdmin
|
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
||||||
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
||||||
return {
|
return {
|
||||||
email,
|
email,
|
||||||
|
|
@ -428,7 +432,7 @@ export default function MemberInvitePage() {
|
||||||
: isDuplicate
|
: isDuplicate
|
||||||
? 'Duplicate email'
|
? 'Duplicate email'
|
||||||
: isUnauthorizedAdmin
|
: isUnauthorizedAdmin
|
||||||
? 'Only super admins can invite program admins'
|
? 'Only super admins can invite super admins'
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -449,7 +453,7 @@ export default function MemberInvitePage() {
|
||||||
const email = r.email.trim().toLowerCase()
|
const email = r.email.trim().toLowerCase()
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = seenEmails.has(email)
|
const isDuplicate = seenEmails.has(email)
|
||||||
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
|
const isUnauthorizedAdmin = r.role === 'SUPER_ADMIN' && !isSuperAdmin
|
||||||
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
||||||
return {
|
return {
|
||||||
email,
|
email,
|
||||||
|
|
@ -464,7 +468,7 @@ export default function MemberInvitePage() {
|
||||||
: isDuplicate
|
: isDuplicate
|
||||||
? 'Duplicate email'
|
? 'Duplicate email'
|
||||||
: isUnauthorizedAdmin
|
: isUnauthorizedAdmin
|
||||||
? 'Only super admins can invite program admins'
|
? 'Only super admins can invite super admins'
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -547,7 +551,7 @@ export default function MemberInvitePage() {
|
||||||
Add members individually or upload a CSV file
|
Add members individually or upload a CSV file
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<span className="block mt-1 text-primary font-medium">
|
<span className="block mt-1 text-primary font-medium">
|
||||||
As a super admin, you can also invite program admins
|
As a super admin, you can also invite super admins
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
@ -658,11 +662,16 @@ export default function MemberInvitePage() {
|
||||||
Super Admin
|
Super Admin
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
{isSuperAdmin && (
|
{isAdmin && (
|
||||||
<SelectItem value="PROGRAM_ADMIN">
|
<SelectItem value="PROGRAM_ADMIN">
|
||||||
Program Admin
|
Program Admin
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<SelectItem value="AWARD_MASTER">
|
||||||
|
Award Master
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="JURY_MEMBER">
|
<SelectItem value="JURY_MEMBER">
|
||||||
Jury Member
|
Jury Member
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<FileTypePicker
|
<FileTypePicker
|
||||||
value={req.acceptedMimeTypes}
|
value={req.acceptedMimeTypes ?? []}
|
||||||
onChange={(mimeTypes) =>
|
onChange={(mimeTypes) =>
|
||||||
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { EditableCard } from '@/components/ui/editable-card'
|
import { EditableCard } from '@/components/ui/editable-card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
Filter,
|
Filter,
|
||||||
|
|
@ -78,8 +78,8 @@ function ConfigSummary({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Late Policy:</span>
|
<span className="text-muted-foreground">Late Policy:</span>
|
||||||
<span className="capitalize">{config.lateSubmissionPolicy}</span>
|
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
|
||||||
{config.lateGraceHours > 0 && (
|
{(config.lateGraceHours ?? 0) > 0 && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({config.lateGraceHours}h grace)
|
({config.lateGraceHours}h grace)
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -94,32 +94,26 @@ function ConfigSummary({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'FILTER': {
|
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 (
|
return (
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Rules:</span>
|
<span className="text-muted-foreground">Rules:</span>
|
||||||
<span>{config.rules?.length ?? 0} eligibility rules</span>
|
<span>{ruleCount} eligibility rules</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">AI Screening:</span>
|
<span className="text-muted-foreground">AI Screening:</span>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
|
{aiEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Manual Queue:</span>
|
<span className="text-muted-foreground">Manual Queue:</span>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
|
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,23 +121,27 @@ function ConfigSummary({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'EVALUATION': {
|
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 (
|
return (
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Required Reviews:</span>
|
<span className="text-muted-foreground">Required Reviews:</span>
|
||||||
<span>{config.requiredReviews ?? 3}</span>
|
<span>{reviews}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Load per Juror:</span>
|
<span className="text-muted-foreground">Load per Juror:</span>
|
||||||
<span>
|
<span>
|
||||||
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
|
{minLoad} - {maxLoad}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Overflow Policy:</span>
|
<span className="text-muted-foreground">Overflow Policy:</span>
|
||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
|
{overflow.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,29 +180,32 @@ function ConfigSummary({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'LIVE_FINAL': {
|
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 (
|
return (
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Jury Voting:</span>
|
<span className="text-muted-foreground">Jury Voting:</span>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
|
{juryEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Audience Voting:</span>
|
<span className="text-muted-foreground">Audience Voting:</span>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
|
{audienceEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{config.audienceVotingEnabled && (
|
{audienceEnabled && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({config.audienceVoteWeight}% weight)
|
({Math.round(audienceWeight * 100)}% weight)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Reveal:</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -260,10 +261,21 @@ export function StageConfigEditor({
|
||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
switch (stageType) {
|
switch (stageType) {
|
||||||
case 'INTAKE': {
|
case 'INTAKE': {
|
||||||
const config = {
|
const rawConfig = {
|
||||||
...defaultIntakeConfig(),
|
...defaultIntakeConfig(),
|
||||||
...(localConfig as object),
|
...(localConfig as object),
|
||||||
} as IntakeConfig
|
} 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 (
|
return (
|
||||||
<IntakeSection
|
<IntakeSection
|
||||||
config={config}
|
config={config}
|
||||||
|
|
@ -272,10 +284,21 @@ export function StageConfigEditor({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'FILTER': {
|
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(),
|
...defaultFilterConfig(),
|
||||||
...(localConfig as object),
|
...raw,
|
||||||
} as FilterConfig
|
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 (
|
return (
|
||||||
<FilteringSection
|
<FilteringSection
|
||||||
config={config}
|
config={config}
|
||||||
|
|
@ -284,10 +307,15 @@ export function StageConfigEditor({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'EVALUATION': {
|
case 'EVALUATION': {
|
||||||
const config = {
|
const raw = localConfig as Record<string, unknown>
|
||||||
|
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
|
||||||
|
const config: EvaluationConfig = {
|
||||||
...defaultEvaluationConfig(),
|
...defaultEvaluationConfig(),
|
||||||
...(localConfig as object),
|
...raw,
|
||||||
} as EvaluationConfig
|
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 (
|
return (
|
||||||
<AssignmentSection
|
<AssignmentSection
|
||||||
config={config}
|
config={config}
|
||||||
|
|
@ -296,10 +324,15 @@ export function StageConfigEditor({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'LIVE_FINAL': {
|
case 'LIVE_FINAL': {
|
||||||
const config = {
|
const raw = localConfig as Record<string, unknown>
|
||||||
|
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
|
||||||
|
const config: LiveFinalConfig = {
|
||||||
...defaultLiveConfig(),
|
...defaultLiveConfig(),
|
||||||
...(localConfig as object),
|
...raw,
|
||||||
} as LiveFinalConfig
|
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 (
|
return (
|
||||||
<LiveFinalsSection
|
<LiveFinalsSection
|
||||||
config={config}
|
config={config}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
Webhook,
|
Webhook,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { AISettingsForm } from './ai-settings-form'
|
import { AISettingsForm } from './ai-settings-form'
|
||||||
import { AIUsageCard } from './ai-usage-card'
|
import { AIUsageCard } from './ai-usage-card'
|
||||||
|
|
@ -199,10 +198,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
AI
|
AI
|
||||||
</TabsTrigger>
|
</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 className="h-4 w-4" />
|
||||||
Tags
|
Tags
|
||||||
</TabsTrigger>
|
</Link>
|
||||||
<TabsTrigger value="analytics" className="gap-2 shrink-0">
|
<TabsTrigger value="analytics" className="gap-2 shrink-0">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Analytics
|
Analytics
|
||||||
|
|
@ -213,6 +212,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
Storage
|
Storage
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
|
|
@ -279,10 +284,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
AI
|
AI
|
||||||
</TabsTrigger>
|
</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 className="h-4 w-4" />
|
||||||
Tags
|
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">
|
<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" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Analytics
|
Analytics
|
||||||
|
|
@ -298,6 +304,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
Storage
|
Storage
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -325,40 +336,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="branding">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -528,31 +505,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
|
||||||
|
|
||||||
/** Get active category IDs from a list of mime types */
|
/** Get active category IDs from a list of mime types */
|
||||||
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
|
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
|
||||||
|
if (!mimeTypes || !Array.isArray(mimeTypes)) return []
|
||||||
return FILE_TYPE_CATEGORIES.filter((cat) =>
|
return FILE_TYPE_CATEGORIES.filter((cat) =>
|
||||||
cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
|
cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
|
||||||
).map((cat) => cat.id)
|
).map((cat) => cat.id)
|
||||||
|
|
|
||||||
|
|
@ -183,8 +183,8 @@ export const userRouter = router({
|
||||||
list: adminProcedure
|
list: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
|
|
@ -274,7 +274,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
maxAssignments: z.number().int().min(1).max(100).optional(),
|
maxAssignments: z.number().int().min(1).max(100).optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -339,7 +339,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
|
|
@ -472,7 +472,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
// Optional pre-assignments for jury members
|
// Optional pre-assignments for jury members
|
||||||
assignments: z
|
assignments: z
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue