Compare commits
10 Commits
dc075f8969
...
9ee767b6cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee767b6cd | ||
|
|
0afd4d97c6 | ||
|
|
2a374195c4 | ||
| c88f540633 | |||
| ae0ac58547 | |||
| 59f90ccc37 | |||
| 70cfad7d46 | |||
| 451b483880 | |||
| 7d1c87e938 | |||
| 31225b099e |
@@ -281,7 +281,8 @@ ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_roundId_fkey";
|
|||||||
-- Make TaggingJob.roundId nullable
|
-- Make TaggingJob.roundId nullable
|
||||||
DO $$ BEGIN ALTER TABLE "TaggingJob" ALTER COLUMN "roundId" DROP NOT NULL; EXCEPTION WHEN others THEN NULL; END $$;
|
DO $$ BEGIN ALTER TABLE "TaggingJob" ALTER COLUMN "roundId" DROP NOT NULL; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- Drop Round table and its enums (model retired in Phase 6)
|
-- Drop Round and RoundTemplate tables and their enums (models retired in Phase 6)
|
||||||
|
DROP TABLE IF EXISTS "RoundTemplate" CASCADE;
|
||||||
DROP TABLE IF EXISTS "Round" CASCADE;
|
DROP TABLE IF EXISTS "Round" CASCADE;
|
||||||
DROP TYPE IF EXISTS "RoundStatus";
|
DROP TYPE IF EXISTS "RoundStatus";
|
||||||
DROP TYPE IF EXISTS "RoundType";
|
DROP TYPE IF EXISTS "RoundType";
|
||||||
|
|||||||
@@ -136,6 +136,85 @@ 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 = [
|
||||||
|
// Pollution & Waste — aligned with MOPC OceanIssue: POLLUTION_REDUCTION
|
||||||
|
{ name: 'Plastic Pollution Solutions', description: 'Technologies and methods to reduce marine plastic debris, microplastics, and single-use packaging', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 0 },
|
||||||
|
{ name: 'Oil Spill Prevention & Response', description: 'Tools and systems for preventing and cleaning up oil and chemical spills at sea', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 1 },
|
||||||
|
{ name: 'Wastewater & Runoff Treatment', description: 'Filtering agricultural runoff, industrial discharge, and urban wastewater before it reaches the ocean', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 2 },
|
||||||
|
{ name: 'Marine Debris Cleanup', description: 'Ocean and coastal cleanup technologies, collection vessels, and waste recovery systems', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 3 },
|
||||||
|
{ name: 'Circular Economy & Recycling', description: 'Upcycling ocean waste, circular packaging, and zero-waste supply chains for coastal industries', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 4 },
|
||||||
|
|
||||||
|
// Climate & Carbon — aligned with MOPC OceanIssue: CLIMATE_MITIGATION, BLUE_CARBON, OCEAN_ACIDIFICATION
|
||||||
|
{ name: 'Blue Carbon Ecosystems', description: 'Conservation and restoration of mangroves, seagrass beds, and salt marshes for carbon sequestration', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 10 },
|
||||||
|
{ name: 'Ocean Acidification Mitigation', description: 'Solutions addressing declining ocean pH, alkalinity enhancement, and impacts on calcifying organisms', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 11 },
|
||||||
|
{ name: 'Climate Adaptation for Coasts', description: 'Nature-based solutions and infrastructure protecting coastal communities from rising seas and storms', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 12 },
|
||||||
|
{ name: 'Renewable Ocean Energy', description: 'Wave, tidal, offshore wind, and ocean thermal energy conversion technologies', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 13 },
|
||||||
|
{ name: 'Carbon Capture & Sequestration', description: 'Marine-based carbon dioxide removal technologies including algae, mineralization, and ocean fertilization', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 14 },
|
||||||
|
|
||||||
|
// Sustainable Seafood & Aquaculture — aligned with MOPC OceanIssue: SUSTAINABLE_FISHING
|
||||||
|
{ name: 'Sustainable Aquaculture', description: 'Low-impact fish and shellfish farming, alternative feeds (e.g., seaweed, insect-based), and recirculating systems', category: 'Seafood & Aquaculture', color: '#059669', sortOrder: 20 },
|
||||||
|
{ name: 'Overfishing Prevention', description: 'Monitoring, traceability, and enforcement tools to combat illegal and unsustainable fishing', category: 'Seafood & Aquaculture', color: '#059669', sortOrder: 21 },
|
||||||
|
{ name: 'Seafood Traceability & Supply Chain', description: 'Blockchain, IoT, and certification systems ensuring sustainable and ethical seafood sourcing', category: 'Seafood & Aquaculture', color: '#059669', sortOrder: 22 },
|
||||||
|
{ name: 'Algae & Seaweed Innovation', description: 'Cultivation, processing, and applications of macroalgae and microalgae for food, feed, biomaterials, and biofuels', category: 'Seafood & Aquaculture', color: '#059669', sortOrder: 23 },
|
||||||
|
{ name: 'Small-Scale Fisheries & Hatcheries', description: 'Support for artisanal fishing communities, small-scale hatchery technology, and local fisheries management', category: 'Seafood & Aquaculture', color: '#059669', sortOrder: 24 },
|
||||||
|
|
||||||
|
// Marine Biodiversity & Habitat — aligned with MOPC OceanIssue: HABITAT_RESTORATION
|
||||||
|
{ name: 'Coral Reef Restoration', description: 'Technologies for coral propagation, transplantation, reef structure creation, and resilience monitoring', category: 'Biodiversity & Habitat', color: '#7c3aed', sortOrder: 30 },
|
||||||
|
{ name: 'Marine Protected Areas', description: 'Design, monitoring, and management of MPAs and marine spatial planning', category: 'Biodiversity & Habitat', color: '#7c3aed', sortOrder: 31 },
|
||||||
|
{ name: 'Endangered Species Conservation', description: 'Protection programs for marine mammals, sea turtles, sharks, and other threatened species', category: 'Biodiversity & Habitat', color: '#7c3aed', sortOrder: 32 },
|
||||||
|
{ name: 'Coastal & Wetland Restoration', description: 'Restoring marshes, estuaries, dunes, and other coastal habitats for biodiversity and resilience', category: 'Biodiversity & Habitat', color: '#7c3aed', sortOrder: 33 },
|
||||||
|
{ name: 'Invasive Species Management', description: 'Detection, monitoring, and control of invasive marine organisms and ballast water management', category: 'Biodiversity & Habitat', color: '#7c3aed', sortOrder: 34 },
|
||||||
|
|
||||||
|
// Ocean Technology & Innovation — aligned with MOPC OceanIssue: TECHNOLOGY_INNOVATION
|
||||||
|
{ name: 'Ocean Monitoring & Sensors', description: 'IoT sensors, buoys, and autonomous platforms for real-time ocean data collection', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 40 },
|
||||||
|
{ name: 'Underwater Robotics & AUVs', description: 'Autonomous underwater vehicles, ROVs, and marine drones for exploration and monitoring', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 41 },
|
||||||
|
{ name: 'AI & Data Analytics for Oceans', description: 'Machine learning and big data applications for ocean health prediction, species identification, and pattern detection', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 42 },
|
||||||
|
{ name: 'Satellite & Remote Sensing', description: 'Earth observation, hyperspectral imaging, and satellite-based ocean monitoring', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 43 },
|
||||||
|
{ name: 'Marine Biotechnology', description: 'Bio-inspired materials, biomimicry, marine-derived pharmaceuticals, and bioplastics from ocean organisms', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 44 },
|
||||||
|
{ name: 'Desalination & Water Purification', description: 'Energy-efficient desalination, membrane technology, and portable water purification systems', category: 'Ocean Technology', color: '#7c3aed', sortOrder: 45 },
|
||||||
|
|
||||||
|
// Sustainable Shipping & Ports — aligned with MOPC OceanIssue: SUSTAINABLE_SHIPPING
|
||||||
|
{ name: 'Green Shipping & Fuels', description: 'Alternative marine fuels (hydrogen, ammonia, LNG), electric vessels, and emission reduction', category: 'Shipping & Ports', color: '#053d57', sortOrder: 50 },
|
||||||
|
{ name: 'Port Sustainability', description: 'Shore power, smart port logistics, and environmental impact reduction in harbors', category: 'Shipping & Ports', color: '#053d57', sortOrder: 51 },
|
||||||
|
{ name: 'Anti-fouling & Hull Technology', description: 'Non-toxic anti-fouling coatings, hull cleaning, and drag reduction for vessels', category: 'Shipping & Ports', color: '#053d57', sortOrder: 52 },
|
||||||
|
{ name: 'Underwater Noise Reduction', description: 'Technologies and practices to reduce vessel noise impact on marine life', category: 'Shipping & Ports', color: '#053d57', sortOrder: 53 },
|
||||||
|
|
||||||
|
// Community & Education — aligned with MOPC OceanIssue: COMMUNITY_CAPACITY, CONSUMER_AWARENESS
|
||||||
|
{ name: 'Coastal Community Development', description: 'Livelihood programs, capacity building, and economic alternatives for fishing-dependent communities', category: 'Community & Education', color: '#ea580c', sortOrder: 60 },
|
||||||
|
{ name: 'Ocean Literacy & Education', description: 'Educational programs, curricula, and outreach to increase public ocean awareness', category: 'Community & Education', color: '#ea580c', sortOrder: 61 },
|
||||||
|
{ name: 'Citizen Science & Engagement', description: 'Public participation platforms for ocean data collection, species reporting, and conservation', category: 'Community & Education', color: '#ea580c', sortOrder: 62 },
|
||||||
|
{ name: 'Ecotourism & Responsible Tourism', description: 'Sustainable marine tourism models that support conservation and local economies', category: 'Community & Education', color: '#ea580c', sortOrder: 63 },
|
||||||
|
{ name: 'Consumer Awareness & Labeling', description: 'Eco-labels, consumer apps, and awareness campaigns for sustainable ocean products', category: 'Community & Education', color: '#ea580c', sortOrder: 64 },
|
||||||
|
|
||||||
|
// Business & Investment — aligned with MOPC competition structure (Startup / Business Concept)
|
||||||
|
{ name: 'Blue Economy & Finance', description: 'Sustainable ocean economy models, blue bonds, and financial mechanisms for ocean projects', category: 'Business & Investment', color: '#557f8c', sortOrder: 70 },
|
||||||
|
{ name: 'Impact Investing & ESG', description: 'Ocean-focused impact funds, ESG frameworks, and blended finance for marine conservation', category: 'Business & Investment', color: '#557f8c', sortOrder: 71 },
|
||||||
|
{ name: 'Startup Acceleration', description: 'Scaling early-stage ocean startups, go-to-market strategy, and business model validation', category: 'Business & Investment', color: '#557f8c', sortOrder: 72 },
|
||||||
|
{ name: 'Ocean Policy & Governance', description: 'International maritime law, regulatory frameworks, and ocean governance institutions', category: 'Business & Investment', color: '#557f8c', sortOrder: 73 },
|
||||||
|
{ name: 'Mediterranean & Small Seas', description: 'Conservation and sustainable development specific to enclosed and semi-enclosed seas like the Mediterranean', category: 'Business & Investment', color: '#557f8c', sortOrder: 74 },
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -186,7 +265,6 @@ async function main() {
|
|||||||
|
|
||||||
const juryUserIds: string[] = []
|
const juryUserIds: string[] = []
|
||||||
for (const j of juryMembers) {
|
for (const j of juryMembers) {
|
||||||
const passwordHash = await bcrypt.hash('Jury2026!', 12)
|
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: j.email },
|
where: { email: j.email },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -194,11 +272,9 @@ async function main() {
|
|||||||
email: j.email,
|
email: j.email,
|
||||||
name: j.name,
|
name: j.name,
|
||||||
role: UserRole.JURY_MEMBER,
|
role: UserRole.JURY_MEMBER,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.NONE,
|
||||||
country: j.country,
|
country: j.country,
|
||||||
expertiseTags: j.tags,
|
expertiseTags: j.tags,
|
||||||
passwordHash,
|
|
||||||
mustSetPassword: true,
|
|
||||||
bio: `Expert in ${j.tags.join(', ')}`,
|
bio: `Expert in ${j.tags.join(', ')}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -218,7 +294,6 @@ async function main() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const m of mentors) {
|
for (const m of mentors) {
|
||||||
const passwordHash = await bcrypt.hash('Mentor2026!', 12)
|
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: m.email },
|
where: { email: m.email },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -226,11 +301,9 @@ async function main() {
|
|||||||
email: m.email,
|
email: m.email,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: UserRole.MENTOR,
|
role: UserRole.MENTOR,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.NONE,
|
||||||
country: m.country,
|
country: m.country,
|
||||||
expertiseTags: m.tags,
|
expertiseTags: m.tags,
|
||||||
passwordHash,
|
|
||||||
mustSetPassword: true,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(` ✓ Mentor: ${m.name}`)
|
console.log(` ✓ Mentor: ${m.name}`)
|
||||||
@@ -247,7 +320,6 @@ async function main() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const o of observers) {
|
for (const o of observers) {
|
||||||
const passwordHash = await bcrypt.hash('Observer2026!', 12)
|
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: o.email },
|
where: { email: o.email },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -255,10 +327,8 @@ async function main() {
|
|||||||
email: o.email,
|
email: o.email,
|
||||||
name: o.name,
|
name: o.name,
|
||||||
role: UserRole.OBSERVER,
|
role: UserRole.OBSERVER,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.NONE,
|
||||||
country: o.country,
|
country: o.country,
|
||||||
passwordHash,
|
|
||||||
mustSetPassword: true,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(` ✓ Observer: ${o.name}`)
|
console.log(` ✓ Observer: ${o.name}`)
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MailX,
|
MailX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
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 +105,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',
|
||||||
@@ -273,9 +275,20 @@ export default function MemberInvitePage() {
|
|||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
// Fetch current user to check role
|
// Use session role directly (from JWT) — no DB query needed, works even with stale user IDs
|
||||||
const { data: currentUser } = trpc.user.me.useQuery()
|
const { data: session } = useSession()
|
||||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
||||||
|
const isAdmin = isSuperAdmin || session?.user?.role === 'PROGRAM_ADMIN'
|
||||||
|
|
||||||
|
// Compute available roles as a stable list — avoids Radix Select
|
||||||
|
// not re-rendering conditional children when async data loads
|
||||||
|
const availableRoles = useMemo((): Role[] => {
|
||||||
|
const roles: Role[] = []
|
||||||
|
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
||||||
|
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
||||||
|
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
||||||
|
return roles
|
||||||
|
}, [isSuperAdmin, isAdmin])
|
||||||
|
|
||||||
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -406,14 +419,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 +443,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 +464,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 +479,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 +562,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>
|
||||||
@@ -653,21 +668,11 @@ export default function MemberInvitePage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isSuperAdmin && (
|
{availableRoles.map((role) => (
|
||||||
<SelectItem value="SUPER_ADMIN">
|
<SelectItem key={role} value={role}>
|
||||||
Super Admin
|
{ROLE_LABELS[role]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
{isSuperAdmin && (
|
|
||||||
<SelectItem value="PROGRAM_ADMIN">
|
|
||||||
Program Admin
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
<SelectItem value="JURY_MEMBER">
|
|
||||||
Jury Member
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Circle,
|
||||||
Clock,
|
Clock,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
@@ -86,6 +87,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
// Fetch files (flat list for backward compatibility)
|
// Fetch files (flat list for backward compatibility)
|
||||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||||
|
|
||||||
|
// Fetch file requirements from the pipeline's intake stage
|
||||||
|
const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!project }
|
||||||
|
)
|
||||||
|
|
||||||
// Fetch available stages for upload selector (if project has a programId)
|
// Fetch available stages for upload selector (if project has a programId)
|
||||||
const { data: programData } = trpc.program.get.useQuery(
|
const { data: programData } = trpc.program.get.useQuery(
|
||||||
{ id: project?.programId || '' },
|
{ id: project?.programId || '' },
|
||||||
@@ -521,35 +528,105 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{files && files.length > 0 ? (
|
{/* Required Documents from Pipeline Intake Stage */}
|
||||||
<FileViewer
|
{requirementsData && requirementsData.requirements.length > 0 && (
|
||||||
projectId={projectId}
|
<>
|
||||||
files={files.map((f) => ({
|
<div>
|
||||||
id: f.id,
|
<p className="text-sm font-semibold mb-3">Required Documents</p>
|
||||||
fileName: f.fileName,
|
<div className="grid gap-2">
|
||||||
fileType: f.fileType,
|
{requirementsData.requirements.map((req, idx) => {
|
||||||
mimeType: f.mimeType,
|
const isFulfilled = req.fulfilled
|
||||||
size: f.size,
|
return (
|
||||||
bucket: f.bucket,
|
<div
|
||||||
objectKey: f.objectKey,
|
key={req.id ?? `req-${idx}`}
|
||||||
}))}
|
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||||
/>
|
isFulfilled
|
||||||
) : (
|
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
|
||||||
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
|
: 'border-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{isFulfilled ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate">{req.name}</p>
|
||||||
|
{req.isRequired && (
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
Required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{req.description && (
|
||||||
|
<span className="truncate">{req.description}</span>
|
||||||
|
)}
|
||||||
|
{req.maxSizeMB && (
|
||||||
|
<span className="shrink-0">Max {req.maxSizeMB}MB</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isFulfilled && req.fulfilledFile && (
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||||
|
{req.fulfilledFile.fileName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isFulfilled && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator className="my-4" />
|
{/* Additional Documents Upload */}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
<p className="text-sm font-semibold mb-3">
|
||||||
|
{requirementsData && requirementsData.requirements.length > 0
|
||||||
|
? 'Additional Documents'
|
||||||
|
: 'Upload New Files'}
|
||||||
|
</p>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
||||||
onUploadComplete={() => {
|
onUploadComplete={() => {
|
||||||
utils.file.listByProject.invalidate({ projectId })
|
utils.file.listByProject.invalidate({ projectId })
|
||||||
|
utils.file.getProjectRequirements.invalidate({ projectId })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* All Files list */}
|
||||||
|
{files && files.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold mb-3">All Files</p>
|
||||||
|
<FileViewer
|
||||||
|
projectId={projectId}
|
||||||
|
files={files.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
fileType: f.fileType,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
size: f.size,
|
||||||
|
bucket: f.bucket,
|
||||||
|
objectKey: f.objectKey,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import type { Route } from 'next'
|
|||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ArrowLeft, Loader2, Save, Rocket } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
|
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||||
|
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||||
@@ -20,6 +21,7 @@ import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-fin
|
|||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||||
|
|
||||||
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||||
@@ -27,12 +29,20 @@ import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFin
|
|||||||
export default function NewPipelinePage() {
|
export default function NewPipelinePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const programId = searchParams.get('programId') ?? ''
|
const { currentEdition } = useEdition()
|
||||||
|
const programId = searchParams.get('programId') || currentEdition?.id || ''
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||||
const [openSection, setOpenSection] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
const initialStateRef = useRef(JSON.stringify(state))
|
const initialStateRef = useRef(JSON.stringify(state))
|
||||||
|
|
||||||
|
// Update programId in state when edition context loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (programId && !state.programId) {
|
||||||
|
setState((prev) => ({ ...prev, programId }))
|
||||||
|
}
|
||||||
|
}, [programId, state.programId])
|
||||||
|
|
||||||
// Dirty tracking — warn on navigate away
|
// Dirty tracking — warn on navigate away
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
@@ -120,9 +130,9 @@ export default function NewPipelinePage() {
|
|||||||
const validation = validateAll(state)
|
const validation = validateAll(state)
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error('Please fix validation errors before saving')
|
toast.error('Please fix validation errors before saving')
|
||||||
// Open first section with errors
|
// Navigate to first section with errors
|
||||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,60 +169,12 @@ export default function NewPipelinePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSaving = createMutation.isPending || publishMutation.isPending
|
const isSaving = createMutation.isPending && !publishMutation.isPending
|
||||||
|
const isSubmitting = publishMutation.isPending
|
||||||
|
|
||||||
const sections = [
|
if (!programId) {
|
||||||
{
|
return (
|
||||||
title: 'Basics',
|
<div className="space-y-6">
|
||||||
description: 'Pipeline name, slug, and program',
|
|
||||||
isValid: basicsValid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Intake',
|
|
||||||
description: 'Submission windows and file requirements',
|
|
||||||
isValid: !!intakeStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Main Track Stages',
|
|
||||||
description: `${mainTrack?.stages.length ?? 0} stages configured`,
|
|
||||||
isValid: tracksValid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Filtering',
|
|
||||||
description: 'Gate rules and AI screening settings',
|
|
||||||
isValid: !!filterStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Assignment',
|
|
||||||
description: 'Jury evaluation assignment strategy',
|
|
||||||
isValid: !!evalStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Awards',
|
|
||||||
description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`,
|
|
||||||
isValid: true, // Awards are optional
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Live Finals',
|
|
||||||
description: 'Voting, cohorts, and reveal settings',
|
|
||||||
isValid: !!liveStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Notifications',
|
|
||||||
description: 'Event notifications and override governance',
|
|
||||||
isValid: true, // Always valid
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Review & Publish',
|
|
||||||
description: 'Validation summary and publish controls',
|
|
||||||
isValid: allValid,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin/rounds/pipelines">
|
<Link href="/admin/rounds/pipelines">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
@@ -222,169 +184,169 @@ export default function NewPipelinePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configure the full pipeline structure for project evaluation
|
Please select an edition first to create a pipeline.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<Button
|
)
|
||||||
type="button"
|
}
|
||||||
variant="outline"
|
|
||||||
disabled={isSaving || !allValid}
|
// Step configuration
|
||||||
onClick={() => handleSave(false)}
|
const steps: StepConfig[] = [
|
||||||
>
|
{
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
title: 'Basics',
|
||||||
Save Draft
|
description: 'Pipeline name and program',
|
||||||
</Button>
|
isValid: basicsValid,
|
||||||
<Button
|
},
|
||||||
type="button"
|
{
|
||||||
disabled={isSaving || !allValid}
|
title: 'Intake',
|
||||||
onClick={() => handleSave(true)}
|
description: 'Submission window & files',
|
||||||
>
|
isValid: !!intakeStage,
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
},
|
||||||
Save & Publish
|
{
|
||||||
|
title: 'Main Track Stages',
|
||||||
|
description: 'Configure pipeline stages',
|
||||||
|
isValid: tracksValid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Screening',
|
||||||
|
description: 'Gate rules and AI screening',
|
||||||
|
isValid: !!filterStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Evaluation',
|
||||||
|
description: 'Jury assignment strategy',
|
||||||
|
isValid: !!evalStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Awards',
|
||||||
|
description: 'Special award tracks',
|
||||||
|
isValid: true, // Awards are optional
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Finals',
|
||||||
|
description: 'Voting and reveal settings',
|
||||||
|
isValid: !!liveStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Notifications',
|
||||||
|
description: 'Event notifications',
|
||||||
|
isValid: true, // Always valid
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Review & Create',
|
||||||
|
description: 'Validation summary',
|
||||||
|
isValid: allValid,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/rounds/pipelines">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure the full pipeline structure for project evaluation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wizard Sections */}
|
{/* Sidebar Stepper */}
|
||||||
<div className="space-y-3">
|
<SidebarStepper
|
||||||
{/* 0: Basics */}
|
steps={steps}
|
||||||
<WizardSection
|
currentStep={currentStep}
|
||||||
stepNumber={1}
|
onStepChange={setCurrentStep}
|
||||||
title={sections[0].title}
|
onSave={() => handleSave(false)}
|
||||||
description={sections[0].description}
|
onSubmit={() => handleSave(true)}
|
||||||
isOpen={openSection === 0}
|
isSaving={isSaving}
|
||||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
isSubmitting={isSubmitting}
|
||||||
isValid={sections[0].isValid}
|
saveLabel="Save Draft"
|
||||||
>
|
submitLabel="Save & Publish"
|
||||||
|
canSubmit={allValid}
|
||||||
|
>
|
||||||
|
{/* Step 0: Basics */}
|
||||||
|
<div>
|
||||||
<BasicsSection state={state} onChange={updateState} />
|
<BasicsSection state={state} onChange={updateState} />
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 1: Intake */}
|
{/* Step 1: Intake */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={2}
|
|
||||||
title={sections[1].title}
|
|
||||||
description={sections[1].description}
|
|
||||||
isOpen={openSection === 1}
|
|
||||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
|
||||||
isValid={sections[1].isValid}
|
|
||||||
>
|
|
||||||
<IntakeSection
|
<IntakeSection
|
||||||
config={intakeConfig}
|
config={intakeConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 2: Main Track Stages */}
|
{/* Step 2: Main Track Stages */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={3}
|
|
||||||
title={sections[2].title}
|
|
||||||
description={sections[2].description}
|
|
||||||
isOpen={openSection === 2}
|
|
||||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
|
||||||
isValid={sections[2].isValid}
|
|
||||||
>
|
|
||||||
<MainTrackSection
|
<MainTrackSection
|
||||||
stages={mainTrack?.stages ?? []}
|
stages={mainTrack?.stages ?? []}
|
||||||
onChange={updateMainTrackStages}
|
onChange={updateMainTrackStages}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 3: Filtering */}
|
{/* Step 3: Screening */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={4}
|
|
||||||
title={sections[3].title}
|
|
||||||
description={sections[3].description}
|
|
||||||
isOpen={openSection === 3}
|
|
||||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
|
||||||
isValid={sections[3].isValid}
|
|
||||||
>
|
|
||||||
<FilteringSection
|
<FilteringSection
|
||||||
config={filterConfig}
|
config={filterConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 4: Assignment */}
|
{/* Step 4: Evaluation */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={5}
|
|
||||||
title={sections[4].title}
|
|
||||||
description={sections[4].description}
|
|
||||||
isOpen={openSection === 4}
|
|
||||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
|
||||||
isValid={sections[4].isValid}
|
|
||||||
>
|
|
||||||
<AssignmentSection
|
<AssignmentSection
|
||||||
config={evalConfig}
|
config={evalConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 5: Awards */}
|
{/* Step 5: Awards */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={6}
|
<AwardsSection
|
||||||
title={sections[5].title}
|
tracks={state.tracks}
|
||||||
description={sections[5].description}
|
onChange={(tracks) => updateState({ tracks })}
|
||||||
isOpen={openSection === 5}
|
/>
|
||||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
</div>
|
||||||
isValid={sections[5].isValid}
|
|
||||||
>
|
|
||||||
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
{/* 6: Live Finals */}
|
{/* Step 6: Live Finals */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={7}
|
|
||||||
title={sections[6].title}
|
|
||||||
description={sections[6].description}
|
|
||||||
isOpen={openSection === 6}
|
|
||||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
|
||||||
isValid={sections[6].isValid}
|
|
||||||
>
|
|
||||||
<LiveFinalsSection
|
<LiveFinalsSection
|
||||||
config={liveConfig}
|
config={liveConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 7: Notifications */}
|
{/* Step 7: Notifications */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={8}
|
|
||||||
title={sections[7].title}
|
|
||||||
description={sections[7].description}
|
|
||||||
isOpen={openSection === 7}
|
|
||||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
|
||||||
isValid={sections[7].isValid}
|
|
||||||
>
|
|
||||||
<NotificationsSection
|
<NotificationsSection
|
||||||
config={state.notificationConfig}
|
config={state.notificationConfig}
|
||||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||||
overridePolicy={state.overridePolicy}
|
overridePolicy={state.overridePolicy}
|
||||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 8: Review */}
|
{/* Step 8: Review & Create */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={9}
|
|
||||||
title={sections[8].title}
|
|
||||||
description={sections[8].description}
|
|
||||||
isOpen={openSection === 8}
|
|
||||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
|
||||||
isValid={sections[8].isValid}
|
|
||||||
>
|
|
||||||
<ReviewSection state={state} />
|
<ReviewSection state={state} />
|
||||||
</WizardSection>
|
</div>
|
||||||
</div>
|
</SidebarStepper>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,422 +1,11 @@
|
|||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
type EditPipelinePageProps = {
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
params: Promise<{ id: string }>
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { ArrowLeft, Loader2, Save } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
|
|
||||||
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
|
|
||||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
|
||||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
|
||||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
|
||||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
|
||||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
|
||||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
|
||||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
|
||||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
|
||||||
|
|
||||||
import {
|
|
||||||
defaultIntakeConfig,
|
|
||||||
defaultFilterConfig,
|
|
||||||
defaultEvaluationConfig,
|
|
||||||
defaultLiveConfig,
|
|
||||||
defaultNotificationConfig,
|
|
||||||
} from '@/lib/pipeline-defaults'
|
|
||||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
|
||||||
import type {
|
|
||||||
WizardState,
|
|
||||||
IntakeConfig,
|
|
||||||
FilterConfig,
|
|
||||||
EvaluationConfig,
|
|
||||||
LiveFinalConfig,
|
|
||||||
WizardTrackConfig,
|
|
||||||
} from '@/types/pipeline-wizard'
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function pipelineToWizardState(pipeline: any): WizardState {
|
|
||||||
const settings = (pipeline.settingsJson as Record<string, unknown>) ?? {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: pipeline.name,
|
|
||||||
slug: pipeline.slug,
|
|
||||||
programId: pipeline.programId,
|
|
||||||
settingsJson: settings,
|
|
||||||
tracks: (pipeline.tracks ?? []).map((t: any) => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
slug: t.slug,
|
|
||||||
kind: t.kind as WizardTrackConfig['kind'],
|
|
||||||
sortOrder: t.sortOrder,
|
|
||||||
routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'],
|
|
||||||
decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'],
|
|
||||||
stages: (t.stages ?? []).map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
slug: s.slug,
|
|
||||||
stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'],
|
|
||||||
sortOrder: s.sortOrder,
|
|
||||||
configJson: (s.configJson as Record<string, unknown>) ?? {},
|
|
||||||
})),
|
|
||||||
awardConfig: t.specialAward
|
|
||||||
? {
|
|
||||||
name: t.specialAward.name,
|
|
||||||
description: t.specialAward.description ?? undefined,
|
|
||||||
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
notificationConfig:
|
|
||||||
(settings.notificationConfig as Record<string, boolean>) ??
|
|
||||||
defaultNotificationConfig(),
|
|
||||||
overridePolicy:
|
|
||||||
(settings.overridePolicy as Record<string, unknown>) ?? {
|
|
||||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditPipelinePage() {
|
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
|
||||||
const router = useRouter()
|
const { id } = await params
|
||||||
const params = useParams()
|
// Editing now happens inline on the detail page
|
||||||
const pipelineId = params.id as string
|
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||||
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
|
||||||
id: pipelineId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState | null>(null)
|
|
||||||
const [openSection, setOpenSection] = useState(0)
|
|
||||||
const initialStateRef = useRef<string>('')
|
|
||||||
|
|
||||||
// Initialize state from pipeline data
|
|
||||||
useEffect(() => {
|
|
||||||
if (pipeline && !state) {
|
|
||||||
const wizardState = pipelineToWizardState(pipeline)
|
|
||||||
setState(wizardState)
|
|
||||||
initialStateRef.current = JSON.stringify(wizardState)
|
|
||||||
}
|
|
||||||
}, [pipeline, state])
|
|
||||||
|
|
||||||
// Dirty tracking
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state) return
|
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
||||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
|
||||||
setState((prev) => (prev ? { ...prev, ...updates } : null))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateStageConfig = useCallback(
|
|
||||||
(stageType: string, configJson: Record<string, unknown>) => {
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev) return null
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
tracks: prev.tracks.map((track) => {
|
|
||||||
if (track.kind !== 'MAIN') return track
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
stages: track.stages.map((stage) =>
|
|
||||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateMainTrackStages = useCallback(
|
|
||||||
(stages: WizardState['tracks'][0]['stages']) => {
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev) return null
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
tracks: prev.tracks.map((track) =>
|
|
||||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
if (state) initialStateRef.current = JSON.stringify(state)
|
|
||||||
toast.success('Pipeline updated successfully')
|
|
||||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading || !state) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
|
||||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
|
||||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
|
||||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
|
||||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
|
||||||
|
|
||||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
|
||||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
|
||||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
|
||||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
|
||||||
|
|
||||||
const basicsValid = validateBasics(state).valid
|
|
||||||
const tracksValid = validateTracks(state.tracks).valid
|
|
||||||
const allValid = validateAll(state).valid
|
|
||||||
const isActive = pipeline?.status === 'ACTIVE'
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const validation = validateAll(state)
|
|
||||||
if (!validation.valid) {
|
|
||||||
toast.error('Please fix validation errors before saving')
|
|
||||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
|
||||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateStructureMutation.mutateAsync({
|
|
||||||
id: pipelineId,
|
|
||||||
name: state.name,
|
|
||||||
slug: state.slug,
|
|
||||||
settingsJson: {
|
|
||||||
...state.settingsJson,
|
|
||||||
notificationConfig: state.notificationConfig,
|
|
||||||
overridePolicy: state.overridePolicy,
|
|
||||||
},
|
|
||||||
tracks: state.tracks.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
slug: t.slug,
|
|
||||||
kind: t.kind,
|
|
||||||
sortOrder: t.sortOrder,
|
|
||||||
routingModeDefault: t.routingModeDefault,
|
|
||||||
decisionMode: t.decisionMode,
|
|
||||||
stages: t.stages.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
slug: s.slug,
|
|
||||||
stageType: s.stageType,
|
|
||||||
sortOrder: s.sortOrder,
|
|
||||||
configJson: s.configJson,
|
|
||||||
})),
|
|
||||||
awardConfig: t.awardConfig,
|
|
||||||
})),
|
|
||||||
autoTransitions: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSaving = updateStructureMutation.isPending
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{ title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid },
|
|
||||||
{ title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage },
|
|
||||||
{ title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid },
|
|
||||||
{ title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage },
|
|
||||||
{ title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage },
|
|
||||||
{ title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true },
|
|
||||||
{ title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage },
|
|
||||||
{ title: 'Notifications', description: 'Event notifications and override governance', isValid: true },
|
|
||||||
{ title: 'Review', description: 'Validation summary', isValid: allValid },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Edit Pipeline</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{pipeline?.name}
|
|
||||||
{isActive && ' (Active — some fields are locked)'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || !allValid}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wizard Sections */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={1}
|
|
||||||
title={sections[0].title}
|
|
||||||
description={sections[0].description}
|
|
||||||
isOpen={openSection === 0}
|
|
||||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
|
||||||
isValid={sections[0].isValid}
|
|
||||||
>
|
|
||||||
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={2}
|
|
||||||
title={sections[1].title}
|
|
||||||
description={sections[1].description}
|
|
||||||
isOpen={openSection === 1}
|
|
||||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
|
||||||
isValid={sections[1].isValid}
|
|
||||||
>
|
|
||||||
<IntakeSection
|
|
||||||
config={intakeConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={3}
|
|
||||||
title={sections[2].title}
|
|
||||||
description={sections[2].description}
|
|
||||||
isOpen={openSection === 2}
|
|
||||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
|
||||||
isValid={sections[2].isValid}
|
|
||||||
>
|
|
||||||
<MainTrackSection
|
|
||||||
stages={mainTrack?.stages ?? []}
|
|
||||||
onChange={updateMainTrackStages}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={4}
|
|
||||||
title={sections[3].title}
|
|
||||||
description={sections[3].description}
|
|
||||||
isOpen={openSection === 3}
|
|
||||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
|
||||||
isValid={sections[3].isValid}
|
|
||||||
>
|
|
||||||
<FilteringSection
|
|
||||||
config={filterConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={5}
|
|
||||||
title={sections[4].title}
|
|
||||||
description={sections[4].description}
|
|
||||||
isOpen={openSection === 4}
|
|
||||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
|
||||||
isValid={sections[4].isValid}
|
|
||||||
>
|
|
||||||
<AssignmentSection
|
|
||||||
config={evalConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={6}
|
|
||||||
title={sections[5].title}
|
|
||||||
description={sections[5].description}
|
|
||||||
isOpen={openSection === 5}
|
|
||||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
|
||||||
isValid={sections[5].isValid}
|
|
||||||
>
|
|
||||||
<AwardsSection
|
|
||||||
tracks={state.tracks}
|
|
||||||
onChange={(tracks) => updateState({ tracks })}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={7}
|
|
||||||
title={sections[6].title}
|
|
||||||
description={sections[6].description}
|
|
||||||
isOpen={openSection === 6}
|
|
||||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
|
||||||
isValid={sections[6].isValid}
|
|
||||||
>
|
|
||||||
<LiveFinalsSection
|
|
||||||
config={liveConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={8}
|
|
||||||
title={sections[7].title}
|
|
||||||
description={sections[7].description}
|
|
||||||
isOpen={openSection === 7}
|
|
||||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
|
||||||
isValid={sections[7].isValid}
|
|
||||||
>
|
|
||||||
<NotificationsSection
|
|
||||||
config={state.notificationConfig}
|
|
||||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
|
||||||
overridePolicy={state.overridePolicy}
|
|
||||||
onOverridePolicyChange={(overridePolicy) =>
|
|
||||||
updateState({ overridePolicy })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={9}
|
|
||||||
title={sections[8].title}
|
|
||||||
description={sections[8].description}
|
|
||||||
isOpen={openSection === 8}
|
|
||||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
|
||||||
isValid={sections[8].isValid}
|
|
||||||
>
|
|
||||||
<ReviewSection state={state} />
|
|
||||||
</WizardSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
@@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,7 +23,6 @@ import { toast } from 'sonner'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Rocket,
|
Rocket,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -35,8 +30,14 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||||
|
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
||||||
|
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
|
||||||
|
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
||||||
|
|
||||||
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||||
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||||
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||||
@@ -51,15 +52,6 @@ const statusColors: Record<string, string> = {
|
|||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-blue-100 text-blue-700',
|
|
||||||
FILTER: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-purple-100 text-purple-700',
|
|
||||||
SELECTION: 'bg-rose-100 text-rose-700',
|
|
||||||
LIVE_FINAL: 'bg-emerald-100 text-emerald-700',
|
|
||||||
RESULTS: 'bg-cyan-100 text-cyan-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
function StagePanel({
|
function StagePanel({
|
||||||
stageId,
|
stageId,
|
||||||
stageType,
|
stageType,
|
||||||
@@ -100,20 +92,14 @@ export default function PipelineDetailPage() {
|
|||||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const stagePanelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||||
id: pipelineId,
|
id: pipelineId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-select first track and stage
|
const { isUpdating, updatePipeline, updateStageConfig } =
|
||||||
useEffect(() => {
|
usePipelineInlineEdit(pipelineId)
|
||||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
|
||||||
const firstTrack = pipeline.tracks[0]
|
|
||||||
setSelectedTrackId(firstTrack.id)
|
|
||||||
if (firstTrack.stages.length > 0) {
|
|
||||||
setSelectedStageId(firstTrack.stages[0].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [pipeline, selectedTrackId])
|
|
||||||
|
|
||||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||||
onSuccess: () => toast.success('Pipeline published'),
|
onSuccess: () => toast.success('Pipeline published'),
|
||||||
@@ -125,6 +111,25 @@ export default function PipelineDetailPage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-select first track and stage on load
|
||||||
|
useEffect(() => {
|
||||||
|
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||||
|
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||||
|
setSelectedTrackId(firstTrack.id)
|
||||||
|
if (firstTrack.stages.length > 0) {
|
||||||
|
const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||||
|
setSelectedStageId(firstStage.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pipeline, selectedTrackId])
|
||||||
|
|
||||||
|
// Scroll to stage panel when a stage is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStageId && stagePanelRef.current) {
|
||||||
|
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, [selectedStageId])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -170,109 +175,156 @@ export default function PipelineDetailPage() {
|
|||||||
setSelectedTrackId(trackId)
|
setSelectedTrackId(trackId)
|
||||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||||
if (track && track.stages.length > 0) {
|
if (track && track.stages.length > 0) {
|
||||||
setSelectedStageId(track.stages[0].id)
|
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||||
|
setSelectedStageId(firstStage.id)
|
||||||
} else {
|
} else {
|
||||||
setSelectedStageId(null)
|
setSelectedStageId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStageSelect = (stageId: string) => {
|
||||||
|
setSelectedStageId(stageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: pipelineId,
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare flowchart data for the selected track
|
||||||
|
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Link href="/admin/rounds/pipelines">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<Button variant="ghost" size="icon">
|
<Link href="/admin/rounds/pipelines" className="mt-1">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||||
</Button>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-xl font-bold">{pipeline.name}</h1>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
statusColors[pipeline.status] ?? ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pipeline.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">
|
|
||||||
{pipeline.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Settings2 className="h-4 w-4 mr-1" />
|
|
||||||
Advanced
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</Link>
|
||||||
<DropdownMenuContent align="end">
|
<div className="min-w-0">
|
||||||
{pipeline.status === 'DRAFT' && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<DropdownMenuItem
|
<InlineEditableText
|
||||||
disabled={publishMutation.isPending}
|
value={pipeline.name}
|
||||||
onClick={() =>
|
onSave={(newName) => updatePipeline({ name: newName })}
|
||||||
publishMutation.mutate({ id: pipelineId })
|
variant="h1"
|
||||||
}
|
placeholder="Untitled Pipeline"
|
||||||
>
|
disabled={isUpdating}
|
||||||
{publishMutation.isPending ? (
|
/>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<DropdownMenu>
|
||||||
) : (
|
<DropdownMenuTrigger asChild>
|
||||||
<Rocket className="h-4 w-4 mr-2" />
|
<button
|
||||||
)}
|
className={cn(
|
||||||
Publish
|
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
||||||
</DropdownMenuItem>
|
statusColors[pipeline.status] ?? '',
|
||||||
)}
|
'hover:opacity-80'
|
||||||
{pipeline.status === 'ACTIVE' && (
|
)}
|
||||||
|
>
|
||||||
|
{pipeline.status}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('DRAFT')}
|
||||||
|
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Draft
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('ACTIVE')}
|
||||||
|
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('CLOSED')}
|
||||||
|
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Closed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('ARCHIVED')}
|
||||||
|
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<span className="text-muted-foreground">slug:</span>
|
||||||
|
<InlineEditableText
|
||||||
|
value={pipeline.slug}
|
||||||
|
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
|
||||||
|
variant="mono"
|
||||||
|
placeholder="pipeline-slug"
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 sm:hidden">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="hidden sm:inline-flex">
|
||||||
|
<Settings2 className="h-4 w-4 mr-1" />
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{pipeline.status === 'DRAFT' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={publishMutation.isPending}
|
||||||
|
onClick={() => publishMutation.mutate({ id: pipelineId })}
|
||||||
|
>
|
||||||
|
{publishMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Publish
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{pipeline.status === 'ACTIVE' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onClick={() => handleStatusChange('CLOSED')}
|
||||||
|
>
|
||||||
|
Close Pipeline
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() => handleStatusChange('ARCHIVED')}
|
||||||
updateMutation.mutate({
|
|
||||||
id: pipelineId,
|
|
||||||
status: 'CLOSED',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Close Pipeline
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
</DropdownMenuContent>
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenu>
|
||||||
<DropdownMenuItem
|
</div>
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
updateMutation.mutate({
|
|
||||||
id: pipelineId,
|
|
||||||
status: 'ARCHIVED',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline Summary */}
|
{/* Pipeline Summary */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-3 grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -320,120 +372,91 @@ export default function PipelineDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Tabs */}
|
{/* Track Switcher (only if multiple tracks) */}
|
||||||
{pipeline.tracks.length > 0 && (
|
{pipeline.tracks.length > 1 && (
|
||||||
<Tabs
|
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
|
||||||
value={selectedTrackId ?? undefined}
|
{pipeline.tracks
|
||||||
onValueChange={handleTrackChange}
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
>
|
.map((track) => (
|
||||||
<TabsList className="w-full justify-start overflow-x-auto">
|
<button
|
||||||
{pipeline.tracks
|
key={track.id}
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
onClick={() => handleTrackChange(track.id)}
|
||||||
.map((track) => (
|
className={cn(
|
||||||
<TabsTrigger
|
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||||
key={track.id}
|
selectedTrackId === track.id
|
||||||
value={track.id}
|
? 'bg-primary text-primary-foreground'
|
||||||
className="flex items-center gap-1.5"
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||||
>
|
)}
|
||||||
<span>{track.name}</span>
|
>
|
||||||
<Badge
|
<span>{track.name}</span>
|
||||||
variant="outline"
|
<Badge
|
||||||
className="text-[9px] h-4 px-1"
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
{track.kind}
|
'text-[9px] h-4 px-1',
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{pipeline.tracks.map((track) => (
|
|
||||||
<TabsContent key={track.id} value={track.id} className="mt-4">
|
|
||||||
{/* Track Info */}
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-sm">{track.name}</CardTitle>
|
|
||||||
<CardDescription className="font-mono text-xs">
|
|
||||||
{track.slug}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{track.routingMode && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{track.routingMode}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{track.decisionMode && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{track.decisionMode}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Stage Tabs within Track */}
|
|
||||||
{track.stages.length > 0 ? (
|
|
||||||
<Tabs
|
|
||||||
value={
|
|
||||||
selectedTrackId === track.id
|
selectedTrackId === track.id
|
||||||
? selectedStageId ?? undefined
|
? 'border-primary-foreground/20 text-primary-foreground/80'
|
||||||
: undefined
|
: ''
|
||||||
}
|
)}
|
||||||
onValueChange={setSelectedStageId}
|
|
||||||
>
|
>
|
||||||
<TabsList className="w-full justify-start overflow-x-auto">
|
{track.kind}
|
||||||
{track.stages
|
</Badge>
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
</button>
|
||||||
.map((stage) => (
|
))}
|
||||||
<TabsTrigger
|
</div>
|
||||||
key={stage.id}
|
|
||||||
value={stage.id}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<span>{stage.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[9px] h-4 px-1',
|
|
||||||
stageTypeColors[stage.stageType] ?? ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{stage.stageType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{track.stages.map((stage) => (
|
|
||||||
<TabsContent
|
|
||||||
key={stage.id}
|
|
||||||
value={stage.id}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
<StagePanel
|
|
||||||
stageId={stage.id}
|
|
||||||
stageType={stage.stageType}
|
|
||||||
configJson={
|
|
||||||
stage.configJson as Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No stages configured for this track
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Flowchart */}
|
||||||
|
{flowchartTracks.length > 0 ? (
|
||||||
|
<PipelineFlowchart
|
||||||
|
tracks={flowchartTracks}
|
||||||
|
selectedStageId={selectedStageId}
|
||||||
|
onStageSelect={handleStageSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No tracks configured for this pipeline
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Stage Detail */}
|
||||||
|
<div ref={stagePanelRef}>
|
||||||
|
{selectedStage ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||||
|
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stage Config Editor */}
|
||||||
|
<StageConfigEditor
|
||||||
|
stageId={selectedStage.id}
|
||||||
|
stageName={selectedStage.name}
|
||||||
|
stageType={selectedStage.stageType}
|
||||||
|
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||||
|
onSave={updateStageConfig}
|
||||||
|
isSaving={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage Activity Panel */}
|
||||||
|
<StagePanel
|
||||||
|
stageId={selectedStage.id}
|
||||||
|
stageType={selectedStage.stageType}
|
||||||
|
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click a stage in the flowchart above to view its configuration and activity
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,36 +8,45 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
|
||||||
Eye,
|
|
||||||
Edit,
|
|
||||||
Layers,
|
Layers,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Workflow,
|
||||||
|
Pencil,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { format } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusConfig = {
|
||||||
DRAFT: 'bg-gray-100 text-gray-700',
|
DRAFT: {
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
label: 'Draft',
|
||||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
bgClass: 'bg-gray-100 text-gray-700',
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
dotClass: 'bg-gray-500',
|
||||||
}
|
},
|
||||||
|
ACTIVE: {
|
||||||
|
label: 'Active',
|
||||||
|
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||||
|
dotClass: 'bg-emerald-500',
|
||||||
|
},
|
||||||
|
CLOSED: {
|
||||||
|
label: 'Closed',
|
||||||
|
bgClass: 'bg-blue-100 text-blue-700',
|
||||||
|
dotClass: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
ARCHIVED: {
|
||||||
|
label: 'Archived',
|
||||||
|
bgClass: 'bg-muted text-muted-foreground',
|
||||||
|
dotClass: 'bg-muted-foreground',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
export default function PipelineListPage() {
|
export default function PipelineListPage() {
|
||||||
const { currentEdition } = useEdition()
|
const { currentEdition } = useEdition()
|
||||||
@@ -112,17 +121,20 @@ export default function PipelineListPage() {
|
|||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
||||||
<Card>
|
<Card className="border-2 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
|
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||||
<p className="mt-2 font-medium">No Pipelines Yet</p>
|
<Workflow className="h-10 w-10 text-primary" />
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
</div>
|
||||||
Create your first pipeline to start managing project evaluation
|
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||||
|
Pipelines organize your project evaluation workflow into tracks and stages.
|
||||||
|
Create your first pipeline to get started with managing project evaluations.
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||||
<Button size="sm">
|
<Button>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create Pipeline
|
Create Your First Pipeline
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -132,73 +144,112 @@ export default function PipelineListPage() {
|
|||||||
{/* Pipeline Cards */}
|
{/* Pipeline Cards */}
|
||||||
{pipelines && pipelines.length > 0 && (
|
{pipelines && pipelines.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{pipelines.map((pipeline) => (
|
{pipelines.map((pipeline) => {
|
||||||
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow">
|
const status = pipeline.status as keyof typeof statusConfig
|
||||||
<CardHeader className="pb-3">
|
const config = statusConfig[status] || statusConfig.DRAFT
|
||||||
<div className="flex items-start justify-between">
|
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base truncate">
|
return (
|
||||||
{pipeline.name}
|
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow h-full flex flex-col">
|
||||||
</CardTitle>
|
<CardHeader className="pb-3">
|
||||||
<CardDescription className="font-mono text-xs">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{pipeline.slug}
|
<div className="min-w-0 flex-1">
|
||||||
</CardDescription>
|
<CardTitle className="text-base leading-tight mb-1">
|
||||||
</div>
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route} className="hover:underline">
|
||||||
<div className="flex items-center gap-2">
|
{pipeline.name}
|
||||||
<Badge
|
</Link>
|
||||||
variant="secondary"
|
</CardTitle>
|
||||||
className={cn(
|
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||||
'text-[10px] shrink-0',
|
{pipeline.slug}
|
||||||
statusColors[pipeline.status] ?? ''
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] shrink-0 flex items-center gap-1.5',
|
||||||
|
config.bgClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="mt-auto">
|
||||||
|
{/* Track Indicator - Simplified visualization */}
|
||||||
|
<div className="mb-3 pb-3 border-b">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{pipeline._count.tracks === 0
|
||||||
|
? 'No tracks'
|
||||||
|
: pipeline._count.tracks === 1
|
||||||
|
? '1 track'
|
||||||
|
: `${pipeline._count.tracks} tracks`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pipeline._count.tracks > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-6 flex-1 rounded border border-border bg-muted/30 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="h-1 w-1 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pipeline._count.tracks > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">
|
||||||
|
+{pipeline._count.tracks - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{pipeline.status}
|
|
||||||
</Badge>
|
{/* Stats */}
|
||||||
<DropdownMenu>
|
<div className="space-y-1.5">
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center justify-between text-xs">
|
||||||
<Button
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
variant="ghost"
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
size="icon"
|
<span>Routing rules</span>
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
</div>
|
||||||
>
|
<span className="font-medium text-foreground">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
{pipeline._count.routingRules}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="flex-1">
|
||||||
|
<Button size="sm" variant="outline" className="w-full">
|
||||||
|
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</Link>
|
||||||
<DropdownMenuContent align="end">
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}/advanced` as Route} className="flex-1">
|
||||||
<DropdownMenuItem asChild>
|
<Button size="sm" variant="outline" className="w-full">
|
||||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
<Settings2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
Advanced
|
||||||
View
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
<DropdownMenuItem asChild>
|
</CardContent>
|
||||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
|
</Card>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
)
|
||||||
Edit
|
})}
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{pipeline._count.tracks} tracks</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
|
||||||
<span>{pipeline._count.routingRules} rules</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export default function ApplicantPipelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
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">
|
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function MentorDashboard() {
|
|||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<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" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
Messages
|
Messages
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams, usePathname } from 'next/navigation'
|
import { useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -27,9 +28,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react'
|
import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
|
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
|
||||||
@@ -131,6 +133,8 @@ export function MembersContent() {
|
|||||||
|
|
||||||
const roles = TAB_ROLES[tab]
|
const roles = TAB_ROLES[tab]
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const { data: currentUser } = trpc.user.me.useQuery()
|
const { data: currentUser } = trpc.user.me.useQuery()
|
||||||
const currentUserRole = currentUser?.role as RoleValue | undefined
|
const currentUserRole = currentUser?.role as RoleValue | undefined
|
||||||
|
|
||||||
@@ -141,6 +145,75 @@ export function MembersContent() {
|
|||||||
perPage: 20,
|
perPage: 20,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const bulkInvite = trpc.user.bulkSendInvitations.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
const { sent, errors } = result as { sent: number; skipped: number; errors: string[] }
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
toast.warning(`Sent ${sent} invitation${sent !== 1 ? 's' : ''}, ${errors.length} failed`)
|
||||||
|
} else {
|
||||||
|
toast.success(`Invitations sent to ${sent} member${sent !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
utils.user.list.invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to send invitations')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Users on the current page that are selectable (status NONE)
|
||||||
|
const selectableUsers = useMemo(
|
||||||
|
() => (data?.users ?? []).filter((u) => u.status === 'NONE'),
|
||||||
|
[data?.users]
|
||||||
|
)
|
||||||
|
|
||||||
|
const allSelectableSelected =
|
||||||
|
selectableUsers.length > 0 && selectableUsers.every((u) => selectedIds.has(u.id))
|
||||||
|
|
||||||
|
const someSelectableSelected =
|
||||||
|
selectableUsers.some((u) => selectedIds.has(u.id)) && !allSelectableSelected
|
||||||
|
|
||||||
|
const toggleUser = useCallback((userId: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(userId)) {
|
||||||
|
next.delete(userId)
|
||||||
|
} else {
|
||||||
|
next.add(userId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
if (allSelectableSelected) {
|
||||||
|
// Deselect all on this page
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const u of selectableUsers) {
|
||||||
|
next.delete(u.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Select all selectable on this page
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const u of selectableUsers) {
|
||||||
|
next.add(u.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [allSelectableSelected, selectableUsers])
|
||||||
|
|
||||||
|
// Clear selection when filters/page change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}, [tab, search, page])
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
updateParams({ tab: value === 'all' ? null : value, page: '1' })
|
updateParams({ tab: value === 'all' ? null : value, page: '1' })
|
||||||
}
|
}
|
||||||
@@ -197,6 +270,15 @@ export function MembersContent() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-10">
|
||||||
|
{selectableUsers.length > 0 && (
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
aria-label="Select all uninvited members"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
<TableHead>Member</TableHead>
|
<TableHead>Member</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
<TableHead>Expertise</TableHead>
|
<TableHead>Expertise</TableHead>
|
||||||
@@ -209,6 +291,17 @@ export function MembersContent() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{data.users.map((user) => (
|
{data.users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
{user.status === 'NONE' ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(user.id)}
|
||||||
|
onCheckedChange={() => toggleUser(user.id)}
|
||||||
|
aria-label={`Select ${user.name || user.email}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -297,6 +390,14 @@ export function MembersContent() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{user.status === 'NONE' && (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(user.id)}
|
||||||
|
onCheckedChange={() => toggleUser(user.id)}
|
||||||
|
aria-label={`Select ${user.name || user.email}`}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
user={user}
|
user={user}
|
||||||
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||||
@@ -395,6 +496,50 @@ export function MembersContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Floating bulk invite toolbar */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
|
||||||
|
>
|
||||||
|
<Card className="shadow-lg border-2">
|
||||||
|
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<span className="text-sm font-medium whitespace-nowrap">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => bulkInvite.mutate({ userIds: Array.from(selectedIds) })}
|
||||||
|
disabled={bulkInvite.isPending}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{bulkInvite.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Invite Selected
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
disabled={bulkInvite.isPending}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
276
src/components/admin/pipeline/pipeline-flowchart.tsx
Normal file
276
src/components/admin/pipeline/pipeline-flowchart.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
type StageNode = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stageType: string
|
||||||
|
sortOrder: number
|
||||||
|
_count?: { projectStageStates: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowchartTrack = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: string
|
||||||
|
sortOrder: number
|
||||||
|
stages: StageNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PipelineFlowchartProps = {
|
||||||
|
tracks: FlowchartTrack[]
|
||||||
|
selectedStageId?: string | null
|
||||||
|
onStageSelect?: (stageId: string) => void
|
||||||
|
className?: string
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
|
||||||
|
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
|
||||||
|
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
|
||||||
|
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
|
||||||
|
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
|
||||||
|
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
|
||||||
|
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_WIDTH = 140
|
||||||
|
const NODE_HEIGHT = 70
|
||||||
|
const NODE_GAP = 32
|
||||||
|
const ARROW_SIZE = 6
|
||||||
|
const TRACK_LABEL_HEIGHT = 28
|
||||||
|
const TRACK_GAP = 20
|
||||||
|
|
||||||
|
export function PipelineFlowchart({
|
||||||
|
tracks,
|
||||||
|
selectedStageId,
|
||||||
|
onStageSelect,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: PipelineFlowchartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const nodeW = compact ? 100 : NODE_WIDTH
|
||||||
|
const nodeH = compact ? 50 : NODE_HEIGHT
|
||||||
|
const gap = compact ? 20 : NODE_GAP
|
||||||
|
|
||||||
|
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
|
||||||
|
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
|
||||||
|
const totalHeight =
|
||||||
|
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
|
||||||
|
|
||||||
|
const getNodePosition = useCallback(
|
||||||
|
(trackIndex: number, stageIndex: number) => {
|
||||||
|
const x = 20 + stageIndex * (nodeW + gap)
|
||||||
|
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
|
||||||
|
return { x, y }
|
||||||
|
},
|
||||||
|
[nodeW, nodeH, gap]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn('relative rounded-lg border bg-card', className)}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
width={totalWidth}
|
||||||
|
height={totalHeight}
|
||||||
|
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
||||||
|
className="min-w-full"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth={ARROW_SIZE}
|
||||||
|
markerHeight={ARROW_SIZE}
|
||||||
|
refX={ARROW_SIZE}
|
||||||
|
refY={ARROW_SIZE / 2}
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
|
||||||
|
fill="#94a3b8"
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
|
{/* Glow filter for selected node */}
|
||||||
|
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
|
||||||
|
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{sortedTracks.map((track, trackIndex) => {
|
||||||
|
const sortedStages = [...track.stages].sort(
|
||||||
|
(a, b) => a.sortOrder - b.sortOrder
|
||||||
|
)
|
||||||
|
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={track.id}>
|
||||||
|
{/* Track label */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={trackY + 14}
|
||||||
|
className="fill-muted-foreground text-[11px] font-medium"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{track.name}
|
||||||
|
{track.kind !== 'MAIN' && ` (${track.kind})`}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Arrows between stages */}
|
||||||
|
{sortedStages.map((stage, stageIndex) => {
|
||||||
|
if (stageIndex === 0) return null
|
||||||
|
const from = getNodePosition(trackIndex, stageIndex - 1)
|
||||||
|
const to = getNodePosition(trackIndex, stageIndex)
|
||||||
|
const arrowY = from.y + nodeH / 2
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`arrow-${stage.id}`}
|
||||||
|
x1={from.x + nodeW}
|
||||||
|
y1={arrowY}
|
||||||
|
x2={to.x - 2}
|
||||||
|
y2={arrowY}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Stage nodes */}
|
||||||
|
{sortedStages.map((stage, stageIndex) => {
|
||||||
|
const pos = getNodePosition(trackIndex, stageIndex)
|
||||||
|
const isSelected = selectedStageId === stage.id
|
||||||
|
const isHovered = hoveredStageId === stage.id
|
||||||
|
const colors = stageTypeColors[stage.stageType] ?? {
|
||||||
|
bg: '#f8fafc',
|
||||||
|
border: '#cbd5e1',
|
||||||
|
text: '#475569',
|
||||||
|
glow: '#64748b',
|
||||||
|
}
|
||||||
|
const projectCount = stage._count?.projectStageStates ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={stage.id}
|
||||||
|
onClick={() => onStageSelect?.(stage.id)}
|
||||||
|
onMouseEnter={() => setHoveredStageId(stage.id)}
|
||||||
|
onMouseLeave={() => setHoveredStageId(null)}
|
||||||
|
className={cn(onStageSelect && 'cursor-pointer')}
|
||||||
|
filter={isSelected ? 'url(#selectedGlow)' : undefined}
|
||||||
|
>
|
||||||
|
{/* Selection ring */}
|
||||||
|
{isSelected && (
|
||||||
|
<motion.rect
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
x={pos.x - 3}
|
||||||
|
y={pos.y - 3}
|
||||||
|
width={nodeW + 6}
|
||||||
|
height={nodeH + 6}
|
||||||
|
rx={10}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.glow}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node background */}
|
||||||
|
<rect
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
width={nodeW}
|
||||||
|
height={nodeH}
|
||||||
|
rx={8}
|
||||||
|
fill={colors.bg}
|
||||||
|
stroke={isSelected ? colors.glow : colors.border}
|
||||||
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
|
style={{
|
||||||
|
transition: 'stroke 0.15s, stroke-width 0.15s',
|
||||||
|
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
|
||||||
|
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage name */}
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + (compact ? 20 : 24)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{stage.name.length > (compact ? 12 : 16)
|
||||||
|
? stage.name.slice(0, compact ? 10 : 14) + '...'
|
||||||
|
: stage.name}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + (compact ? 34 : 40)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className="text-[9px]"
|
||||||
|
style={{ fontFamily: 'inherit', opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
{stage.stageType.replace('_', ' ')}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Project count */}
|
||||||
|
{!compact && projectCount > 0 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={pos.x + nodeW / 2 - 14}
|
||||||
|
y={pos.y + nodeH - 18}
|
||||||
|
width={28}
|
||||||
|
height={14}
|
||||||
|
rx={7}
|
||||||
|
fill={colors.border}
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + nodeH - 8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className="text-[9px] font-medium"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{projectCount}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Scroll hint gradient for mobile */}
|
||||||
|
{totalWidth > 400 && (
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type AssignmentSectionProps = {
|
type AssignmentSectionProps = {
|
||||||
config: EvaluationConfig
|
config: EvaluationConfig
|
||||||
onChange: (config: EvaluationConfig) => void
|
onChange: (config: EvaluationConfig) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
|
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
|
||||||
const updateConfig = (updates: Partial<EvaluationConfig>) => {
|
const updateConfig = (updates: Partial<EvaluationConfig>) => {
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
@@ -26,12 +28,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Required Reviews per Project</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Required Reviews per Project</Label>
|
||||||
|
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
value={config.requiredReviews}
|
value={config.requiredReviews ?? 3}
|
||||||
|
disabled={isActive}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
|
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
|
||||||
}
|
}
|
||||||
@@ -42,12 +48,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Max Load per Juror</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Max Load per Juror</Label>
|
||||||
|
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
value={config.maxLoadPerJuror}
|
value={config.maxLoadPerJuror ?? 20}
|
||||||
|
disabled={isActive}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
|
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
|
||||||
}
|
}
|
||||||
@@ -58,12 +68,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Min Load per Juror</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Min Load per Juror</Label>
|
||||||
|
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={50}
|
max={50}
|
||||||
value={config.minLoadPerJuror}
|
value={config.minLoadPerJuror ?? 5}
|
||||||
|
disabled={isActive}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
|
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
|
||||||
}
|
}
|
||||||
@@ -74,30 +88,44 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
|
||||||
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Availability Weighting</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Availability Weighting</Label>
|
||||||
|
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Factor in juror availability when assigning projects
|
Factor in juror availability when assigning projects
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.availabilityWeighting}
|
checked={config.availabilityWeighting ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateConfig({ availabilityWeighting: checked })
|
updateConfig({ availabilityWeighting: checked })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Overflow Policy</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Overflow Policy</Label>
|
||||||
|
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={config.overflowPolicy}
|
value={config.overflowPolicy ?? 'queue'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
|
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Plus, Trash2, Trophy } from 'lucide-react'
|
import { Plus, Trash2, Trophy } from 'lucide-react'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
|
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
|
||||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||||
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
|
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
|
||||||
@@ -31,6 +32,7 @@ import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client
|
|||||||
type AwardsSectionProps = {
|
type AwardsSectionProps = {
|
||||||
tracks: WizardTrackConfig[]
|
tracks: WizardTrackConfig[]
|
||||||
onChange: (tracks: WizardTrackConfig[]) => void
|
onChange: (tracks: WizardTrackConfig[]) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function slugify(name: string): string {
|
function slugify(name: string): string {
|
||||||
@@ -40,7 +42,7 @@ function slugify(name: string): string {
|
|||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
|
||||||
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
|
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
|
||||||
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
|
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configure special award tracks that run alongside the main competition.
|
Configure special award tracks that run alongside the main competition.
|
||||||
</p>
|
</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" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
Add Award Track
|
Add Award Track
|
||||||
</Button>
|
</Button>
|
||||||
@@ -100,6 +102,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,6 +132,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g., Innovation Award"
|
placeholder="e.g., Innovation Award"
|
||||||
value={track.awardConfig?.name ?? track.name}
|
value={track.awardConfig?.name ?? track.name}
|
||||||
|
disabled={isActive}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const name = e.target.value
|
const name = e.target.value
|
||||||
updateAward(index, {
|
updateAward(index, {
|
||||||
@@ -143,7 +147,10 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Routing Mode</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label className="text-xs">Routing Mode</Label>
|
||||||
|
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={track.routingModeDefault ?? 'PARALLEL'}
|
value={track.routingModeDefault ?? 'PARALLEL'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -151,6 +158,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
routingModeDefault: value as RoutingMode,
|
routingModeDefault: value as RoutingMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="text-sm">
|
<SelectTrigger className="text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -172,12 +180,16 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Decision Mode</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label className="text-xs">Decision Mode</Label>
|
||||||
|
<InfoTooltip content="How the winner is determined for this award track." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={track.decisionMode ?? 'JURY_VOTE'}
|
value={track.decisionMode ?? 'JURY_VOTE'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateAward(index, { decisionMode: value as DecisionMode })
|
updateAward(index, { decisionMode: value as DecisionMode })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="text-sm">
|
<SelectTrigger className="text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -192,7 +204,10 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Scoring Mode</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label className="text-xs">Scoring Mode</Label>
|
||||||
|
<InfoTooltip content="The method used to aggregate scores for this award." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
|
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -203,6 +218,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="text-sm">
|
<SelectTrigger className="text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import type { WizardState } from '@/types/pipeline-wizard'
|
import type { WizardState } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
@@ -52,7 +53,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="pipeline-slug">Slug</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="pipeline-slug">Slug</Label>
|
||||||
|
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="pipeline-slug"
|
id="pipeline-slug"
|
||||||
placeholder="e.g., mopc-2026"
|
placeholder="e.g., mopc-2026"
|
||||||
@@ -70,7 +74,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="pipeline-program">Program</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="pipeline-program">Program</Label>
|
||||||
|
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={state.programId}
|
value={state.programId}
|
||||||
onValueChange={(value) => onChange({ programId: value })}
|
onValueChange={(value) => onChange({ programId: value })}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { useState } from 'react'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -13,21 +15,105 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
CollapsibleContent,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
|
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
|
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
|
||||||
|
|
||||||
|
type KnownField = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
operators: string[]
|
||||||
|
valueType: 'select' | 'text' | 'number' | 'boolean'
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_FIELDS: KnownField[] = [
|
||||||
|
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
|
||||||
|
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
|
||||||
|
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
|
||||||
|
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
|
||||||
|
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
|
||||||
|
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
|
||||||
|
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
|
||||||
|
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPERATOR_LABELS: Record<string, string> = {
|
||||||
|
is: 'is',
|
||||||
|
is_not: 'is not',
|
||||||
|
is_one_of: 'is one of',
|
||||||
|
after: 'after',
|
||||||
|
before: 'before',
|
||||||
|
exists: 'exists',
|
||||||
|
min_length: 'min length',
|
||||||
|
greaterThan: 'greater than',
|
||||||
|
lessThan: 'less than',
|
||||||
|
equals: 'equals',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Human-readable preview for a rule ───────────────────────────────────────
|
||||||
|
|
||||||
|
function getRulePreview(rule: FilterRuleConfig): string {
|
||||||
|
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
|
||||||
|
const fieldLabel = field?.label ?? rule.field
|
||||||
|
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
|
||||||
|
|
||||||
|
if (rule.operator === 'exists') {
|
||||||
|
return `Projects where ${fieldLabel} exists will pass`
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStr = typeof rule.value === 'boolean'
|
||||||
|
? (rule.value ? 'Yes' : 'No')
|
||||||
|
: String(rule.value)
|
||||||
|
|
||||||
|
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
|
||||||
|
|
||||||
|
const AI_VISIBLE_FIELDS = [
|
||||||
|
'Project title',
|
||||||
|
'Description',
|
||||||
|
'Competition category',
|
||||||
|
'Ocean issue',
|
||||||
|
'Country & region',
|
||||||
|
'Tags',
|
||||||
|
'Founded year',
|
||||||
|
'Team size',
|
||||||
|
'File count',
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FilteringSectionProps = {
|
type FilteringSectionProps = {
|
||||||
config: FilterConfig
|
config: FilterConfig
|
||||||
onChange: (config: FilterConfig) => void
|
onChange: (config: FilterConfig) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
|
||||||
|
const [rulesOpen, setRulesOpen] = useState(false)
|
||||||
|
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
|
||||||
|
|
||||||
const updateConfig = (updates: Partial<FilterConfig>) => {
|
const updateConfig = (updates: Partial<FilterConfig>) => {
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rules = config.rules ?? []
|
||||||
|
const aiCriteriaText = config.aiCriteriaText ?? ''
|
||||||
|
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
|
||||||
|
|
||||||
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
|
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
|
||||||
const updated = [...config.rules]
|
const updated = [...rules]
|
||||||
updated[index] = { ...updated[index], ...updates }
|
updated[index] = { ...updated[index], ...updates }
|
||||||
onChange({ ...config, rules: updated })
|
onChange({ ...config, rules: updated })
|
||||||
}
|
}
|
||||||
@@ -36,114 +122,138 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
|||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
rules: [
|
rules: [
|
||||||
...config.rules,
|
...rules,
|
||||||
{ field: '', operator: 'equals', value: '', weight: 1 },
|
{ field: '', operator: 'is', value: '', weight: 1 },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeRule = (index: number) => {
|
const removeRule = (index: number) => {
|
||||||
onChange({ ...config, rules: config.rules.filter((_, i) => i !== index) })
|
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
|
||||||
|
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const highPct = Math.round(thresholds.high * 100)
|
||||||
|
const medPct = Math.round(thresholds.medium * 100)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Deterministic Gate Rules */}
|
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
|
||||||
<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="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>AI Screening</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Brain className="h-4 w-4 text-primary" />
|
||||||
Use AI to evaluate projects against rubric criteria
|
<Label>AI Screening</Label>
|
||||||
|
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Use AI to evaluate projects against your screening criteria
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.aiRubricEnabled}
|
checked={config.aiRubricEnabled}
|
||||||
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
|
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.aiRubricEnabled && (
|
{config.aiRubricEnabled && (
|
||||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||||
|
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">High Confidence Threshold</Label>
|
<Label className="text-sm font-medium">Screening Criteria</Label>
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Describe what makes a project eligible or ineligible in natural language.
|
||||||
|
The AI will evaluate each project against these criteria.
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={aiCriteriaText}
|
||||||
|
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
|
||||||
|
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
|
||||||
|
rows={5}
|
||||||
|
className="resize-y"
|
||||||
|
disabled={isActive}
|
||||||
|
/>
|
||||||
|
{aiCriteriaText.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{aiCriteriaText.length} characters
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* "What the AI sees" Info Card */}
|
||||||
|
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||||
|
>
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
<span>What the AI sees</span>
|
||||||
|
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<Card className="mt-2 bg-muted/50 border-muted">
|
||||||
|
<CardContent className="pt-3 pb-3 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
All data is anonymized before being sent to the AI. Only these fields are included:
|
||||||
|
</p>
|
||||||
|
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||||
|
{AI_VISIBLE_FIELDS.map((field) => (
|
||||||
|
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
|
{field}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-2 italic">
|
||||||
|
No personal identifiers (names, emails, etc.) are sent to the AI.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Confidence Thresholds */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Confidence Thresholds</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Control how the AI's confidence score maps to outcomes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Visual range preview */}
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-medium">
|
||||||
|
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
|
||||||
|
Auto-approve above {highPct}%
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
|
||||||
|
Review {medPct}%{'\u2013'}{highPct}%
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
|
||||||
|
Auto-reject below {medPct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* High threshold slider */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
|
||||||
|
<Label className="text-xs">Auto-approve threshold</Label>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-medium">{highPct}%</span>
|
||||||
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[config.aiConfidenceThresholds.high * 100]}
|
value={[highPct]}
|
||||||
onValueChange={([v]) =>
|
onValueChange={([v]) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
aiConfidenceThresholds: {
|
aiConfidenceThresholds: {
|
||||||
...config.aiConfidenceThresholds,
|
...thresholds,
|
||||||
high: v / 100,
|
high: v / 100,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -151,22 +261,25 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
|||||||
min={50}
|
min={50}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
className="flex-1"
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono w-10 text-right">
|
|
||||||
{Math.round(config.aiConfidenceThresholds.high * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
{/* Medium threshold slider */}
|
||||||
<Label className="text-xs">Medium Confidence Threshold</Label>
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
|
||||||
|
<Label className="text-xs">Manual review threshold</Label>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-medium">{medPct}%</span>
|
||||||
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[config.aiConfidenceThresholds.medium * 100]}
|
value={[medPct]}
|
||||||
onValueChange={([v]) =>
|
onValueChange={([v]) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
aiConfidenceThresholds: {
|
aiConfidenceThresholds: {
|
||||||
...config.aiConfidenceThresholds,
|
...thresholds,
|
||||||
medium: v / 100,
|
medium: v / 100,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -174,21 +287,21 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
|||||||
min={20}
|
min={20}
|
||||||
max={80}
|
max={80}
|
||||||
step={5}
|
step={5}
|
||||||
className="flex-1"
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono w-10 text-right">
|
|
||||||
{Math.round(config.aiConfidenceThresholds.medium * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Queue */}
|
{/* ── Manual Review Queue ────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Manual Review Queue</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Manual Review Queue</Label>
|
||||||
|
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Projects below medium confidence go to manual review
|
Projects below medium confidence go to manual review
|
||||||
</p>
|
</p>
|
||||||
@@ -196,8 +309,171 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={config.manualQueueEnabled}
|
checked={config.manualQueueEnabled}
|
||||||
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
|
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
|
||||||
|
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="cursor-pointer">Eligibility Rules</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({rules.length} rule{rules.length !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
{rulesOpen && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||||
|
Deterministic rules that projects must pass. Applied before AI screening.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="space-y-3 mt-3">
|
||||||
|
{rules.map((rule, index) => {
|
||||||
|
const fieldConfig = getFieldConfig(rule.field)
|
||||||
|
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="pt-3 pb-3 px-4 space-y-2">
|
||||||
|
{/* Rule inputs */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 grid gap-2 sm:grid-cols-3">
|
||||||
|
{/* Field dropdown */}
|
||||||
|
<Select
|
||||||
|
value={rule.field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newFieldConfig = getFieldConfig(value)
|
||||||
|
const firstOp = newFieldConfig?.operators[0] ?? 'is'
|
||||||
|
updateRule(index, {
|
||||||
|
field: value,
|
||||||
|
operator: firstOp,
|
||||||
|
value: newFieldConfig?.valueType === 'boolean' ? true : '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={isActive}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Select field..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{KNOWN_FIELDS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Operator dropdown (filtered by field) */}
|
||||||
|
<Select
|
||||||
|
value={rule.operator}
|
||||||
|
onValueChange={(value) => updateRule(index, { operator: value })}
|
||||||
|
disabled={isActive || !rule.field}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableOperators.map((op) => (
|
||||||
|
<SelectItem key={op} value={op}>
|
||||||
|
{OPERATOR_LABELS[op] ?? op}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Value input (adapted by field type) */}
|
||||||
|
{rule.operator === 'exists' ? (
|
||||||
|
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
|
||||||
|
(no value needed)
|
||||||
|
</div>
|
||||||
|
) : fieldConfig?.valueType === 'boolean' ? (
|
||||||
|
<Select
|
||||||
|
value={String(rule.value)}
|
||||||
|
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
|
||||||
|
disabled={isActive}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Yes</SelectItem>
|
||||||
|
<SelectItem value="false">No</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : fieldConfig?.valueType === 'number' ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={fieldConfig.placeholder ?? 'Value'}
|
||||||
|
value={String(rule.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
disabled={isActive}
|
||||||
|
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
placeholder={fieldConfig?.placeholder ?? 'Value'}
|
||||||
|
value={String(rule.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
disabled={isActive}
|
||||||
|
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 mt-0.5"
|
||||||
|
onClick={() => removeRule(index)}
|
||||||
|
disabled={isActive}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Human-readable preview */}
|
||||||
|
{rule.field && rule.operator && (
|
||||||
|
<p className="text-xs text-muted-foreground italic pl-1">
|
||||||
|
{getRulePreview(rule)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-3">
|
||||||
|
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!rulesOpen ? null : rules.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,85 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Plus, Trash2, FileText } from 'lucide-react'
|
import { Plus, Trash2, FileText } from 'lucide-react'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
|
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
|
||||||
|
import {
|
||||||
|
FILE_TYPE_CATEGORIES,
|
||||||
|
getActiveCategoriesFromMimeTypes,
|
||||||
|
categoriesToMimeTypes,
|
||||||
|
} from '@/lib/file-type-categories'
|
||||||
|
|
||||||
|
type FileTypePickerProps = {
|
||||||
|
value: string[]
|
||||||
|
onChange: (mimeTypes: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
|
||||||
|
const activeCategories = getActiveCategoriesFromMimeTypes(value)
|
||||||
|
|
||||||
|
const toggleCategory = (categoryId: string) => {
|
||||||
|
const isActive = activeCategories.includes(categoryId)
|
||||||
|
const newCategories = isActive
|
||||||
|
? activeCategories.filter((id) => id !== categoryId)
|
||||||
|
: [...activeCategories, categoryId]
|
||||||
|
onChange(categoriesToMimeTypes(newCategories))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Accepted Types</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{FILE_TYPE_CATEGORIES.map((cat) => {
|
||||||
|
const isActive = activeCategories.includes(cat.id)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cat.id}
|
||||||
|
type="button"
|
||||||
|
variant={isActive ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
onClick={() => toggleCategory(cat.id)}
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{activeCategories.length === 0 ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">All types</Badge>
|
||||||
|
) : (
|
||||||
|
activeCategories.map((catId) => {
|
||||||
|
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
|
||||||
|
return cat ? (
|
||||||
|
<Badge key={catId} variant="secondary" className="text-[10px]">
|
||||||
|
{cat.label}
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type IntakeSectionProps = {
|
type IntakeSectionProps = {
|
||||||
config: IntakeConfig
|
config: IntakeConfig
|
||||||
onChange: (config: IntakeConfig) => void
|
onChange: (config: IntakeConfig) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
|
||||||
const updateConfig = (updates: Partial<IntakeConfig>) => {
|
const updateConfig = (updates: Partial<IntakeConfig>) => {
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileRequirements = config.fileRequirements ?? []
|
||||||
|
|
||||||
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
|
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
|
||||||
const updated = [...config.fileRequirements]
|
const updated = [...fileRequirements]
|
||||||
updated[index] = { ...updated[index], ...updates }
|
updated[index] = { ...updated[index], ...updates }
|
||||||
onChange({ ...config, fileRequirements: updated })
|
onChange({ ...config, fileRequirements: updated })
|
||||||
}
|
}
|
||||||
@@ -35,7 +99,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
fileRequirements: [
|
fileRequirements: [
|
||||||
...config.fileRequirements,
|
...fileRequirements,
|
||||||
{
|
{
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -48,26 +112,36 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeFileReq = (index: number) => {
|
const removeFileReq = (index: number) => {
|
||||||
const updated = config.fileRequirements.filter((_, i) => i !== index)
|
const updated = fileRequirements.filter((_, i) => i !== index)
|
||||||
onChange({ ...config, fileRequirements: updated })
|
onChange({ ...config, fileRequirements: updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Submission Window */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Submission Window</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Submission Window</Label>
|
||||||
|
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Enable timed submission windows for project intake
|
Enable timed submission windows for project intake
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.submissionWindowEnabled}
|
checked={config.submissionWindowEnabled ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateConfig({ submissionWindowEnabled: checked })
|
updateConfig({ submissionWindowEnabled: checked })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,14 +149,18 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
{/* Late Policy */}
|
{/* Late Policy */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Late Submission Policy</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Late Submission Policy</Label>
|
||||||
|
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={config.lateSubmissionPolicy}
|
value={config.lateSubmissionPolicy ?? 'flag'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
|
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -95,14 +173,17 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.lateSubmissionPolicy === 'flag' && (
|
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Grace Period (hours)</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Grace Period (hours)</Label>
|
||||||
|
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={168}
|
max={168}
|
||||||
value={config.lateGraceHours}
|
value={config.lateGraceHours ?? 24}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
|
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
|
||||||
}
|
}
|
||||||
@@ -114,20 +195,23 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
{/* File Requirements */}
|
{/* File Requirements */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>File Requirements</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addFileReq}>
|
<Label>File Requirements</Label>
|
||||||
|
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
Add Requirement
|
Add Requirement
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.fileRequirements.length === 0 && (
|
{fileRequirements.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
No file requirements configured. Projects can be submitted without files.
|
No file requirements configured. Projects can be submitted without files.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.fileRequirements.map((req, index) => (
|
{fileRequirements.map((req, index) => (
|
||||||
<Card key={index}>
|
<Card key={index}>
|
||||||
<CardContent className="pt-4 space-y-3">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -176,6 +260,14 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
<Label className="text-xs">Required</Label>
|
<Label className="text-xs">Required</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FileTypePicker
|
||||||
|
value={req.acceptedMimeTypes ?? []}
|
||||||
|
onChange={(mimeTypes) =>
|
||||||
|
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -183,6 +275,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => removeFileReq(index)}
|
onClick={() => removeFileReq(index)}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type LiveFinalsSectionProps = {
|
type LiveFinalsSectionProps = {
|
||||||
config: LiveFinalConfig
|
config: LiveFinalConfig
|
||||||
onChange: (config: LiveFinalConfig) => void
|
onChange: (config: LiveFinalConfig) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
|
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
|
||||||
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
|
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
@@ -26,42 +28,53 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Jury Voting</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Jury Voting</Label>
|
||||||
|
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Allow jury members to vote during the live finals event
|
Allow jury members to vote during the live finals event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.juryVotingEnabled}
|
checked={config.juryVotingEnabled ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateConfig({ juryVotingEnabled: checked })
|
updateConfig({ juryVotingEnabled: checked })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Audience Voting</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Audience Voting</Label>
|
||||||
|
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Allow audience members to vote on projects
|
Allow audience members to vote on projects
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.audienceVotingEnabled}
|
checked={config.audienceVotingEnabled ?? false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateConfig({ audienceVotingEnabled: checked })
|
updateConfig({ audienceVotingEnabled: checked })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.audienceVotingEnabled && (
|
{(config.audienceVotingEnabled ?? false) && (
|
||||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Audience Vote Weight</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label className="text-xs">Audience Vote Weight</Label>
|
||||||
|
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
value={[config.audienceVoteWeight * 100]}
|
value={[(config.audienceVoteWeight ?? 0) * 100]}
|
||||||
onValueChange={([v]) =>
|
onValueChange={([v]) =>
|
||||||
updateConfig({ audienceVoteWeight: v / 100 })
|
updateConfig({ audienceVoteWeight: v / 100 })
|
||||||
}
|
}
|
||||||
@@ -71,7 +84,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono w-10 text-right">
|
<span className="text-xs font-mono w-10 text-right">
|
||||||
{Math.round(config.audienceVoteWeight * 100)}%
|
{Math.round((config.audienceVoteWeight ?? 0) * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -83,14 +96,18 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Cohort Setup Mode</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Cohort Setup Mode</Label>
|
||||||
|
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={config.cohortSetupMode}
|
value={config.cohortSetupMode ?? 'manual'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
|
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -107,14 +124,18 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Result Reveal Policy</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>Result Reveal Policy</Label>
|
||||||
|
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={config.revealPolicy}
|
value={config.revealPolicy ?? 'ceremony'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateConfig({
|
updateConfig({
|
||||||
revealPolicy: value as LiveFinalConfig['revealPolicy'],
|
revealPolicy: value as LiveFinalConfig['revealPolicy'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { WizardStageConfig } from '@/types/pipeline-wizard'
|
import type { WizardStageConfig } from '@/types/pipeline-wizard'
|
||||||
import type { StageType } from '@prisma/client'
|
import type { StageType } from '@prisma/client'
|
||||||
|
|
||||||
type MainTrackSectionProps = {
|
type MainTrackSectionProps = {
|
||||||
stages: WizardStageConfig[]
|
stages: WizardStageConfig[]
|
||||||
onChange: (stages: WizardStageConfig[]) => void
|
onChange: (stages: WizardStageConfig[]) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
|
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
|
||||||
@@ -45,7 +47,7 @@ function slugify(name: string): string {
|
|||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
|
||||||
const updateStage = useCallback(
|
const updateStage = useCallback(
|
||||||
(index: number, updates: Partial<WizardStageConfig>) => {
|
(index: number, updates: Partial<WizardStageConfig>) => {
|
||||||
const updated = [...stages]
|
const updated = [...stages]
|
||||||
@@ -90,20 +92,33 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
Define the stages projects flow through in the main competition track.
|
<p className="text-sm text-muted-foreground">
|
||||||
Drag to reorder. Minimum 2 stages required.
|
Define the stages projects flow through in the main competition track.
|
||||||
</p>
|
Drag to reorder. Minimum 2 stages required.
|
||||||
|
</p>
|
||||||
|
<InfoTooltip
|
||||||
|
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
|
||||||
|
side="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</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" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
Add Stage
|
Add Stage
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
{stages.map((stage, index) => {
|
{stages.map((stage, index) => {
|
||||||
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
|
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 (
|
return (
|
||||||
<Card key={index}>
|
<Card key={index}>
|
||||||
<CardContent className="py-3 px-4">
|
<CardContent className="py-3 px-4">
|
||||||
@@ -115,7 +130,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
disabled={index === 0}
|
disabled={isActive || index === 0}
|
||||||
onClick={() => moveStage(index, 'up')}
|
onClick={() => moveStage(index, 'up')}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -126,7 +141,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
disabled={index === stages.length - 1}
|
disabled={isActive || index === stages.length - 1}
|
||||||
onClick={() => moveStage(index, 'down')}
|
onClick={() => moveStage(index, 'down')}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
@@ -143,12 +158,16 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Stage name"
|
placeholder="Stage name"
|
||||||
value={stage.name}
|
value={stage.name}
|
||||||
className="h-8 text-sm"
|
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
|
||||||
|
disabled={isActive}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const name = e.target.value
|
const name = e.target.value
|
||||||
updateStage(index, { name, slug: slugify(name) })
|
updateStage(index, { name, slug: slugify(name) })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{hasDuplicateSlug && (
|
||||||
|
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stage type */}
|
{/* Stage type */}
|
||||||
@@ -158,6 +177,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateStage(index, { stageType: value as StageType })
|
updateStage(index, { stageType: value as StageType })
|
||||||
}
|
}
|
||||||
|
disabled={isActive}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -186,7 +206,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
|
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)}
|
onClick={() => removeStage(index)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Bell } from 'lucide-react'
|
import { Bell } from 'lucide-react'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
|
|
||||||
type NotificationsSectionProps = {
|
type NotificationsSectionProps = {
|
||||||
config: Record<string, boolean>
|
config: Record<string, boolean>
|
||||||
onChange: (config: Record<string, boolean>) => void
|
onChange: (config: Record<string, boolean>) => void
|
||||||
overridePolicy: Record<string, unknown>
|
overridePolicy: Record<string, unknown>
|
||||||
onOverridePolicyChange: (policy: Record<string, unknown>) => void
|
onOverridePolicyChange: (policy: Record<string, unknown>) => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIFICATION_EVENTS = [
|
const NOTIFICATION_EVENTS = [
|
||||||
@@ -60,6 +62,7 @@ export function NotificationsSection({
|
|||||||
onChange,
|
onChange,
|
||||||
overridePolicy,
|
overridePolicy,
|
||||||
onOverridePolicyChange,
|
onOverridePolicyChange,
|
||||||
|
isActive,
|
||||||
}: NotificationsSectionProps) {
|
}: NotificationsSectionProps) {
|
||||||
const toggleEvent = (key: string, enabled: boolean) => {
|
const toggleEvent = (key: string, enabled: boolean) => {
|
||||||
onChange({ ...config, [key]: enabled })
|
onChange({ ...config, [key]: enabled })
|
||||||
@@ -67,10 +70,11 @@ export function NotificationsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Choose which pipeline events trigger notifications. All events are enabled by default.
|
Choose which pipeline events trigger notifications. All events are enabled by default.
|
||||||
</p>
|
</p>
|
||||||
|
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -88,6 +92,7 @@ export function NotificationsSection({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={config[event.key] !== false}
|
checked={config[event.key] !== false}
|
||||||
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
|
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
|
||||||
|
disabled={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
|
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
|
||||||
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { validateAll } from '@/lib/pipeline-validation'
|
import { validateAll } from '@/lib/pipeline-validation'
|
||||||
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
|
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
|
||||||
@@ -97,7 +98,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
|
|||||||
{/* Validation Checks */}
|
{/* Validation Checks */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Validation Checks</CardTitle>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CardTitle className="text-sm">Validation Checks</CardTitle>
|
||||||
|
<InfoTooltip content="Automated checks that verify all required fields are filled and configuration is consistent before saving." />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="divide-y">
|
<CardContent className="divide-y">
|
||||||
<ValidationSection label="Basics" result={validation.sections.basics} />
|
<ValidationSection label="Basics" result={validation.sections.basics} />
|
||||||
@@ -109,7 +113,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
|
|||||||
{/* Structure Summary */}
|
{/* Structure Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Structure Summary</CardTitle>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CardTitle className="text-sm">Structure Summary</CardTitle>
|
||||||
|
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
|||||||
367
src/components/admin/pipeline/stage-config-editor.tsx
Normal file
367
src/components/admin/pipeline/stage-config-editor.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { EditableCard } from '@/components/ui/editable-card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
Filter,
|
||||||
|
ClipboardCheck,
|
||||||
|
Trophy,
|
||||||
|
Tv,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||||
|
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||||
|
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||||
|
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultIntakeConfig,
|
||||||
|
defaultFilterConfig,
|
||||||
|
defaultEvaluationConfig,
|
||||||
|
defaultLiveConfig,
|
||||||
|
} from '@/lib/pipeline-defaults'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IntakeConfig,
|
||||||
|
FilterConfig,
|
||||||
|
EvaluationConfig,
|
||||||
|
LiveFinalConfig,
|
||||||
|
} from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
|
type StageConfigEditorProps = {
|
||||||
|
stageId: string
|
||||||
|
stageName: string
|
||||||
|
stageType: string
|
||||||
|
configJson: Record<string, unknown> | null
|
||||||
|
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||||
|
isSaving?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageIcons: Record<string, React.ReactNode> = {
|
||||||
|
INTAKE: <Inbox className="h-4 w-4" />,
|
||||||
|
FILTER: <Filter className="h-4 w-4" />,
|
||||||
|
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
|
||||||
|
SELECTION: <Trophy className="h-4 w-4" />,
|
||||||
|
LIVE_FINAL: <Tv className="h-4 w-4" />,
|
||||||
|
RESULTS: <BarChart3 className="h-4 w-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSummary({
|
||||||
|
stageType,
|
||||||
|
configJson,
|
||||||
|
}: {
|
||||||
|
stageType: string
|
||||||
|
configJson: Record<string, unknown> | null
|
||||||
|
}) {
|
||||||
|
if (!configJson) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No configuration set
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (stageType) {
|
||||||
|
case 'INTAKE': {
|
||||||
|
const config = configJson as unknown as IntakeConfig
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Submission Window:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Late Policy:</span>
|
||||||
|
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
|
||||||
|
{(config.lateGraceHours ?? 0) > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({config.lateGraceHours}h grace)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">File Requirements:</span>
|
||||||
|
<span>{config.fileRequirements?.length ?? 0} configured</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FILTER': {
|
||||||
|
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>{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]">
|
||||||
|
{aiEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Manual Queue:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EVALUATION': {
|
||||||
|
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>{reviews}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Load per Juror:</span>
|
||||||
|
<span>
|
||||||
|
{minLoad} - {maxLoad}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Overflow Policy:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{overflow.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SELECTION': {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Ranking Method:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.rankingMethod as string) ?? 'score_average').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Tie Breaker:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{configJson.finalistCount != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Finalist Count:</span>
|
||||||
|
<span>{String(configJson.finalistCount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LIVE_FINAL': {
|
||||||
|
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]">
|
||||||
|
{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]">
|
||||||
|
{audienceEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
{audienceEnabled && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({Math.round(audienceWeight * 100)}% weight)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Reveal:</span>
|
||||||
|
<span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RESULTS': {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Publication:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.publicationMode as string) ?? 'manual').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Show Scores:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{configJson.showDetailedScores ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
Configuration view not available for this stage type
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageConfigEditor({
|
||||||
|
stageId,
|
||||||
|
stageName,
|
||||||
|
stageType,
|
||||||
|
configJson,
|
||||||
|
onSave,
|
||||||
|
isSaving = false,
|
||||||
|
}: StageConfigEditorProps) {
|
||||||
|
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
||||||
|
() => configJson ?? {}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
await onSave(stageId, localConfig)
|
||||||
|
}, [stageId, localConfig, onSave])
|
||||||
|
|
||||||
|
const renderEditor = () => {
|
||||||
|
switch (stageType) {
|
||||||
|
case 'INTAKE': {
|
||||||
|
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}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'FILTER': {
|
||||||
|
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(),
|
||||||
|
...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}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'EVALUATION': {
|
||||||
|
const raw = localConfig as Record<string, unknown>
|
||||||
|
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
|
||||||
|
const config: EvaluationConfig = {
|
||||||
|
...defaultEvaluationConfig(),
|
||||||
|
...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}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'LIVE_FINAL': {
|
||||||
|
const raw = localConfig as Record<string, unknown>
|
||||||
|
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
|
||||||
|
const config: LiveFinalConfig = {
|
||||||
|
...defaultLiveConfig(),
|
||||||
|
...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}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'SELECTION':
|
||||||
|
case 'RESULTS':
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
Configuration for {stageType.replace('_', ' ')} stages is managed
|
||||||
|
through the stage settings.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditableCard
|
||||||
|
title={`${stageName} Configuration`}
|
||||||
|
icon={stageIcons[stageType]}
|
||||||
|
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
>
|
||||||
|
{renderEditor()}
|
||||||
|
</EditableCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,11 +122,16 @@ export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{config?.aiRubricEnabled && (
|
{config?.aiRubricEnabled && (
|
||||||
<div className="mt-3 pt-3 border-t">
|
<div className="mt-3 pt-3 border-t space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
|
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
|
||||||
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
|
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
|
||||||
</p>
|
</p>
|
||||||
|
{config.aiCriteriaText && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
Criteria: {config.aiCriteriaText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -111,15 +113,18 @@ export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{projectStates.items.map((ps) => (
|
{projectStates.items.map((ps) => (
|
||||||
<div
|
<Link
|
||||||
key={ps.id}
|
key={ps.id}
|
||||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
href={`/admin/projects/${ps.project.id}` as Route}
|
||||||
|
className="block"
|
||||||
>
|
>
|
||||||
<span className="truncate">{ps.project.title}</span>
|
<div className="flex items-center justify-between text-sm py-1.5 border-b last:border-0 hover:bg-muted/50 cursor-pointer rounded-md px-1 transition-colors">
|
||||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
<span className="truncate">{ps.project.title}</span>
|
||||||
{ps.state}
|
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||||
</Badge>
|
{ps.state}
|
||||||
</div>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
|
|
||||||
|
|
||||||
type WizardSectionProps = {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
stepNumber: number
|
|
||||||
isOpen: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
isValid: boolean
|
|
||||||
hasErrors?: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WizardSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
stepNumber,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
isValid,
|
|
||||||
hasErrors,
|
|
||||||
children,
|
|
||||||
}: WizardSectionProps) {
|
|
||||||
return (
|
|
||||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
|
||||||
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge
|
|
||||||
variant={isValid ? 'default' : 'outline'}
|
|
||||||
className={cn(
|
|
||||||
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
|
|
||||||
isValid
|
|
||||||
? 'bg-emerald-500 text-white hover:bg-emerald-500'
|
|
||||||
: hasErrors
|
|
||||||
? 'border-destructive text-destructive'
|
|
||||||
: ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isValid ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
) : hasErrors ? (
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
stepNumber
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
|
||||||
{description && !isOpen && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 text-muted-foreground transition-transform',
|
|
||||||
isOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="pt-0">{children}</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/components/ui/editable-card.tsx
Normal file
121
src/components/ui/editable-card.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Pencil, X, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
type EditableCardProps = {
|
||||||
|
title: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
summary: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
onSave?: () => void | Promise<void>
|
||||||
|
isSaving?: boolean
|
||||||
|
alwaysShowEdit?: boolean
|
||||||
|
defaultEditing?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
summary,
|
||||||
|
children,
|
||||||
|
onSave,
|
||||||
|
isSaving = false,
|
||||||
|
alwaysShowEdit = false,
|
||||||
|
defaultEditing = false,
|
||||||
|
className,
|
||||||
|
}: EditableCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(defaultEditing)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (onSave) {
|
||||||
|
await onSave()
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('group relative overflow-hidden', className)}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && (
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={cn(
|
||||||
|
'h-7 gap-1.5 text-xs',
|
||||||
|
!alwaysShowEdit && 'sm:opacity-0 sm:group-hover:opacity-100 transition-opacity'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isEditing ? (
|
||||||
|
<motion.div
|
||||||
|
key="edit"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children}
|
||||||
|
{onSave && (
|
||||||
|
<div className="flex justify-end pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="view"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ui/info-tooltip.tsx
Normal file
36
src/components/ui/info-tooltip.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
type InfoTooltipProps = {
|
||||||
|
content: string
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More info</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={side} className="max-w-xs text-sm">
|
||||||
|
{content}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
src/components/ui/inline-editable-text.tsx
Normal file
179
src/components/ui/inline-editable-text.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Pencil, Check, X } from 'lucide-react'
|
||||||
|
|
||||||
|
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
|
||||||
|
|
||||||
|
type InlineEditableTextProps = {
|
||||||
|
value: string
|
||||||
|
onSave: (newValue: string) => void | Promise<void>
|
||||||
|
variant?: InlineEditableTextVariant
|
||||||
|
placeholder?: string
|
||||||
|
multiline?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<InlineEditableTextVariant, string> = {
|
||||||
|
h1: 'text-xl font-bold',
|
||||||
|
h2: 'text-base font-semibold',
|
||||||
|
body: 'text-sm',
|
||||||
|
mono: 'text-sm font-mono text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineEditableText({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
variant = 'body',
|
||||||
|
placeholder = 'Click to edit...',
|
||||||
|
multiline = false,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
}: InlineEditableTextProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState(value)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}, [isEditing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const trimmed = editValue.trim()
|
||||||
|
if (trimmed === value) {
|
||||||
|
setIsEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!trimmed) {
|
||||||
|
setEditValue(value)
|
||||||
|
setIsEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(trimmed)
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch {
|
||||||
|
setEditValue(value)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [editValue, value, onSave])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setEditValue(value)
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (multiline && !e.ctrlKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleCancel, handleSave, multiline]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<span className={cn(variantStyles[variant], className)}>
|
||||||
|
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isEditing ? (
|
||||||
|
<motion.div
|
||||||
|
key="editing"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={cn('flex items-center gap-1.5', className)}
|
||||||
|
>
|
||||||
|
{multiline ? (
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(variantStyles[variant], 'h-auto py-1')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.button
|
||||||
|
key="viewing"
|
||||||
|
type="button"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={cn(
|
||||||
|
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
|
||||||
|
'hover:bg-muted/60 transition-colors text-left cursor-text',
|
||||||
|
variantStyles[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(!value && 'text-muted-foreground italic')}>
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<Pencil className="h-3 w-3 shrink-0 opacity-30 sm:opacity-0 sm:group-hover:opacity-50 transition-opacity" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
262
src/components/ui/sidebar-stepper.tsx
Normal file
262
src/components/ui/sidebar-stepper.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
|
||||||
|
|
||||||
|
export type StepConfig = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
isValid?: boolean
|
||||||
|
hasErrors?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarStepperProps = {
|
||||||
|
steps: StepConfig[]
|
||||||
|
currentStep: number
|
||||||
|
onStepChange: (index: number) => void
|
||||||
|
onSave?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
isSaving?: boolean
|
||||||
|
isSubmitting?: boolean
|
||||||
|
saveLabel?: string
|
||||||
|
submitLabel?: string
|
||||||
|
canSubmit?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarStepper({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onStepChange,
|
||||||
|
onSave,
|
||||||
|
onSubmit,
|
||||||
|
isSaving = false,
|
||||||
|
isSubmitting = false,
|
||||||
|
saveLabel = 'Save Draft',
|
||||||
|
submitLabel = 'Create Pipeline',
|
||||||
|
canSubmit = true,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarStepperProps) {
|
||||||
|
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-6 min-h-[600px]', className)}>
|
||||||
|
{/* Sidebar - hidden on mobile */}
|
||||||
|
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
|
||||||
|
<nav className="flex-1 space-y-1 py-2">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCurrent = index === currentStep
|
||||||
|
const isComplete = step.isValid === true
|
||||||
|
const hasErrors = step.hasErrors === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepChange(index)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||||
|
isCurrent
|
||||||
|
? 'bg-primary/5 border border-primary/20'
|
||||||
|
: 'hover:bg-muted/50 border border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
|
||||||
|
isCurrent && 'bg-primary text-primary-foreground',
|
||||||
|
!isCurrent && isComplete && 'bg-emerald-500 text-white',
|
||||||
|
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
|
||||||
|
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isComplete && !isCurrent ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
) : hasErrors && !isCurrent ? (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium truncate',
|
||||||
|
isCurrent && 'text-primary',
|
||||||
|
!isCurrent && 'text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
{step.description && (
|
||||||
|
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t pt-4 space-y-2 mt-auto">
|
||||||
|
{onSave && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onSubmit && (
|
||||||
|
<Button
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={isSubmitting || isSaving || !canSubmit}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile step indicator */}
|
||||||
|
<div className="lg:hidden flex flex-col w-full">
|
||||||
|
<MobileStepIndicator
|
||||||
|
steps={steps}
|
||||||
|
currentStep={currentStep}
|
||||||
|
onStepChange={onStepChange}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 mt-4">
|
||||||
|
<StepContent currentStep={currentStep} direction={direction}>
|
||||||
|
{children}
|
||||||
|
</StepContent>
|
||||||
|
</div>
|
||||||
|
{/* Mobile actions */}
|
||||||
|
<div className="flex gap-2 pt-4 border-t mt-4">
|
||||||
|
{onSave && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onSubmit && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isSubmitting || isSaving || !canSubmit}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop content */}
|
||||||
|
<div className="hidden lg:block flex-1 min-w-0">
|
||||||
|
<StepContent currentStep={currentStep} direction={direction}>
|
||||||
|
{children}
|
||||||
|
</StepContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileStepIndicator({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onStepChange,
|
||||||
|
}: {
|
||||||
|
steps: StepConfig[]
|
||||||
|
currentStep: number
|
||||||
|
onStepChange: (index: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="flex items-center gap-1 pb-2 min-w-max">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCurrent = index === currentStep
|
||||||
|
const isComplete = step.isValid === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepChange(index)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
|
||||||
|
isCurrent && 'bg-primary text-primary-foreground',
|
||||||
|
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
|
||||||
|
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isComplete && !isCurrent ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{step.title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepContent({
|
||||||
|
currentStep,
|
||||||
|
direction,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
currentStep: number
|
||||||
|
direction: (prev: number, next: number) => number
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const childArray = Array.isArray(children) ? children : [children]
|
||||||
|
const currentChild = childArray[currentStep]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={currentStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentChild}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
src/hooks/use-pipeline-inline-edit.ts
Normal file
46
src/hooks/use-pipeline-inline-edit.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export function usePipelineInlineEdit(pipelineId: string) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const updateMutation = trpc.pipeline.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to update pipeline: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to update stage: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatePipeline = async (
|
||||||
|
data: { name?: string; slug?: string; status?: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED'; settingsJson?: Record<string, unknown> }
|
||||||
|
) => {
|
||||||
|
await updateMutation.mutateAsync({ id: pipelineId, ...data })
|
||||||
|
toast.success('Pipeline updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStageConfig = async (
|
||||||
|
stageId: string,
|
||||||
|
configJson: Record<string, unknown>,
|
||||||
|
name?: string
|
||||||
|
) => {
|
||||||
|
await updateConfigMutation.mutateAsync({ id: stageId, configJson, name })
|
||||||
|
toast.success('Stage configuration updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUpdating: updateMutation.isPending || updateConfigMutation.isPending,
|
||||||
|
updatePipeline,
|
||||||
|
updateStageConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/lib/file-type-categories.ts
Normal file
30
src/lib/file-type-categories.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type FileTypeCategory = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
mimeTypes: string[]
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
|
||||||
|
{ id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
|
||||||
|
{ id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
|
||||||
|
{ id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
|
||||||
|
{ id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
|
||||||
|
{ id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
|
||||||
|
{ id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Get active category IDs from a list of mime types */
|
||||||
|
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
|
||||||
|
if (!mimeTypes || !Array.isArray(mimeTypes)) return []
|
||||||
|
return FILE_TYPE_CATEGORIES.filter((cat) =>
|
||||||
|
cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
|
||||||
|
).map((cat) => cat.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert category IDs to flat mime type array */
|
||||||
|
export function categoriesToMimeTypes(categoryIds: string[]): string[] {
|
||||||
|
return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
|
||||||
|
(cat) => cat.mimeTypes
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export function defaultFilterConfig(): FilterConfig {
|
|||||||
return {
|
return {
|
||||||
rules: [],
|
rules: [],
|
||||||
aiRubricEnabled: false,
|
aiRubricEnabled: false,
|
||||||
|
aiCriteriaText: '',
|
||||||
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
|
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
|
||||||
manualQueueEnabled: true,
|
manualQueueEnabled: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, observerProcedure } from '../trpc'
|
import { router, observerProcedure } from '../trpc'
|
||||||
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
|
|
||||||
const editionOrStageInput = z.object({
|
const editionOrStageInput = z.object({
|
||||||
stageId: z.string().optional(),
|
stageId: z.string().optional(),
|
||||||
@@ -384,9 +385,16 @@ export const analyticsRouter = router({
|
|||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return distribution.map((d) => ({
|
// Resolve country names to ISO codes (DB may store "France" instead of "FR")
|
||||||
countryCode: d.country || 'UNKNOWN',
|
const codeMap = new Map<string, number>()
|
||||||
count: d._count.id,
|
for (const d of distribution) {
|
||||||
|
const resolved = normalizeCountryToCode(d.country) ?? d.country ?? 'UNKNOWN'
|
||||||
|
codeMap.set(resolved, (codeMap.get(resolved) ?? 0) + d._count.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(codeMap.entries()).map(([countryCode, count]) => ({
|
||||||
|
countryCode,
|
||||||
|
count,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,19 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build per-juror limits map for jurors with personal maxAssignments
|
||||||
|
const jurorLimits: Record<string, number> = {}
|
||||||
|
for (const juror of jurors) {
|
||||||
|
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
|
||||||
|
jurorLimits[juror.id] = juror.maxAssignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const constraints = {
|
const constraints = {
|
||||||
requiredReviewsPerProject: requiredReviews,
|
requiredReviewsPerProject: requiredReviews,
|
||||||
minAssignmentsPerJuror,
|
minAssignmentsPerJuror,
|
||||||
maxAssignmentsPerJuror,
|
maxAssignmentsPerJuror,
|
||||||
|
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
|
||||||
existingAssignments: existingAssignments.map((a) => ({
|
existingAssignments: existingAssignments.map((a) => ({
|
||||||
jurorId: a.userId,
|
jurorId: a.userId,
|
||||||
projectId: a.projectId,
|
projectId: a.projectId,
|
||||||
@@ -420,8 +429,58 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Fetch per-juror maxAssignments and current counts for capacity checking
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { stageId: input.stageId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
// Get stage default max
|
||||||
|
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||||
|
where: { id: input.stageId },
|
||||||
|
select: { configJson: true, name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
|
|
||||||
|
// Track running counts to handle multiple assignments to the same juror in one batch
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out assignments that would exceed a juror's limit
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
const allowedAssignments = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true // unknown user, let createMany handle it
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment running count for subsequent assignments to same user
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const result = await ctx.prisma.assignment.createMany({
|
const result = await ctx.prisma.assignment.createMany({
|
||||||
data: input.assignments.map((a) => ({
|
data: allowedAssignments.map((a) => ({
|
||||||
...a,
|
...a,
|
||||||
stageId: input.stageId,
|
stageId: input.stageId,
|
||||||
method: 'BULK',
|
method: 'BULK',
|
||||||
@@ -436,15 +495,19 @@ export const assignmentRouter = router({
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'BULK_CREATE',
|
action: 'BULK_CREATE',
|
||||||
entityType: 'Assignment',
|
entityType: 'Assignment',
|
||||||
detailsJson: { count: result.count },
|
detailsJson: {
|
||||||
|
count: result.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send notifications to assigned jury members (grouped by user)
|
// Send notifications to assigned jury members (grouped by user)
|
||||||
if (result.count > 0 && input.assignments.length > 0) {
|
if (result.count > 0 && allowedAssignments.length > 0) {
|
||||||
// Group assignments by user to get counts
|
// Group assignments by user to get counts
|
||||||
const userAssignmentCounts = input.assignments.reduce(
|
const userAssignmentCounts = allowedAssignments.reduce(
|
||||||
(acc, a) => {
|
(acc, a) => {
|
||||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
@@ -452,11 +515,6 @@ export const assignmentRouter = router({
|
|||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
)
|
)
|
||||||
|
|
||||||
const stage = await ctx.prisma.stage.findUnique({
|
|
||||||
where: { id: input.stageId },
|
|
||||||
select: { name: true, windowCloseAt: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const deadline = stage?.windowCloseAt
|
const deadline = stage?.windowCloseAt
|
||||||
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
@@ -495,6 +553,7 @@ export const assignmentRouter = router({
|
|||||||
created: result.count,
|
created: result.count,
|
||||||
requested: input.assignments.length,
|
requested: input.assignments.length,
|
||||||
skipped: input.assignments.length - result.count,
|
skipped: input.assignments.length - result.count,
|
||||||
|
skippedDueToCapacity,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -826,11 +885,61 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
usedAI: z.boolean().default(false),
|
usedAI: z.boolean().default(false),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { stageId: input.stageId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
|
||||||
|
where: { id: input.stageId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const created = await ctx.prisma.assignment.createMany({
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
data: input.assignments.map((a) => ({
|
data: assignmentsToCreate.map((a) => ({
|
||||||
userId: a.userId,
|
userId: a.userId,
|
||||||
projectId: a.projectId,
|
projectId: a.projectId,
|
||||||
stageId: input.stageId,
|
stageId: input.stageId,
|
||||||
@@ -852,13 +961,15 @@ export const assignmentRouter = router({
|
|||||||
stageId: input.stageId,
|
stageId: input.stageId,
|
||||||
count: created.count,
|
count: created.count,
|
||||||
usedAI: input.usedAI,
|
usedAI: input.usedAI,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (created.count > 0) {
|
if (created.count > 0) {
|
||||||
const userAssignmentCounts = input.assignments.reduce(
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
(acc, a) => {
|
(acc, a) => {
|
||||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
@@ -905,7 +1016,11 @@ export const assignmentRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created: created.count }
|
return {
|
||||||
|
created: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -922,11 +1037,61 @@ export const assignmentRouter = router({
|
|||||||
reasoning: z.string().optional(),
|
reasoning: z.string().optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { stageId: input.stageId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
|
||||||
|
where: { id: input.stageId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const created = await ctx.prisma.assignment.createMany({
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
data: input.assignments.map((a) => ({
|
data: assignmentsToCreate.map((a) => ({
|
||||||
userId: a.userId,
|
userId: a.userId,
|
||||||
projectId: a.projectId,
|
projectId: a.projectId,
|
||||||
stageId: input.stageId,
|
stageId: input.stageId,
|
||||||
@@ -945,13 +1110,15 @@ export const assignmentRouter = router({
|
|||||||
detailsJson: {
|
detailsJson: {
|
||||||
stageId: input.stageId,
|
stageId: input.stageId,
|
||||||
count: created.count,
|
count: created.count,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (created.count > 0) {
|
if (created.count > 0) {
|
||||||
const userAssignmentCounts = input.assignments.reduce(
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
(acc, a) => {
|
(acc, a) => {
|
||||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
@@ -998,7 +1165,11 @@ export const assignmentRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created: created.count }
|
return {
|
||||||
|
created: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -696,6 +696,132 @@ export const fileRouter = router({
|
|||||||
return results
|
return results
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file requirements for a project from its pipeline's intake stage.
|
||||||
|
* Returns both configJson-based requirements and actual FileRequirement records,
|
||||||
|
* along with which ones are already fulfilled by uploaded files.
|
||||||
|
*/
|
||||||
|
getProjectRequirements: adminProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// 1. Get the project and its program
|
||||||
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: { programId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Find the pipeline for this program
|
||||||
|
const pipeline = await ctx.prisma.pipeline.findFirst({
|
||||||
|
where: { programId: project.programId },
|
||||||
|
include: {
|
||||||
|
tracks: {
|
||||||
|
where: { kind: 'MAIN' },
|
||||||
|
include: {
|
||||||
|
stages: {
|
||||||
|
where: { stageType: 'INTAKE' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!pipeline) return null
|
||||||
|
|
||||||
|
const mainTrack = pipeline.tracks[0]
|
||||||
|
if (!mainTrack) return null
|
||||||
|
|
||||||
|
const intakeStage = mainTrack.stages[0]
|
||||||
|
if (!intakeStage) return null
|
||||||
|
|
||||||
|
// 3. Check for actual FileRequirement records first
|
||||||
|
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
|
||||||
|
where: { stageId: intakeStage.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
files: {
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. If we have DB requirements, return those (they're the canonical source)
|
||||||
|
if (dbRequirements.length > 0) {
|
||||||
|
return {
|
||||||
|
stageId: intakeStage.id,
|
||||||
|
requirements: dbRequirements.map((req) => ({
|
||||||
|
id: req.id,
|
||||||
|
name: req.name,
|
||||||
|
description: req.description,
|
||||||
|
acceptedMimeTypes: req.acceptedMimeTypes,
|
||||||
|
maxSizeMB: req.maxSizeMB,
|
||||||
|
isRequired: req.isRequired,
|
||||||
|
fulfilled: req.files.length > 0,
|
||||||
|
fulfilledFile: req.files[0] ?? null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fall back to configJson requirements
|
||||||
|
const configJson = intakeStage.configJson as Record<string, unknown> | null
|
||||||
|
const fileRequirements = (configJson?.fileRequirements as Array<{
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
acceptedMimeTypes?: string[]
|
||||||
|
maxSizeMB?: number
|
||||||
|
isRequired?: boolean
|
||||||
|
type?: string
|
||||||
|
required?: boolean
|
||||||
|
}>) ?? []
|
||||||
|
|
||||||
|
if (fileRequirements.length === 0) return null
|
||||||
|
|
||||||
|
// 6. Get project files to check fulfillment
|
||||||
|
const projectFiles = await ctx.prisma.projectFile.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
stageId: intakeStage.id,
|
||||||
|
requirements: fileRequirements.map((req) => {
|
||||||
|
const reqName = req.name.toLowerCase()
|
||||||
|
// Match by checking if any uploaded file's fileName contains the requirement name
|
||||||
|
const matchingFile = projectFiles.find((f) =>
|
||||||
|
f.fileName.toLowerCase().includes(reqName) ||
|
||||||
|
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: null as string | null,
|
||||||
|
name: req.name,
|
||||||
|
description: req.description ?? null,
|
||||||
|
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
|
||||||
|
maxSizeMB: req.maxSizeMB ?? null,
|
||||||
|
// Handle both formats: isRequired (wizard type) and required (seed data)
|
||||||
|
isRequired: req.isRequired ?? req.required ?? false,
|
||||||
|
fulfilled: !!matchingFile,
|
||||||
|
fulfilledFile: matchingFile ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// FILE REQUIREMENTS
|
// FILE REQUIREMENTS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const pipelineRouter = router({
|
|||||||
createStructure: adminProcedure
|
createStructure: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string(),
|
programId: z.string().min(1, 'Program ID is required'),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
settingsJson: z.record(z.unknown()).optional(),
|
settingsJson: z.record(z.unknown()).optional(),
|
||||||
|
|||||||
@@ -267,22 +267,41 @@ export const stageAssignmentRouter = router({
|
|||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch per-juror maxAssignments for all jurors involved
|
||||||
|
const allJurorIds = jurorLoads.map((j) => j.userId)
|
||||||
|
const jurorUsers = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: allJurorIds } },
|
||||||
|
select: { id: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
const jurorMaxMap = new Map(jurorUsers.map((u) => [u.id, u.maxAssignments]))
|
||||||
|
|
||||||
const overLoaded = jurorLoads.filter(
|
const overLoaded = jurorLoads.filter(
|
||||||
(j) => j._count > input.targetPerJuror
|
(j) => j._count > input.targetPerJuror
|
||||||
)
|
)
|
||||||
const underLoaded = jurorLoads.filter(
|
|
||||||
(j) => j._count < input.targetPerJuror
|
|
||||||
)
|
|
||||||
|
|
||||||
// Calculate how many can be moved
|
// For under-loaded jurors, also check they haven't hit their personal maxAssignments
|
||||||
|
const underLoaded = jurorLoads.filter((j) => {
|
||||||
|
if (j._count >= input.targetPerJuror) return false
|
||||||
|
const userMax = jurorMaxMap.get(j.userId)
|
||||||
|
// If user has a personal max and is already at it, they can't receive more
|
||||||
|
if (userMax !== null && userMax !== undefined && j._count >= userMax) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate how many can be moved, respecting per-juror limits
|
||||||
const excessTotal = overLoaded.reduce(
|
const excessTotal = overLoaded.reduce(
|
||||||
(sum, j) => sum + (j._count - input.targetPerJuror),
|
(sum, j) => sum + (j._count - input.targetPerJuror),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
const capacityTotal = underLoaded.reduce(
|
const capacityTotal = underLoaded.reduce((sum, j) => {
|
||||||
(sum, j) => sum + (input.targetPerJuror - j._count),
|
const userMax = jurorMaxMap.get(j.userId)
|
||||||
0
|
const effectiveTarget = (userMax !== null && userMax !== undefined)
|
||||||
)
|
? Math.min(input.targetPerJuror, userMax)
|
||||||
|
: input.targetPerJuror
|
||||||
|
return sum + Math.max(0, effectiveTarget - j._count)
|
||||||
|
}, 0)
|
||||||
const movableCount = Math.min(excessTotal, capacityTotal)
|
const movableCount = Math.min(excessTotal, capacityTotal)
|
||||||
|
|
||||||
if (input.dryRun) {
|
if (input.dryRun) {
|
||||||
@@ -322,7 +341,12 @@ export const stageAssignmentRouter = router({
|
|||||||
for (const assignment of assignmentsToMove) {
|
for (const assignment of assignmentsToMove) {
|
||||||
// Find an under-loaded juror who doesn't already have this project
|
// Find an under-loaded juror who doesn't already have this project
|
||||||
for (const under of underLoaded) {
|
for (const under of underLoaded) {
|
||||||
if (under._count >= input.targetPerJuror) continue
|
// Respect both target and personal maxAssignments
|
||||||
|
const userMax = jurorMaxMap.get(under.userId)
|
||||||
|
const effectiveCapacity = (userMax !== null && userMax !== undefined)
|
||||||
|
? Math.min(input.targetPerJuror, userMax)
|
||||||
|
: input.targetPerJuror
|
||||||
|
if (under._count >= effectiveCapacity) continue
|
||||||
|
|
||||||
// Check no existing assignment for this juror-project pair
|
// Check no existing assignment for this juror-project pair
|
||||||
const exists = await tx.assignment.findFirst({
|
const exists = await tx.assignment.findFirst({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const userRouter = router({
|
|||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
me: protectedProcedure.query(async ({ ctx }) => {
|
me: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return ctx.prisma.user.findUniqueOrThrow({
|
const user = await ctx.prisma.user.findUnique({
|
||||||
where: { id: ctx.user.id },
|
where: { id: ctx.user.id },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -41,6 +41,15 @@ export const userRouter = router({
|
|||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'User session is stale. Please log out and log back in.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,8 +192,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 +283,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 +348,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 +481,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
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ interface AssignmentConstraints {
|
|||||||
requiredReviewsPerProject: number
|
requiredReviewsPerProject: number
|
||||||
minAssignmentsPerJuror?: number
|
minAssignmentsPerJuror?: number
|
||||||
maxAssignmentsPerJuror?: number
|
maxAssignmentsPerJuror?: number
|
||||||
|
jurorLimits?: Record<string, number> // userId -> personal max assignments
|
||||||
existingAssignments: Array<{
|
existingAssignments: Array<{
|
||||||
jurorId: string
|
jurorId: string
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -260,9 +261,24 @@ function buildBatchPrompt(
|
|||||||
}))
|
}))
|
||||||
.filter((a) => a.jurorId && a.projectId)
|
.filter((a) => a.jurorId && a.projectId)
|
||||||
|
|
||||||
|
// Build per-juror limits mapped to anonymous IDs
|
||||||
|
let jurorLimitsStr = ''
|
||||||
|
if (constraints.jurorLimits && Object.keys(constraints.jurorLimits).length > 0) {
|
||||||
|
const anonymousLimits: Record<string, number> = {}
|
||||||
|
for (const [realId, limit] of Object.entries(constraints.jurorLimits)) {
|
||||||
|
const anonId = jurorIdMap.get(realId)
|
||||||
|
if (anonId) {
|
||||||
|
anonymousLimits[anonId] = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(anonymousLimits).length > 0) {
|
||||||
|
jurorLimitsStr = `\nJUROR_LIMITS: ${JSON.stringify(anonymousLimits)} (per-juror max assignments, override global max)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `JURORS: ${JSON.stringify(jurors)}
|
return `JURORS: ${JSON.stringify(jurors)}
|
||||||
PROJECTS: ${JSON.stringify(projects)}
|
PROJECTS: ${JSON.stringify(projects)}
|
||||||
CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror
|
CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror${jurorLimitsStr}
|
||||||
EXISTING: ${JSON.stringify(anonymousExisting)}
|
EXISTING: ${JSON.stringify(anonymousExisting)}
|
||||||
Return JSON: {"assignments": [...]}`
|
Return JSON: {"assignments": [...]}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,12 +419,20 @@ export async function getSmartSuggestions(options: {
|
|||||||
const suggestions: AssignmentScore[] = []
|
const suggestions: AssignmentScore[] = []
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
// Skip users at AI max (they won't appear in suggestions)
|
|
||||||
const currentCount = user._count.assignments
|
const currentCount = user._count.assignments
|
||||||
|
|
||||||
|
// Skip users at AI max (they won't appear in suggestions)
|
||||||
if (currentCount >= aiMaxPerJudge) {
|
if (currentCount >= aiMaxPerJudge) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-juror hard block: skip entirely if at personal maxAssignments limit
|
||||||
|
if (user.maxAssignments !== null && user.maxAssignments !== undefined) {
|
||||||
|
if (currentCount >= user.maxAssignments) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
// Skip if already assigned
|
// Skip if already assigned
|
||||||
const pairKey = `${user.id}:${project.id}`
|
const pairKey = `${user.id}:${project.id}`
|
||||||
@@ -621,6 +629,13 @@ export async function getMentorSuggestionsForProject(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-mentor hard block: skip entirely if at personal maxAssignments limit
|
||||||
|
if (mentor.maxAssignments !== null && mentor.maxAssignments !== undefined) {
|
||||||
|
if (mentor._count.mentorAssignments >= mentor.maxAssignments) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||||
mentor.expertiseTags,
|
mentor.expertiseTags,
|
||||||
projectTags
|
projectTags
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type FileRequirementConfig = {
|
|||||||
export type FilterConfig = {
|
export type FilterConfig = {
|
||||||
rules: FilterRuleConfig[]
|
rules: FilterRuleConfig[]
|
||||||
aiRubricEnabled: boolean
|
aiRubricEnabled: boolean
|
||||||
|
aiCriteriaText: string
|
||||||
aiConfidenceThresholds: {
|
aiConfidenceThresholds: {
|
||||||
high: number
|
high: number
|
||||||
medium: number
|
medium: number
|
||||||
|
|||||||
Reference in New Issue
Block a user