Compare commits

...

10 Commits

Author SHA1 Message Date
Matt
9ee767b6cd Use session role for invite page, handle stale user sessions gracefully
Some checks failed
Build and Push Docker Image / build (push) Failing after 2m31s
Switch invite page from DB query (user.me) to JWT session for role checks,
avoiding failures when user ID is stale. Return friendly error from user.me
instead of throwing on missing user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:30:55 +01:00
Matt
0afd4d97c6 Fix admin roles dropdown, rewrite MOPC-specific expertise tags
- Refactor role selector to use computed availableRoles array instead of
  conditional JSX fragments, fixing Radix Select not re-rendering admin
  options when async user data loads
- Rewrite 38 generic expertise tags to 44 MOPC-specific tags across 8
  categories aligned with OceanIssue enum: Pollution & Waste, Climate &
  Carbon, Seafood & Aquaculture, Biodiversity & Habitat, Ocean Technology,
  Shipping & Ports, Community & Education, Business & Investment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:25:41 +01:00
Matt
2a374195c4 Add explicit edit actions for existing pipeline cards 2026-02-14 13:04:27 +01:00
c88f540633 Fix pipeline config crashes, settings UX, invite roles, seed expertise tags
- Fix critical crash when clicking Edit on INTAKE stage configs: normalize
  DB fileRequirements shape (type/required → acceptedMimeTypes/isRequired),
  add null guard in getActiveCategoriesFromMimeTypes
- Fix config summary display for all stage types to handle seed data key
  mismatches (votingEnabled→juryVotingEnabled, minAssignmentsPerJuror→
  minLoadPerJuror, deterministic.rules→rules, etc.)
- Add AWARD_MASTER role to invite page dropdown and user router validations
- Restructure settings sidebar: Tags and Webhooks as direct links instead
  of nested tabs, remove redundant Quick Links section
- Seed 38 expertise tags across 7 categories (Marine Science, Technology,
  Policy, Conservation, Business, Education, Engineering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:40:44 +01:00
ae0ac58547 Mobile responsiveness fixes for pipeline UI redesign
- Detail page header: stack on mobile, icon-only Advanced button on small screens
- InlineEditableText: show pencil icon on mobile (not hover-only)
- EditableCard: show Edit button on mobile (not hover-only)
- PipelineFlowchart: add right-edge fade gradient as scroll hint on mobile
- Summary cards: always 3-col grid (compact on mobile)
- Track switcher: add overflow-x-auto for horizontal scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:59:42 +01:00
59f90ccc37 Pipeline UI/UX redesign: inline editing, flowchart, sidebar stepper
- Add InlineEditableText, EditableCard, SidebarStepper shared components
- Add PipelineFlowchart (interactive SVG stage visualization)
- Add StageConfigEditor and usePipelineInlineEdit hook
- Redesign detail page: flowchart replaces nested tabs, inline editing
- Redesign creation wizard: sidebar stepper replaces accordion sections
- Enhance list page: status dots, track indicators, relative timestamps
- Convert edit page to redirect (editing now inline on detail page)
- Delete old WizardSection accordion component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:54:56 +01:00
70cfad7d46 Platform polish: bulk invite, file requirements, filtering redesign, UX fixes
- F1: Set seed jury/mentors/observers to NONE status (not invited), remove passwords
- F2: Add bulk invite UI with checkbox selection and floating toolbar
- F3: Add getProjectRequirements backend query + requirement slots on project detail
- F4: Redesign filtering section: AI criteria textarea, "What AI sees" card,
  field-aware eligibility rules with human-readable previews
- F5: Auto-redirect to pipeline detail when only one pipeline exists
- F6: Make project names clickable in pipeline intake panel
- F7: Fix pipeline creation error: edition context fallback + .min(1) validation
- Pipeline wizard sections: add isActive locking, info tooltips, UX improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:45:21 +01:00
451b483880 Fix pipeline edit crash: merge defaults with DB configJson
The DB configJson uses different field names than wizard types expect
(e.g., deterministic.rules vs rules, votingEnabled vs juryVotingEnabled).
The ?? operator only guards null/undefined, but configJson is {} (truthy),
so defaults never applied. This caused config.rules.map() to crash with
"Cannot read properties of undefined (reading 'map')".

Fix: spread defaults first then overlay DB values, and add defensive ??
fallbacks in all section components for potentially undefined properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:55:35 +01:00
7d1c87e938 UI/UX audit fixes: clickable pipelines, broken links, isActive locking
- Make pipeline cards clickable on list page (navigate to detail view)
- Fix broken nav link: applicant /messages → /mentor
- Fix broken nav link: mentor /messages → /projects
- Add isActive field locking to all 7 wizard sections (intake, main-track,
  filtering, assignment, awards, live-finals, notifications)
- Add minLoad ≤ maxLoad cross-field validation in assignment section
- Add duplicate stage slug detection in main track section
- Add active pipeline warning banners in intake and main track sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:50:22 +01:00
31225b099e Fix migration: drop RoundTemplate before RoundType enum
RoundTemplate table depends on RoundType enum, must be dropped first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:45:34 +01:00
42 changed files with 3385 additions and 1421 deletions

View File

@@ -281,7 +281,8 @@ ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_roundId_fkey";
-- Make TaggingJob.roundId nullable
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 TYPE IF EXISTS "RoundStatus";
DROP TYPE IF EXISTS "RoundType";

View File

@@ -136,6 +136,85 @@ async function main() {
}
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
// ==========================================================================
@@ -186,7 +265,6 @@ async function main() {
const juryUserIds: string[] = []
for (const j of juryMembers) {
const passwordHash = await bcrypt.hash('Jury2026!', 12)
const user = await prisma.user.upsert({
where: { email: j.email },
update: {},
@@ -194,11 +272,9 @@ async function main() {
email: j.email,
name: j.name,
role: UserRole.JURY_MEMBER,
status: UserStatus.ACTIVE,
status: UserStatus.NONE,
country: j.country,
expertiseTags: j.tags,
passwordHash,
mustSetPassword: true,
bio: `Expert in ${j.tags.join(', ')}`,
},
})
@@ -218,7 +294,6 @@ async function main() {
]
for (const m of mentors) {
const passwordHash = await bcrypt.hash('Mentor2026!', 12)
await prisma.user.upsert({
where: { email: m.email },
update: {},
@@ -226,11 +301,9 @@ async function main() {
email: m.email,
name: m.name,
role: UserRole.MENTOR,
status: UserStatus.ACTIVE,
status: UserStatus.NONE,
country: m.country,
expertiseTags: m.tags,
passwordHash,
mustSetPassword: true,
},
})
console.log(` ✓ Mentor: ${m.name}`)
@@ -247,7 +320,6 @@ async function main() {
]
for (const o of observers) {
const passwordHash = await bcrypt.hash('Observer2026!', 12)
await prisma.user.upsert({
where: { email: o.email },
update: {},
@@ -255,10 +327,8 @@ async function main() {
email: o.email,
name: o.name,
role: UserRole.OBSERVER,
status: UserStatus.ACTIVE,
status: UserStatus.NONE,
country: o.country,
passwordHash,
mustSetPassword: true,
},
})
console.log(` ✓ Observer: ${o.name}`)

View File

@@ -69,10 +69,11 @@ import {
Mail,
MailX,
} from 'lucide-react'
import { useSession } from 'next-auth/react'
import { cn } from '@/lib/utils'
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 {
projectId: string
@@ -104,6 +105,7 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
AWARD_MASTER: 'Award Master',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@@ -273,9 +275,20 @@ export default function MemberInvitePage() {
const utils = trpc.useUtils()
// Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
// Use session role directly (from JWT) — no DB query needed, works even with stale user IDs
const { data: session } = useSession()
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({
onSuccess: () => {
@@ -406,14 +419,16 @@ export default function MemberInvitePage() {
? 'SUPER_ADMIN'
: rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
: rawRole === 'AWARD_MASTER'
? 'AWARD_MASTER'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
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)
return {
email,
@@ -428,7 +443,7 @@ export default function MemberInvitePage() {
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
? 'Only super admins can invite super admins'
: undefined,
}
})
@@ -449,7 +464,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(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)
return {
email,
@@ -464,7 +479,7 @@ export default function MemberInvitePage() {
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
? 'Only super admins can invite super admins'
: undefined,
}
})
@@ -547,7 +562,7 @@ export default function MemberInvitePage() {
Add members individually or upload a CSV file
{isSuperAdmin && (
<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>
)}
</CardDescription>
@@ -653,21 +668,11 @@ export default function MemberInvitePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">
Super Admin
{availableRoles.map((role) => (
<SelectItem key={role} value={role}>
{ROLE_LABELS[role]}
</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>
</Select>
</div>

View File

@@ -38,6 +38,7 @@ import {
Calendar,
CheckCircle2,
XCircle,
Circle,
Clock,
BarChart3,
ThumbsUp,
@@ -86,6 +87,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
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)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
@@ -521,35 +528,105 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<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,
}))}
/>
) : (
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
{/* Required Documents from Pipeline Intake Stage */}
{requirementsData && requirementsData.requirements.length > 0 && (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{requirementsData.requirements.map((req, idx) => {
const isFulfilled = req.fulfilled
return (
<div
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'
: '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>
<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
projectId={projectId}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</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>
</Card>
</AnimatedCard>

View File

@@ -6,10 +6,11 @@ import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
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 { 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 { IntakeSection } from '@/components/admin/pipeline/sections/intake-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 { 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 { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
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() {
const router = useRouter()
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 [openSection, setOpenSection] = useState(0)
const [currentStep, setCurrentStep] = useState(0)
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
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -120,9 +130,9 @@ export default function NewPipelinePage() {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Open first section with errors
if (!validation.sections.basics.valid) setOpenSection(0)
else if (!validation.sections.tracks.valid) setOpenSection(2)
// Navigate to first section with errors
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
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 = [
{
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, // 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">
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
@@ -222,169 +184,169 @@ export default function NewPipelinePage() {
<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
Please select an edition first to create a pipeline.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={isSaving || !allValid}
onClick={() => handleSave(false)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Draft
</Button>
<Button
type="button"
disabled={isSaving || !allValid}
onClick={() => handleSave(true)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
Save & Publish
</div>
)
}
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
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>
</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>
{/* Wizard Sections */}
<div className="space-y-3">
{/* 0: Basics */}
<WizardSection
stepNumber={1}
title={sections[0].title}
description={sections[0].description}
isOpen={openSection === 0}
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
isValid={sections[0].isValid}
>
{/* Sidebar Stepper */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSave={() => handleSave(false)}
onSubmit={() => handleSave(true)}
isSaving={isSaving}
isSubmitting={isSubmitting}
saveLabel="Save Draft"
submitLabel="Save & Publish"
canSubmit={allValid}
>
{/* Step 0: Basics */}
<div>
<BasicsSection state={state} onChange={updateState} />
</WizardSection>
</div>
{/* 1: Intake */}
<WizardSection
stepNumber={2}
title={sections[1].title}
description={sections[1].description}
isOpen={openSection === 1}
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
isValid={sections[1].isValid}
>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 2: Main Track Stages */}
<WizardSection
stepNumber={3}
title={sections[2].title}
description={sections[2].description}
isOpen={openSection === 2}
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
isValid={sections[2].isValid}
>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</WizardSection>
</div>
{/* 3: Filtering */}
<WizardSection
stepNumber={4}
title={sections[3].title}
description={sections[3].description}
isOpen={openSection === 3}
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
isValid={sections[3].isValid}
>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 4: Assignment */}
<WizardSection
stepNumber={5}
title={sections[4].title}
description={sections[4].description}
isOpen={openSection === 4}
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
isValid={sections[4].isValid}
>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 5: Awards */}
<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>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* 6: Live Finals */}
<WizardSection
stepNumber={7}
title={sections[6].title}
description={sections[6].description}
isOpen={openSection === 6}
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
isValid={sections[6].isValid}
>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 7: Notifications */}
<WizardSection
stepNumber={8}
title={sections[7].title}
description={sections[7].description}
isOpen={openSection === 7}
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
isValid={sections[7].isValid}
>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</WizardSection>
</div>
{/* 8: Review */}
<WizardSection
stepNumber={9}
title={sections[8].title}
description={sections[8].description}
isOpen={openSection === 8}
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
isValid={sections[8].isValid}
>
{/* Step 8: Review & Create */}
<div>
<ReviewSection state={state} />
</WizardSection>
</div>
</div>
</SidebarStepper>
</div>
)
}

View File

@@ -1,422 +1,11 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
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'],
},
}
type EditPipelinePageProps = {
params: Promise<{ id: string }>
}
export default function EditPipelinePage() {
const router = useRouter()
const params = useParams()
const pipelineId = params.id as string
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>
)
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
const { id } = await params
// Editing now happens inline on the detail page
redirect(`/admin/rounds/pipeline/${id}` as never)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
@@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +23,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Edit,
MoreHorizontal,
Rocket,
Archive,
@@ -35,8 +30,14 @@ import {
Layers,
GitBranch,
Loader2,
ChevronDown,
} 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 { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-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',
}
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({
stageId,
stageType,
@@ -100,20 +92,14 @@ export default function PipelineDetailPage() {
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const stagePanelRef = useRef<HTMLDivElement>(null)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
// Auto-select first track and stage
useEffect(() => {
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 { isUpdating, updatePipeline, updateStageConfig } =
usePipelineInlineEdit(pipelineId)
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => toast.success('Pipeline published'),
@@ -125,6 +111,25 @@ export default function PipelineDetailPage() {
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) {
return (
<div className="space-y-6">
@@ -170,109 +175,156 @@ export default function PipelineDetailPage() {
setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
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 {
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</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" />
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 min-w-0">
<Link href="/admin/rounds/pipelines" className="mt-1">
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<ArrowLeft 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' && (
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<InlineEditableText
value={pipeline.name}
onSave={(newName) => updatePipeline({ name: newName })}
variant="h1"
placeholder="Untitled Pipeline"
disabled={isUpdating}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
statusColors[pipeline.status] ?? '',
'hover:opacity-80'
)}
>
{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
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'CLOSED',
})
}
onClick={() => handleStatusChange('ARCHIVED')}
>
Close Pipeline
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'ARCHIVED',
})
}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Pipeline Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-3 grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
@@ -320,120 +372,91 @@ export default function PipelineDetailPage() {
</Card>
</div>
{/* Track Tabs */}
{pipeline.tracks.length > 0 && (
<Tabs
value={selectedTrackId ?? undefined}
onValueChange={handleTrackChange}
>
<TabsList className="w-full justify-start overflow-x-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<TabsTrigger
key={track.id}
value={track.id}
className="flex items-center gap-1.5"
>
<span>{track.name}</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1"
>
{track.kind}
</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={
{/* Track Switcher (only if multiple tracks) */}
{pipeline.tracks.length > 1 && (
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<button
key={track.id}
onClick={() => handleTrackChange(track.id)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
selectedTrackId === track.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
)}
>
<span>{track.name}</span>
<Badge
variant="outline"
className={cn(
'text-[9px] h-4 px-1',
selectedTrackId === track.id
? selectedStageId ?? undefined
: undefined
}
onValueChange={setSelectedStageId}
? 'border-primary-foreground/20 text-primary-foreground/80'
: ''
)}
>
<TabsList className="w-full justify-start overflow-x-auto">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<TabsTrigger
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>
{track.kind}
</Badge>
</button>
))}
</div>
)}
{/* 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>
)
}

View File

@@ -1,206 +1,257 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Layers,
GitBranch,
Calendar,
Workflow,
Pencil,
Settings2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
const statusConfig = {
DRAFT: {
label: 'Draft',
bgClass: 'bg-gray-100 text-gray-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() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view pipelines
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its pipelines
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Manage evaluation pipelines for {currentEdition?.name}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pipelines || pipelines.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Workflow className="h-10 w-10 text-primary" />
</div>
<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>
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Your First Pipeline
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Pipeline Cards */}
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => {
const status = pipeline.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
return (
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base leading-tight mb-1">
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route} className="hover:underline">
{pipeline.name}
</Link>
</CardTitle>
<p className="font-mono text-xs text-muted-foreground truncate">
{pipeline.slug}
</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>
{/* Stats */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
<span>Routing rules</span>
</div>
<span className="font-medium text-foreground">
{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>
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-muted text-muted-foreground',
CLOSED: 'bg-blue-100 text-blue-700',
}
export default function PipelineListPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view pipelines
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its pipelines
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Manage evaluation pipelines for {currentEdition?.name}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pipelines || pipelines.length === 0) && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Pipelines Yet</p>
<p className="text-sm text-muted-foreground mb-4">
Create your first pipeline to start managing project evaluation
</p>
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Pipeline Cards */}
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => (
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">
{pipeline.name}
</CardTitle>
<CardDescription className="font-mono text-xs">
{pipeline.slug}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0',
statusColors[pipeline.status] ?? ''
)}
>
{pipeline.status}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
<Eye className="h-4 w-4 mr-2" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
<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>
))}
</Link>
<Link href={`/admin/rounds/pipeline/${pipeline.id}/advanced` as Route} className="flex-1">
<Button size="sm" variant="outline" className="w-full">
<Settings2 className="h-3.5 w-3.5 mr-1.5" />
Advanced
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}
</div>
)
}

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Card,
CardContent,
@@ -27,9 +28,10 @@ import { Skeleton } from '@/components/ui/skeleton'
import { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
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 { formatRelativeTime } from '@/lib/utils'
import { AnimatePresence, motion } from 'motion/react'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
@@ -131,6 +133,8 @@ export function MembersContent() {
const roles = TAB_ROLES[tab]
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined
@@ -141,6 +145,75 @@ export function MembersContent() {
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) => {
updateParams({ tab: value === 'all' ? null : value, page: '1' })
}
@@ -197,6 +270,15 @@ export function MembersContent() {
<Table>
<TableHeader>
<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>Role</TableHead>
<TableHead>Expertise</TableHead>
@@ -209,6 +291,17 @@ export function MembersContent() {
<TableBody>
{data.users.map((user) => (
<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>
<div className="flex items-center gap-3">
<UserAvatar
@@ -297,6 +390,14 @@ export function MembersContent() {
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<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
user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
@@ -395,6 +496,50 @@ export function MembersContent() {
</CardContent>
</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>
)
}

View 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>
)
}

View File

@@ -10,14 +10,16 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
@@ -26,12 +28,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<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
type="number"
min={1}
max={20}
value={config.requiredReviews}
value={config.requiredReviews ?? 3}
disabled={isActive}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
@@ -42,12 +48,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
</div>
<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
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror}
value={config.maxLoadPerJuror ?? 20}
disabled={isActive}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
@@ -58,12 +68,16 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
</div>
<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
type="number"
min={0}
max={50}
value={config.minLoadPerJuror}
value={config.minLoadPerJuror ?? 5}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
@@ -74,30 +88,44 @@ export function AssignmentSection({ config, onChange }: AssignmentSectionProps)
</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>
<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">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting}
checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
<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
value={config.overflowPolicy}
value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />

View File

@@ -24,6 +24,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
@@ -31,6 +32,7 @@ import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
isActive?: boolean
}
function slugify(name: string): string {
@@ -40,7 +42,7 @@ function slugify(name: string): string {
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
@@ -72,7 +74,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
<p className="text-sm text-muted-foreground">
Configure special award tracks that run alongside the main competition.
</p>
<Button type="button" variant="outline" size="sm" onClick={addAward}>
<Button type="button" variant="outline" size="sm" onClick={addAward} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
@@ -100,6 +102,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -129,6 +132,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
@@ -143,7 +147,10 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
/>
</div>
<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
value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) =>
@@ -151,6 +158,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
@@ -172,12 +180,16 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
<div className="grid gap-4 sm:grid-cols-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
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
@@ -192,7 +204,10 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
</Select>
</div>
<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
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
@@ -203,6 +218,7 @@ export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
},
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />

View File

@@ -10,6 +10,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard'
@@ -52,7 +53,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
/>
</div>
<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
id="pipeline-slug"
placeholder="e.g., mopc-2026"
@@ -70,7 +74,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
</div>
<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
value={state.programId}
onValueChange={(value) => onChange({ programId: value })}

View File

@@ -1,11 +1,13 @@
'use client'
import { Input } from '@/components/ui/input'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
@@ -13,21 +15,105 @@ import {
SelectTrigger,
SelectValue,
} 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'
// ─── 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 = {
config: FilterConfig
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>) => {
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 updated = [...config.rules]
const updated = [...rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
@@ -36,114 +122,138 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
onChange({
...config,
rules: [
...config.rules,
{ field: '', operator: 'equals', value: '', weight: 1 },
...rules,
{ field: '', operator: 'is', value: '', weight: 1 },
],
})
}
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 (
<div className="space-y-6">
{/* Deterministic Gate Rules */}
<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 */}
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>AI Screening</Label>
<p className="text-xs text-muted-foreground">
Use AI to evaluate projects against rubric criteria
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<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>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2">
<Label className="text-xs">High Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">Screening Criteria</Label>
<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
value={[config.aiConfidenceThresholds.high * 100]}
value={[highPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
...thresholds,
high: v / 100,
},
})
@@ -151,22 +261,25 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
min={50}
max={100}
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 className="space-y-2">
<Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3">
{/* Medium 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-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
value={[config.aiConfidenceThresholds.medium * 100]}
value={[medPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
...thresholds,
medium: v / 100,
},
})
@@ -174,21 +287,21 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
min={20}
max={80}
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>
{/* Manual Queue */}
{/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<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">
Projects below medium confidence go to manual review
</p>
@@ -196,8 +309,171 @@ export function FilteringSection({ config, onChange }: FilteringSectionProps) {
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
disabled={isActive}
/>
</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>
)
}

View File

@@ -12,21 +12,85 @@ import {
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
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 = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...config.fileRequirements]
const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
@@ -35,7 +99,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
onChange({
...config,
fileRequirements: [
...config.fileRequirements,
...fileRequirements,
{
name: '',
description: '',
@@ -48,26 +112,36 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
}
const removeFileReq = (index: number) => {
const updated = config.fileRequirements.filter((_, i) => i !== index)
const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<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">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled}
checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
@@ -75,14 +149,18 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-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
value={config.lateSubmissionPolicy}
value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
@@ -95,14 +173,17 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
</Select>
</div>
{config.lateSubmissionPolicy === 'flag' && (
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
<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
type="number"
min={0}
max={168}
value={config.lateGraceHours}
value={config.lateGraceHours ?? 24}
onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
}
@@ -114,20 +195,23 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
{/* File Requirements */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>File Requirements</Label>
<Button type="button" variant="outline" size="sm" onClick={addFileReq}>
<div className="flex items-center gap-1.5">
<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" />
Add Requirement
</Button>
</div>
{config.fileRequirements.length === 0 && (
{fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files.
</p>
)}
{config.fileRequirements.map((req, index) => (
{fileRequirements.map((req, index) => (
<Card key={index}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3">
@@ -176,6 +260,14 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
<Label className="text-xs">Required</Label>
</div>
</div>
<div className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div>
<Button
type="button"
@@ -183,6 +275,7 @@ export function IntakeSection({ config, onChange }: IntakeSectionProps) {
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>

View File

@@ -10,14 +10,16 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
@@ -26,42 +28,53 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
<div className="space-y-6">
<div className="flex items-center justify-between">
<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">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled}
checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<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">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled}
checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
{config.audienceVotingEnabled && (
{(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<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">
<Slider
value={[config.audienceVoteWeight * 100]}
value={[(config.audienceVoteWeight ?? 0) * 100]}
onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 })
}
@@ -71,7 +84,7 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.audienceVoteWeight * 100)}%
{Math.round((config.audienceVoteWeight ?? 0) * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground">
@@ -83,14 +96,18 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
</div>
<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
value={config.cohortSetupMode}
value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
@@ -107,14 +124,18 @@ export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps)
</div>
<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
value={config.revealPolicy}
value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />

View File

@@ -21,12 +21,14 @@ import {
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
@@ -45,7 +47,7 @@ function slugify(name: string): string {
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
@@ -90,20 +92,33 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required.
</p>
<div className="flex items-center gap-1.5 mb-1">
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
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>
<Button type="button" variant="outline" size="sm" onClick={addStage}>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
@@ -115,7 +130,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === 0}
disabled={isActive || index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
@@ -126,7 +141,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === stages.length - 1}
disabled={isActive || index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
@@ -143,12 +158,16 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
<Input
placeholder="Stage name"
value={stage.name}
className="h-8 text-sm"
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
@@ -158,6 +177,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -186,7 +206,7 @@ export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={stages.length <= 2}
disabled={isActive || stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />

View File

@@ -4,12 +4,14 @@ import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
const NOTIFICATION_EVENTS = [
@@ -60,6 +62,7 @@ export function NotificationsSection({
onChange,
overridePolicy,
onOverridePolicyChange,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
@@ -67,10 +70,11 @@ export function NotificationsSection({
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</div>
<div className="space-y-2">
@@ -88,6 +92,7 @@ export function NotificationsSection({
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
disabled={isActive}
/>
</div>
</CardContent>

View File

@@ -3,6 +3,7 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
@@ -97,7 +98,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
{/* Validation Checks */}
<Card>
<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>
<CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} />
@@ -109,7 +113,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
{/* Structure Summary */}
<Card>
<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>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">

View 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>
)
}

View File

@@ -122,11 +122,16 @@ export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
)}
{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">
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
</p>
{config.aiCriteriaText && (
<p className="text-xs text-muted-foreground line-clamp-2">
Criteria: {config.aiCriteriaText}
</p>
)}
</div>
)}
</CardContent>

View File

@@ -1,5 +1,7 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -111,15 +113,18 @@ export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
) : (
<div className="space-y-1">
{projectStates.items.map((ps) => (
<div
<Link
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>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
<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">
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
</Link>
))}
</div>
)}

View File

@@ -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>
)
}

View File

@@ -27,7 +27,6 @@ import {
Webhook,
} from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
@@ -199,10 +198,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="gap-2 shrink-0">
<Link href="/admin/settings/tags" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Tags className="h-4 w-4" />
Tags
</TabsTrigger>
</Link>
<TabsTrigger value="analytics" className="gap-2 shrink-0">
<BarChart3 className="h-4 w-4" />
Analytics
@@ -213,6 +212,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Storage
</TabsTrigger>
)}
{isSuperAdmin && (
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Webhook className="h-4 w-4" />
Webhooks
</Link>
)}
</TabsList>
<div className="lg:flex lg:gap-8">
@@ -279,10 +284,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Link href="/admin/settings/tags" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Tags className="h-4 w-4" />
Tags
</TabsTrigger>
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
<TabsTrigger value="analytics" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<BarChart3 className="h-4 w-4" />
Analytics
@@ -298,6 +304,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Storage
</TabsTrigger>
</TabsList>
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Webhook className="h-4 w-4" />
Webhooks
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
</div>
)}
</nav>
@@ -325,40 +336,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsContent>
)}
<TabsContent value="tags">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tags className="h-5 w-5" />
Expertise Tags
</CardTitle>
<CardDescription>
Manage tags used for jury expertise, project categorization, and AI-powered matching
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Expertise tags are used across the platform to:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Categorize jury members by their areas of expertise</li>
<li>Tag projects for better organization and filtering</li>
<li>Power AI-based project tagging</li>
<li>Enable smart jury-project matching</li>
</ul>
<Button asChild>
<Link href="/admin/settings/tags">
<Tags className="mr-2 h-4 w-4" />
Manage Expertise Tags
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="branding">
<AnimatedCard>
<Card>
@@ -528,31 +505,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</div>{/* end lg:flex */}
</Tabs>
{/* Quick Links to sub-pages */}
<div className="grid gap-4 sm:grid-cols-2">
{isSuperAdmin && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />
Webhooks
</CardTitle>
<CardDescription>
Configure webhook endpoints for platform events
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/admin/settings/webhooks">
<Webhook className="mr-2 h-4 w-4" />
Manage Webhooks
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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,
}
}

View 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
)
}

View File

@@ -31,6 +31,7 @@ export function defaultFilterConfig(): FilterConfig {
return {
rules: [],
aiRubricEnabled: false,
aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true,
}

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
const editionOrStageInput = z.object({
stageId: z.string().optional(),
@@ -384,9 +385,16 @@ export const analyticsRouter = router({
_count: { id: true },
})
return distribution.map((d) => ({
countryCode: d.country || 'UNKNOWN',
count: d._count.id,
// Resolve country names to ISO codes (DB may store "France" instead of "FR")
const codeMap = new Map<string, number>()
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,
}))
}),

View File

@@ -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 = {
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
maxAssignmentsPerJuror,
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
@@ -420,8 +429,58 @@ export const assignmentRouter = router({
})
)
.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({
data: input.assignments.map((a) => ({
data: allowedAssignments.map((a) => ({
...a,
stageId: input.stageId,
method: 'BULK',
@@ -436,15 +495,19 @@ export const assignmentRouter = router({
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'Assignment',
detailsJson: { count: result.count },
detailsJson: {
count: result.count,
requested: input.assignments.length,
skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// 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
const userAssignmentCounts = input.assignments.reduce(
const userAssignmentCounts = allowedAssignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
@@ -452,11 +515,6 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
@@ -495,6 +553,7 @@ export const assignmentRouter = router({
created: result.count,
requested: input.assignments.length,
skipped: input.assignments.length - result.count,
skippedDueToCapacity,
}
}),
@@ -826,11 +885,61 @@ export const assignmentRouter = router({
})
),
usedAI: z.boolean().default(false),
forceOverride: z.boolean().default(false),
})
)
.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({
data: input.assignments.map((a) => ({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
@@ -852,13 +961,15 @@ export const assignmentRouter = router({
stageId: input.stageId,
count: created.count,
usedAI: input.usedAI,
forceOverride: input.forceOverride,
skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
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(),
})
),
forceOverride: z.boolean().default(false),
})
)
.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({
data: input.assignments.map((a) => ({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
@@ -945,13 +1110,15 @@ export const assignmentRouter = router({
detailsJson: {
stageId: input.stageId,
count: created.count,
forceOverride: input.forceOverride,
skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
@@ -998,7 +1165,11 @@ export const assignmentRouter = router({
}
}
return { created: created.count }
return {
created: created.count,
requested: input.assignments.length,
skippedDueToCapacity,
}
}),
/**

View File

@@ -696,6 +696,132 @@ export const fileRouter = router({
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
// =========================================================================

View File

@@ -316,7 +316,7 @@ export const pipelineRouter = router({
createStructure: adminProcedure
.input(
z.object({
programId: z.string(),
programId: z.string().min(1, 'Program ID is required'),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
settingsJson: z.record(z.unknown()).optional(),

View File

@@ -267,22 +267,41 @@ export const stageAssignmentRouter = router({
_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(
(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(
(sum, j) => sum + (j._count - input.targetPerJuror),
0
)
const capacityTotal = underLoaded.reduce(
(sum, j) => sum + (input.targetPerJuror - j._count),
0
)
const capacityTotal = underLoaded.reduce((sum, j) => {
const userMax = jurorMaxMap.get(j.userId)
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)
if (input.dryRun) {
@@ -322,7 +341,12 @@ export const stageAssignmentRouter = router({
for (const assignment of assignmentsToMove) {
// Find an under-loaded juror who doesn't already have this project
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
const exists = await tx.assignment.findFirst({

View File

@@ -19,7 +19,7 @@ export const userRouter = router({
* Get current user profile
*/
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findUniqueOrThrow({
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
@@ -41,6 +41,15 @@ export const userRouter = router({
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
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(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', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
@@ -274,7 +283,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
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(),
maxAssignments: z.number().int().min(1).max(100).optional(),
})
@@ -339,7 +348,7 @@ export const userRouter = router({
z.object({
id: z.string(),
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(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
@@ -472,7 +481,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
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(),
// Optional pre-assignments for jury members
assignments: z

View File

@@ -80,6 +80,7 @@ interface AssignmentConstraints {
requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number
jurorLimits?: Record<string, number> // userId -> personal max assignments
existingAssignments: Array<{
jurorId: string
projectId: string
@@ -260,9 +261,24 @@ function buildBatchPrompt(
}))
.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)}
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)}
Return JSON: {"assignments": [...]}`
}

View File

@@ -419,12 +419,20 @@ export async function getSmartSuggestions(options: {
const suggestions: AssignmentScore[] = []
for (const user of users) {
// Skip users at AI max (they won't appear in suggestions)
const currentCount = user._count.assignments
// Skip users at AI max (they won't appear in suggestions)
if (currentCount >= aiMaxPerJudge) {
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) {
// Skip if already assigned
const pairKey = `${user.id}:${project.id}`
@@ -621,6 +629,13 @@ export async function getMentorSuggestionsForProject(
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(
mentor.expertiseTags,
projectTags

View File

@@ -22,6 +22,7 @@ export type FileRequirementConfig = {
export type FilterConfig = {
rules: FilterRuleConfig[]
aiRubricEnabled: boolean
aiCriteriaText: string
aiConfidenceThresholds: {
high: number
medium: number