Compare commits

...

5 Commits

Author SHA1 Message Date
Matt c321d4711e Improve pipeline editor UX: stage detail sheet, structured predicates, page reorganization
Build and Push Docker Image / build (push) Failing after 40s Details
- Add Sheet UI component and StageDetailSheet with config/activity tabs
- Stage config opens in right-side sheet (always-editable, no collapsed summary)
- Replace JSON textarea in routing rules with structured PredicateBuilder form
- Remove StageTransitionsEditor from UI (transitions auto-managed)
- Promote Stage Management section to immediately after flowchart
- Conditionally hide Routing Rules (single track) and Award Governance (no awards)
- Add section headers with descriptions and increase spacing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:11:48 +01:00
Matt 2d91ce02fc Fix pipeline detail hook order causing React error 310 2026-02-14 17:04:43 +01:00
Matt 3975b5c51f Fix CRLF line endings in runtime/deploy scripts and enforce LF 2026-02-14 16:35:26 +01:00
Matt b5425e705e Apply full refactor updates plus pipeline/email UX confirmations 2026-02-14 15:26:42 +01:00
Matt e56e143a40 Update CI workflow to use new Gitea registry at code.monaco-opc.com
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:00:50 +01:00
374 changed files with 116635 additions and 111409 deletions

12
.gitattributes vendored Normal file
View File

@ -0,0 +1,12 @@
* text=auto
# Deployment/runtime scripts must stay LF for Linux containers/shells.
*.sh text eol=lf
Dockerfile text eol=lf
**/Dockerfile text eol=lf
# Keep YAML and env-ish config files LF across platforms.
*.yml text eol=lf
*.yaml text eol=lf
*.env text eol=lf
*.sql text eol=lf

View File

@ -6,8 +6,8 @@ on:
- main - main
env: env:
REGISTRY: code.letsbe.solutions REGISTRY: code.monaco-opc.com
IMAGE_NAME: letsbe/mopc-app IMAGE_NAME: mopc/mopc-portal
jobs: jobs:
build: build:

288
build-check.txt Normal file
View File

@ -0,0 +1,288 @@
▲ Next.js 15.5.10
- Environments: .env.local, .env
Creating an optimized production build ...
✓ Compiled successfully in 10.0s
Linting and checking validity of types ...
Collecting page data ...
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
Generating static pages (0/37) ...
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
clientVersion: '6.19.2',
errorCode: undefined,
retryable: undefined
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /accept-invite couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /accept-invite couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /error couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /error couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Generating static pages (9/37)
Auth check failed in auth layout: Error: Dynamic server usage: Route /verify-email couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /verify-email couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /verify couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /verify couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /onboarding couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /onboarding couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /login couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /login couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Auth check failed in auth layout: Error: Dynamic server usage: Route /set-password couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
at stringify (<anonymous>) {
description: "Route /set-password couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
digest: 'DYNAMIC_SERVER_USAGE'
}
Generating static pages (18/37)
Generating static pages (27/37)
✓ Generating static pages (37/37)
Finalizing page optimization ...
Collecting build traces ...
Route (app) Size First Load JS
┌ ƒ / 182 B 112 kB
├ ○ /_not-found 171 B 103 kB
├ ƒ /accept-invite 5.17 kB 141 kB
├ ƒ /admin 3.54 kB 275 kB
├ ƒ /admin/audit 8.76 kB 172 kB
├ ƒ /admin/awards 4.82 kB 141 kB
├ ƒ /admin/awards/[id] 13.2 kB 196 kB
├ ƒ /admin/awards/[id]/edit 6.02 kB 182 kB
├ ƒ /admin/awards/new 5.71 kB 182 kB
├ ƒ /admin/learning 180 B 106 kB
├ ƒ /admin/learning/[id] 10.5 kB 190 kB
├ ƒ /admin/learning/new 7.85 kB 184 kB
├ ƒ /admin/members 12.5 kB 191 kB
├ ƒ /admin/members/[id] 5.71 kB 197 kB
├ ƒ /admin/members/invite 6.6 kB 188 kB
├ ƒ /admin/mentors 171 B 103 kB
├ ƒ /admin/mentors/[id] 171 B 103 kB
├ ƒ /admin/partners 180 B 106 kB
├ ƒ /admin/partners/[id] 7.44 kB 187 kB
├ ƒ /admin/partners/new 4.2 kB 181 kB
├ ƒ /admin/programs 2.47 kB 147 kB
├ ƒ /admin/programs/[id] 180 B 106 kB
├ ƒ /admin/programs/[id]/apply-settings 12.4 kB 224 kB
├ ƒ /admin/programs/[id]/edit 6.22 kB 186 kB
├ ƒ /admin/programs/new 4.83 kB 150 kB
├ ƒ /admin/projects 16.9 kB 207 kB
├ ƒ /admin/projects/[id] 10.1 kB 205 kB
├ ƒ /admin/projects/[id]/edit 7.67 kB 220 kB
├ ƒ /admin/projects/[id]/mentor 8.78 kB 154 kB
├ ƒ /admin/projects/import 10.5 kB 201 kB
├ ƒ /admin/projects/new 4.42 kB 195 kB
├ ƒ /admin/projects/pool 6.56 kB 186 kB
├ ƒ /admin/reports 4.6 kB 308 kB
├ ƒ /admin/rounds 9.6 kB 211 kB
├ ƒ /admin/rounds/[id] 16.4 kB 210 kB
├ ƒ /admin/rounds/[id]/assignments 16.2 kB 196 kB
├ ƒ /admin/rounds/[id]/coi 8.73 kB 188 kB
├ ƒ /admin/rounds/[id]/edit 10.2 kB 240 kB
├ ƒ /admin/rounds/[id]/filtering 504 B 103 kB
├ ƒ /admin/rounds/[id]/filtering/results 7.69 kB 187 kB
├ ƒ /admin/rounds/[id]/filtering/rules 8.18 kB 188 kB
├ ƒ /admin/rounds/[id]/live-voting 8.68 kB 169 kB
├ ƒ /admin/rounds/new 3.72 kB 227 kB
├ ƒ /admin/settings 21.6 kB 226 kB
├ ƒ /admin/settings/tags 8.33 kB 214 kB
├ ƒ /api/auth/[...nextauth] 171 B 103 kB
├ ƒ /api/cron/reminders 171 B 103 kB
├ ƒ /api/email/change-password 171 B 103 kB
├ ƒ /api/email/verify-credentials 171 B 103 kB
├ ƒ /api/health 171 B 103 kB
├ ƒ /api/storage/local 171 B 103 kB
├ ƒ /api/trpc/[trpc] 171 B 103 kB
├ ƒ /apply/[slug] 954 B 387 kB
├ ƒ /apply/edition/[programSlug] 959 B 388 kB
├ ○ /email/change-password 6.79 kB 118 kB
├ ƒ /error 4.01 kB 118 kB
├ ƒ /jury 4.37 kB 119 kB
├ ƒ /jury/assignments 180 B 106 kB
├ ƒ /jury/awards 3.11 kB 139 kB
├ ƒ /jury/awards/[id] 5.59 kB 151 kB
├ ƒ /jury/learning 5.09 kB 137 kB
├ ƒ /jury/live/[sessionId] 7.21 kB 149 kB
├ ƒ /jury/projects/[id] 4.12 kB 144 kB
├ ƒ /jury/projects/[id]/evaluate 13.2 kB 225 kB
├ ƒ /jury/projects/[id]/evaluation 2.09 kB 116 kB
├ ƒ /live-scores/[sessionId] 6.93 kB 139 kB
├ ƒ /login 6.09 kB 120 kB
├ ƒ /mentor 7.77 kB 144 kB
├ ƒ /mentor/projects 5.51 kB 141 kB
├ ƒ /mentor/projects/[id] 8.58 kB 148 kB
├ ƒ /mentor/resources 5.12 kB 137 kB
├ ƒ /my-submission 6.82 kB 146 kB
├ ƒ /my-submission/[id] 11.7 kB 155 kB
├ ƒ /my-submission/[id]/team 8 kB 216 kB
├ ƒ /observer 2.98 kB 114 kB
├ ƒ /observer/reports 5.63 kB 309 kB
├ ƒ /onboarding 5.44 kB 313 kB
├ ƒ /set-password 7.55 kB 143 kB
├ ƒ /settings/profile 4.25 kB 210 kB
├ ƒ /verify 180 B 106 kB
└ ƒ /verify-email 171 B 103 kB
+ First Load JS shared by all 103 kB
├ chunks/1255-39d374166396f9e9.js 45.7 kB
├ chunks/4bd1b696-100b9d70ed4e49c1.js 54.2 kB
└ other shared chunks (total) 2.87 kB
ƒ Middleware 86.5 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand

View File

@ -141,68 +141,119 @@ async function main() {
// ========================================================================== // ==========================================================================
console.log('\n🏷 Creating expertise tags...') console.log('\n🏷 Creating expertise tags...')
const expertiseTags = [ const tagGroups = [
// 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 }, category: 'Pollution Reduction',
{ 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 }, color: '#dc2626',
{ 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 }, tags: [
{ name: 'Marine Debris Cleanup', description: 'Ocean and coastal cleanup technologies, collection vessels, and waste recovery systems', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 3 }, { name: 'Marine Plastic & Ghost Gear Cleanup', description: 'Collection and processing of plastic waste, fishing nets, and marine debris from coastal and ocean environments' },
{ 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 }, { name: 'Industrial & Wastewater Marine Protection', description: 'Systems reducing chemical discharge, nutrient runoff, and wastewater pollution before ocean impact' },
{ name: 'Circular Materials from Marine Waste', description: 'Transformation of algae, fishery byproducts, and recovered ocean waste into useful products' },
],
},
{
category: 'Climate Mitigation',
color: '#0284c7',
tags: [
{ name: 'Low-Carbon Blue Supply Chains', description: 'Solutions reducing emissions in seafood logistics, cooling, and marine value chains' },
{ name: 'Ocean Renewable Energy', description: 'Wave, tidal, offshore, and hybrid marine energy technologies' },
{ name: 'Marine Carbon Removal & Sequestration', description: 'Approaches that remove and store carbon through ocean-linked biological or mineral pathways' },
],
},
{
category: 'Technology & Innovation',
color: '#7c3aed',
tags: [
{ name: 'Marine Robotics & Autonomous Systems', description: 'ROVs, AUVs, and marine drones used for restoration, monitoring, and intervention' },
{ name: 'AI Ocean Intelligence', description: 'Machine learning and advanced analytics for ocean health, biodiversity, or operations optimization' },
{ name: 'Marine Ecotoxicology & Environmental Testing', description: 'Testing platforms that evaluate product or discharge impacts on marine ecosystems' },
],
},
{
category: 'Sustainable Shipping',
color: '#053d57',
tags: [
{ name: 'Cleaner Maritime Operations', description: 'Operational innovations that reduce emissions, waste, and fuel intensity in maritime transport' },
{ name: 'Port Environmental Performance', description: 'Technologies and practices that improve sustainability outcomes in ports and harbors' },
{ name: 'Marine Noise & Vessel Impact Reduction', description: 'Solutions that mitigate underwater noise and ecological disturbance from vessel activity' },
],
},
{
category: 'Blue Carbon',
color: '#0ea5a4',
tags: [
{ name: 'Seagrass & Mangrove Carbon Projects', description: 'Restoration and protection programs for key blue carbon habitats' },
{ name: 'Blue Carbon Measurement & Verification', description: 'Monitoring and MRV tools for quantifying carbon outcomes in marine ecosystems' },
{ name: 'Financing Blue Carbon Conservation', description: 'Financial models enabling scalable protection and restoration of blue carbon assets' },
],
},
{
category: 'Habitat Restoration',
color: '#16a34a',
tags: [
{ name: 'Coral Restoration & Reef Resilience', description: 'Propagation, outplanting, and resilience strategies for coral ecosystems' },
{ name: 'Coastal Habitat Regeneration', description: 'Recovery of dunes, wetlands, estuaries, and nearshore biodiversity hotspots' },
{ name: 'Biodiversity Threat Mitigation', description: 'Targeted interventions for invasive species, habitat degradation, and species decline' },
],
},
{
category: 'Community Capacity',
color: '#ea580c',
tags: [
{ name: 'Coastal Livelihood & Inclusion Models', description: 'Community-led business models that improve income while protecting marine ecosystems' },
{ name: 'Women-Led Blue Economy Initiatives', description: 'Programs that strengthen women leadership and participation in sustainable marine enterprises' },
{ name: 'Ocean Skills & Entrepreneurship Training', description: 'Capacity-building and startup enablement for students and coastal entrepreneurs' },
],
},
{
category: 'Sustainable Fishing',
color: '#059669',
tags: [
{ name: 'Regenerative Aquaculture', description: 'Aquaculture systems integrating ecological restoration, animal welfare, and reduced environmental pressure' },
{ name: 'Seaweed & Algae Value Chains', description: 'Cultivation and commercialization of algae or seaweed for food, feed, and biomaterials' },
{ name: 'Cold Chain & Post-Harvest Seafood Efficiency', description: 'Technologies reducing fish loss and waste through sustainable preservation and handling' },
],
},
{
category: 'Consumer Awareness',
color: '#f59e0b',
tags: [
{ name: 'Ocean Literacy Platforms', description: 'Digital or physical tools that increase public understanding of ocean health issues' },
{ name: 'Behavior Change for Ocean Protection', description: 'Campaigns and products that help consumers reduce harmful marine impact' },
{ name: 'Traceability & Sustainable Choice Tools', description: 'Interfaces helping buyers identify responsible seafood and ocean-positive products' },
],
},
{
category: 'Ocean Acidification',
color: '#2563eb',
tags: [
{ name: 'Acidification Monitoring & Forecasting', description: 'Sensors and models tracking pH dynamics and acidification risk in marine environments' },
{ name: 'Alkalinity & Buffering Interventions', description: 'Interventions designed to reduce acidification pressure on vulnerable marine systems' },
{ name: 'Acidification-Resilient Aquaculture', description: 'Farming approaches and species strategies resilient to changing ocean chemistry' },
],
},
] as const
// Climate & Carbon — aligned with MOPC OceanIssue: CLIMATE_MITIGATION, BLUE_CARBON, OCEAN_ACIDIFICATION const expertiseTags = tagGroups.flatMap((group, groupIndex) =>
{ 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 }, group.tags.map((tag, tagIndex) => ({
{ 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: tag.name,
{ 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 }, description: tag.description,
{ name: 'Renewable Ocean Energy', description: 'Wave, tidal, offshore wind, and ocean thermal energy conversion technologies', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 13 }, category: group.category,
{ name: 'Carbon Capture & Sequestration', description: 'Marine-based carbon dioxide removal technologies including algae, mineralization, and ocean fertilization', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 14 }, color: group.color,
sortOrder: groupIndex * 10 + tagIndex,
// 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) { for (const tag of expertiseTags) {
await prisma.expertiseTag.upsert({ await prisma.expertiseTag.upsert({
where: { name: tag.name }, where: { name: tag.name },
update: {}, update: {
description: tag.description,
category: tag.category,
color: tag.color,
sortOrder: tag.sortOrder,
isActive: true,
},
create: { create: {
name: tag.name, name: tag.name,
description: tag.description, description: tag.description,
@ -229,18 +280,35 @@ async function main() {
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}
for (const account of staffAccounts) { for (const account of staffAccounts) {
const passwordHash = await bcrypt.hash(account.password, 12) const passwordHash = await bcrypt.hash(account.password, 12)
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: account.email }, where: { email: account.email },
update: { passwordHash }, update: isSuperAdmin
create: { ? {
email: account.email,
name: account.name,
role: account.role,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
passwordHash, passwordHash,
mustSetPassword: false, mustSetPassword: false,
passwordSetAt: new Date(), passwordSetAt: new Date(),
onboardingCompletedAt: new Date(), onboardingCompletedAt: new Date(),
}
: {
status: UserStatus.NONE,
passwordHash: null,
mustSetPassword: true,
passwordSetAt: null,
onboardingCompletedAt: null,
inviteToken: null,
inviteTokenExpiresAt: null,
},
create: {
email: account.email,
name: account.name,
role: account.role,
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null,
mustSetPassword: !isSuperAdmin,
passwordSetAt: isSuperAdmin ? new Date() : null,
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
}, },
}) })
staffUsers[account.email] = user.id staffUsers[account.email] = user.id
@ -267,7 +335,9 @@ async function main() {
for (const j of juryMembers) { for (const j of juryMembers) {
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: j.email }, where: { email: j.email },
update: {}, update: {
status: UserStatus.NONE,
},
create: { create: {
email: j.email, email: j.email,
name: j.name, name: j.name,
@ -296,7 +366,9 @@ async function main() {
for (const m of mentors) { for (const m of mentors) {
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: m.email }, where: { email: m.email },
update: {}, update: {
status: UserStatus.NONE,
},
create: { create: {
email: m.email, email: m.email,
name: m.name, name: m.name,
@ -322,7 +394,9 @@ async function main() {
for (const o of observers) { for (const o of observers) {
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: o.email }, where: { email: o.email },
update: {}, update: {
status: UserStatus.NONE,
},
create: { create: {
email: o.email, email: o.email,
name: o.name, name: o.name,
@ -356,8 +430,13 @@ async function main() {
// ========================================================================== // ==========================================================================
console.log('\n🔗 Creating pipeline...') console.log('\n🔗 Creating pipeline...')
const pipeline = await prisma.pipeline.create({ const pipeline = await prisma.pipeline.upsert({
data: { where: { slug: 'mopc-2026' },
update: {
name: 'MOPC 2026 Main Pipeline',
status: 'ACTIVE',
},
create: {
programId: program.id, programId: program.id,
name: 'MOPC 2026 Main Pipeline', name: 'MOPC 2026 Main Pipeline',
slug: 'mopc-2026', slug: 'mopc-2026',
@ -376,8 +455,10 @@ async function main() {
// ========================================================================== // ==========================================================================
console.log('\n🛤 Creating tracks...') console.log('\n🛤 Creating tracks...')
const mainTrack = await prisma.track.create({ const mainTrack = await prisma.track.upsert({
data: { where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'main' } },
update: { name: 'Main Competition' },
create: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
name: 'Main Competition', name: 'Main Competition',
slug: 'main', slug: 'main',
@ -387,8 +468,10 @@ async function main() {
}, },
}) })
const innovationTrack = await prisma.track.create({ const innovationTrack = await prisma.track.upsert({
data: { where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'innovation-award' } },
update: { name: 'Ocean Innovation Award' },
create: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
name: 'Ocean Innovation Award', name: 'Ocean Innovation Award',
slug: 'innovation-award', slug: 'innovation-award',
@ -400,8 +483,10 @@ async function main() {
}, },
}) })
const impactTrack = await prisma.track.create({ const impactTrack = await prisma.track.upsert({
data: { where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'impact-award' } },
update: { name: 'Ocean Impact Award' },
create: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
name: 'Ocean Impact Award', name: 'Ocean Impact Award',
slug: 'impact-award', slug: 'impact-award',
@ -413,8 +498,10 @@ async function main() {
}, },
}) })
const peoplesTrack = await prisma.track.create({ const peoplesTrack = await prisma.track.upsert({
data: { where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'peoples-choice' } },
update: { name: "People's Choice" },
create: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
name: "People's Choice", name: "People's Choice",
slug: 'peoples-choice', slug: 'peoples-choice',
@ -437,8 +524,10 @@ async function main() {
// --- Main track stages --- // --- Main track stages ---
const mainStages = await Promise.all([ const mainStages = await Promise.all([
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'intake' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.INTAKE, stageType: StageType.INTAKE,
name: 'Application Intake', name: 'Application Intake',
@ -455,8 +544,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'screening' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.FILTER, stageType: StageType.FILTER,
name: 'AI Screening', name: 'AI Screening',
@ -479,8 +570,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'evaluation' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.EVALUATION, stageType: StageType.EVALUATION,
name: 'Expert Evaluation', name: 'Expert Evaluation',
@ -496,8 +589,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'selection' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.SELECTION, stageType: StageType.SELECTION,
name: 'Semi-Final Selection', name: 'Semi-Final Selection',
@ -511,8 +606,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'grand-final' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.LIVE_FINAL, stageType: StageType.LIVE_FINAL,
name: 'Grand Final', name: 'Grand Final',
@ -529,8 +626,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: mainTrack.id, slug: 'results' } },
update: {},
create: {
trackId: mainTrack.id, trackId: mainTrack.id,
stageType: StageType.RESULTS, stageType: StageType.RESULTS,
name: 'Results & Awards', name: 'Results & Awards',
@ -548,8 +647,10 @@ async function main() {
// --- Innovation Award track stages --- // --- Innovation Award track stages ---
const innovationStages = await Promise.all([ const innovationStages = await Promise.all([
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: innovationTrack.id, slug: 'innovation-review' } },
update: {},
create: {
trackId: innovationTrack.id, trackId: innovationTrack.id,
stageType: StageType.EVALUATION, stageType: StageType.EVALUATION,
name: 'Innovation Jury Review', name: 'Innovation Jury Review',
@ -563,8 +664,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: innovationTrack.id, slug: 'innovation-results' } },
update: {},
create: {
trackId: innovationTrack.id, trackId: innovationTrack.id,
stageType: StageType.RESULTS, stageType: StageType.RESULTS,
name: 'Innovation Results', name: 'Innovation Results',
@ -578,8 +681,10 @@ async function main() {
// --- Impact Award track stages --- // --- Impact Award track stages ---
const impactStages = await Promise.all([ const impactStages = await Promise.all([
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: impactTrack.id, slug: 'impact-review' } },
update: {},
create: {
trackId: impactTrack.id, trackId: impactTrack.id,
stageType: StageType.EVALUATION, stageType: StageType.EVALUATION,
name: 'Impact Assessment', name: 'Impact Assessment',
@ -593,8 +698,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: impactTrack.id, slug: 'impact-results' } },
update: {},
create: {
trackId: impactTrack.id, trackId: impactTrack.id,
stageType: StageType.RESULTS, stageType: StageType.RESULTS,
name: 'Impact Results', name: 'Impact Results',
@ -608,8 +715,10 @@ async function main() {
// --- People's Choice track stages --- // --- People's Choice track stages ---
const peoplesStages = await Promise.all([ const peoplesStages = await Promise.all([
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: peoplesTrack.id, slug: 'public-vote' } },
update: {},
create: {
trackId: peoplesTrack.id, trackId: peoplesTrack.id,
stageType: StageType.LIVE_FINAL, stageType: StageType.LIVE_FINAL,
name: 'Public Voting', name: 'Public Voting',
@ -624,8 +733,10 @@ async function main() {
}, },
}, },
}), }),
prisma.stage.create({ prisma.stage.upsert({
data: { where: { trackId_slug: { trackId: peoplesTrack.id, slug: 'peoples-results' } },
update: {},
create: {
trackId: peoplesTrack.id, trackId: peoplesTrack.id,
stageType: StageType.RESULTS, stageType: StageType.RESULTS,
name: "People's Choice Results", name: "People's Choice Results",
@ -655,8 +766,15 @@ async function main() {
let transitionCount = 0 let transitionCount = 0
for (const group of trackStageGroups) { for (const group of trackStageGroups) {
for (let i = 0; i < group.stages.length - 1; i++) { for (let i = 0; i < group.stages.length - 1; i++) {
await prisma.stageTransition.create({ await prisma.stageTransition.upsert({
data: { where: {
fromStageId_toStageId: {
fromStageId: group.stages[i].id,
toStageId: group.stages[i + 1].id,
},
},
update: {},
create: {
fromStageId: group.stages[i].id, fromStageId: group.stages[i].id,
toStageId: group.stages[i + 1].id, toStageId: group.stages[i + 1].id,
isDefault: true, isDefault: true,
@ -670,7 +788,16 @@ async function main() {
// ========================================================================== // ==========================================================================
// 11. Parse CSV & Create Applicants + Projects // 11. Parse CSV & Create Applicants + Projects
// ========================================================================== // ==========================================================================
console.log('\n📄 Parsing Candidatures2026.csv...') console.log('\n📄 Checking for existing projects...')
const existingProjectCount = await prisma.project.count({ where: { programId: program.id } })
let projectCount = 0
if (existingProjectCount > 0) {
projectCount = existingProjectCount
console.log(` ⏭️ ${existingProjectCount} projects already exist, skipping CSV import`)
} else {
console.log(' Parsing Candidatures2026.csv...')
const csvPath = join(__dirname, '..', 'docs', 'Candidatures2026.csv') const csvPath = join(__dirname, '..', 'docs', 'Candidatures2026.csv')
const csvContent = readFileSync(csvPath, 'utf-8') const csvContent = readFileSync(csvPath, 'utf-8')
@ -709,7 +836,6 @@ async function main() {
const intakeStage = mainStages[0] // INTAKE - CLOSED const intakeStage = mainStages[0] // INTAKE - CLOSED
const filterStage = mainStages[1] // FILTER - ACTIVE const filterStage = mainStages[1] // FILTER - ACTIVE
let projectCount = 0
for (const row of validRecords) { for (const row of validRecords) {
const email = (row['E-mail'] || '').trim().toLowerCase() const email = (row['E-mail'] || '').trim().toLowerCase()
const name = (row['Full name'] || '').trim() const name = (row['Full name'] || '').trim()
@ -732,12 +858,15 @@ async function main() {
// Create or get applicant user // Create or get applicant user
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: {}, update: {
status: UserStatus.NONE,
mustSetPassword: true,
},
create: { create: {
email, email,
name, name,
role: UserRole.APPLICANT, role: UserRole.APPLICANT,
status: UserStatus.ACTIVE, status: UserStatus.NONE,
phoneNumber: phone, phoneNumber: phone,
country, country,
metadataJson: university ? { institution: university } : undefined, metadataJson: university ? { institution: university } : undefined,
@ -801,6 +930,7 @@ async function main() {
} }
console.log(` ✓ Created ${projectCount} projects with stage states`) console.log(` ✓ Created ${projectCount} projects with stage states`)
}
// ========================================================================== // ==========================================================================
// 12. Evaluation Form (for Expert Evaluation stage) // 12. Evaluation Form (for Expert Evaluation stage)
@ -834,8 +964,10 @@ async function main() {
// ========================================================================== // ==========================================================================
console.log('\n🏆 Creating special awards...') console.log('\n🏆 Creating special awards...')
await prisma.specialAward.create({ await prisma.specialAward.upsert({
data: { where: { trackId: innovationTrack.id },
update: {},
create: {
programId: program.id, programId: program.id,
name: 'Ocean Innovation Award', name: 'Ocean Innovation Award',
description: 'Recognizes the most innovative technology solution for ocean protection', description: 'Recognizes the most innovative technology solution for ocean protection',
@ -847,8 +979,10 @@ async function main() {
}, },
}) })
await prisma.specialAward.create({ await prisma.specialAward.upsert({
data: { where: { trackId: impactTrack.id },
update: {},
create: {
programId: program.id, programId: program.id,
name: 'Ocean Impact Award', name: 'Ocean Impact Award',
description: 'Recognizes the project with highest community and environmental impact', description: 'Recognizes the project with highest community and environmental impact',
@ -868,6 +1002,10 @@ async function main() {
// ========================================================================== // ==========================================================================
console.log('\n🔀 Creating routing rules...') console.log('\n🔀 Creating routing rules...')
const existingTechRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Tech Innovation to Innovation Award' },
})
if (!existingTechRule) {
await prisma.routingRule.create({ await prisma.routingRule.create({
data: { data: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
@ -883,7 +1021,12 @@ async function main() {
isActive: true, isActive: true,
}, },
}) })
}
const existingImpactRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Community Impact to Impact Award' },
})
if (!existingImpactRule) {
await prisma.routingRule.create({ await prisma.routingRule.create({
data: { data: {
pipelineId: pipeline.id, pipelineId: pipeline.id,
@ -900,12 +1043,68 @@ async function main() {
isActive: true, isActive: true,
}, },
}) })
}
console.log(' ✓ Tech Innovation → Innovation Award (PARALLEL)') console.log(' ✓ Tech Innovation → Innovation Award (PARALLEL)')
console.log(' ✓ Community Impact → Impact Award (EXCLUSIVE)') console.log(' ✓ Community Impact → Impact Award (EXCLUSIVE)')
// ========================================================================== // ==========================================================================
// 15. Summary // 15. Notification Email Settings
// ==========================================================================
console.log('\n🔔 Creating notification email settings...')
const notificationSettings = [
// Team / Applicant notifications
{ notificationType: 'APPLICATION_SUBMITTED', category: 'team', label: 'Application Submitted', description: 'When a team submits their application', sendEmail: true },
{ notificationType: 'TEAM_INVITE_RECEIVED', category: 'team', label: 'Team Invitation Received', description: 'When someone is invited to join a team', sendEmail: true },
{ notificationType: 'TEAM_MEMBER_JOINED', category: 'team', label: 'Team Member Joined', description: 'When a new member joins the team', sendEmail: false },
{ notificationType: 'ADVANCED_SEMIFINAL', category: 'team', label: 'Advanced to Semi-Finals', description: 'When a project advances to semi-finals', sendEmail: true },
{ notificationType: 'ADVANCED_FINAL', category: 'team', label: 'Selected as Finalist', description: 'When a project is selected as a finalist', sendEmail: true },
{ notificationType: 'MENTOR_ASSIGNED', category: 'team', label: 'Mentor Assigned', description: 'When a mentor is assigned to the team', sendEmail: true },
{ notificationType: 'NOT_SELECTED', category: 'team', label: 'Not Selected', description: 'When a project is not selected for the next round', sendEmail: true },
{ notificationType: 'FEEDBACK_AVAILABLE', category: 'team', label: 'Feedback Available', description: 'When jury feedback becomes available', sendEmail: true },
{ notificationType: 'WINNER_ANNOUNCEMENT', category: 'team', label: 'Winner Announcement', description: 'When a project wins an award', sendEmail: true },
// Jury notifications
{ notificationType: 'ASSIGNED_TO_PROJECT', category: 'jury', label: 'Assigned to Project', description: 'When a jury member is assigned to a project', sendEmail: true },
{ notificationType: 'BATCH_ASSIGNED', category: 'jury', label: 'Batch Assignment', description: 'When multiple projects are assigned at once', sendEmail: true },
{ notificationType: 'ROUND_NOW_OPEN', category: 'jury', label: 'Round Now Open', description: 'When a round opens for evaluation', sendEmail: true },
{ notificationType: 'REMINDER_24H', category: 'jury', label: 'Reminder (24h)', description: 'Reminder 24 hours before deadline', sendEmail: true },
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
// Mentor notifications
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
{ notificationType: 'MENTEE_ADVANCED', category: 'mentor', label: 'Mentee Advanced', description: 'When a mentee advances to the next round', sendEmail: true },
{ notificationType: 'MENTEE_FINALIST', category: 'mentor', label: 'Mentee is Finalist', description: 'When a mentee is selected as finalist', sendEmail: true },
{ notificationType: 'MENTEE_WON', category: 'mentor', label: 'Mentee Won', description: 'When a mentee wins an award', sendEmail: true },
// Observer notifications
{ notificationType: 'ROUND_STARTED', category: 'observer', label: 'Round Started', description: 'When a new round begins', sendEmail: false },
{ notificationType: 'ROUND_COMPLETED', category: 'observer', label: 'Round Completed', description: 'When a round is completed', sendEmail: true },
{ notificationType: 'FINALISTS_ANNOUNCED', category: 'observer', label: 'Finalists Announced', description: 'When finalists are announced', sendEmail: true },
{ notificationType: 'WINNERS_ANNOUNCED', category: 'observer', label: 'Winners Announced', description: 'When winners are announced', sendEmail: true },
// Admin notifications
{ notificationType: 'FILTERING_COMPLETE', category: 'admin', label: 'AI Filtering Complete', description: 'When AI filtering job completes', sendEmail: false },
{ notificationType: 'FILTERING_FAILED', category: 'admin', label: 'AI Filtering Failed', description: 'When AI filtering job fails', sendEmail: true },
{ notificationType: 'NEW_APPLICATION', category: 'admin', label: 'New Application', description: 'When a new application is received', sendEmail: false },
{ notificationType: 'SYSTEM_ERROR', category: 'admin', label: 'System Error', description: 'When a system error occurs', sendEmail: true },
]
for (const setting of notificationSettings) {
await prisma.notificationEmailSetting.upsert({
where: { notificationType: setting.notificationType },
update: {
category: setting.category,
label: setting.label,
description: setting.description,
},
create: setting,
})
}
console.log(` ✓ Created ${notificationSettings.length} notification email settings`)
// ==========================================================================
// 16. Summary
// ========================================================================== // ==========================================================================
console.log('\n' + '='.repeat(60)) console.log('\n' + '='.repeat(60))
console.log('✅ SEEDING COMPLETE') console.log('✅ SEEDING COMPLETE')

View File

@ -73,6 +73,7 @@ export default function MemberDetailPage() {
) )
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('JURY_MEMBER') const [role, setRole] = useState<string>('JURY_MEMBER')
const [status, setStatus] = useState<string>('NONE') const [status, setStatus] = useState<string>('NONE')
const [expertiseTags, setExpertiseTags] = useState<string[]>([]) const [expertiseTags, setExpertiseTags] = useState<string[]>([])
@ -83,6 +84,7 @@ export default function MemberDetailPage() {
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setName(user.name || '') setName(user.name || '')
setEmail(user.email || '')
setRole(user.role) setRole(user.role)
setStatus(user.status) setStatus(user.status)
setExpertiseTags(user.expertiseTags || []) setExpertiseTags(user.expertiseTags || [])
@ -94,6 +96,7 @@ export default function MemberDetailPage() {
try { try {
await updateUser.mutateAsync({ await updateUser.mutateAsync({
id: userId, id: userId,
email: email || undefined,
name: name || null, name: name || null,
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED', status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
@ -212,7 +215,12 @@ export default function MemberDetailPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled /> <Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>

View File

@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
Select, Select,
@ -103,6 +104,7 @@ const fileTypeIcons: Record<string, React.ReactNode> = {
function EditProjectContent({ projectId }: { projectId: string }) { function EditProjectContent({ projectId }: { projectId: string }) {
const router = useRouter() const router = useRouter()
const [tagInput, setTagInput] = useState('') const [tagInput, setTagInput] = useState('')
const [statusNotificationConfirmed, setStatusNotificationConfirmed] = useState(false)
// Fetch project data // Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({ const { data: project, isLoading } = trpc.project.get.useQuery({
@ -172,6 +174,24 @@ function EditProjectContent({ projectId }: { projectId: string }) {
}, [project, form]) }, [project, form])
const tags = form.watch('tags') const tags = form.watch('tags')
const selectedStatus = form.watch('status')
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const requiresStatusNotificationConfirmation = Boolean(
project && selectedStatus !== previousStatus && statusTriggersNotifications
)
const notificationRecipientEmails = Array.from(
new Set(
(project?.teamMembers ?? [])
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
.filter((email) => email.length > 0)
)
)
useEffect(() => {
setStatusNotificationConfirmed(false)
form.clearErrors('status')
}, [selectedStatus, form])
// Add tag // Add tag
const addTag = useCallback(() => { const addTag = useCallback(() => {
@ -194,6 +214,14 @@ function EditProjectContent({ projectId }: { projectId: string }) {
) )
const onSubmit = async (data: UpdateProjectForm) => { const onSubmit = async (data: UpdateProjectForm) => {
if (requiresStatusNotificationConfirmation && !statusNotificationConfirmed) {
form.setError('status', {
type: 'manual',
message: 'Confirm participant notifications before saving this status change.',
})
return
}
await updateProject.mutateAsync({ await updateProject.mutateAsync({
id: projectId, id: projectId,
title: data.title, title: data.title,
@ -370,6 +398,39 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<SelectItem value="REJECTED">Rejected</SelectItem> <SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{requiresStatusNotificationConfirmation && (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<p className="text-xs font-medium">
Participant Notification Check
</p>
<p className="text-xs text-muted-foreground">
Saving this status will send automated notifications.
</p>
<p className="text-xs text-muted-foreground">
Recipients ({notificationRecipientEmails.length}):{' '}
{notificationRecipientEmails.length > 0
? notificationRecipientEmails.slice(0, 8).join(', ')
: 'No linked participant accounts found'}
{notificationRecipientEmails.length > 8 ? ', ...' : ''}
</p>
<div className="flex items-start gap-2">
<Checkbox
id="confirm-status-notifications"
checked={statusNotificationConfirmed}
onCheckedChange={(checked) => {
const confirmed = checked === true
setStatusNotificationConfirmed(confirmed)
if (confirmed) {
form.clearErrors('status')
}
}}
/>
<FormLabel htmlFor="confirm-status-notifications" className="text-sm font-normal leading-5">
I verified participant recipients and approve sending automated notifications.
</FormLabel>
</div>
</div>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -557,7 +618,10 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<Button type="button" variant="outline" asChild> <Button type="button" variant="outline" asChild>
<Link href={`/admin/projects/${projectId}`}>Cancel</Link> <Link href={`/admin/projects/${projectId}`}>Cancel</Link>
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button
type="submit"
disabled={isPending || (requiresStatusNotificationConfirmation && !statusNotificationConfirmed)}
>
{updateProject.isPending && ( {updateProject.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}

View File

@ -392,11 +392,14 @@ export default function ProjectsPage() {
const [allMatchingSelected, setAllMatchingSelected] = useState(false) const [allMatchingSelected, setAllMatchingSelected] = useState(false)
const [bulkStatus, setBulkStatus] = useState<string>('') const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status') const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignStageId, setBulkAssignStageId] = useState('') const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false) const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false) const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
const bulkStatusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(bulkStatus)
// Query for fetching all matching IDs (used for "select all across pages") // Query for fetching all matching IDs (used for "select all across pages")
const allIdsQuery = trpc.project.listAllIds.useQuery( const allIdsQuery = trpc.project.listAllIds.useQuery(
{ {
@ -452,6 +455,26 @@ export default function ProjectsPage() {
}, },
}) })
const bulkNotificationPreview = trpc.project.previewStatusNotificationRecipients.useQuery(
{
ids: Array.from(selectedIds),
status: (bulkStatus || 'SUBMITTED') as
| 'SUBMITTED'
| 'ELIGIBLE'
| 'ASSIGNED'
| 'SEMIFINALIST'
| 'FINALIST'
| 'REJECTED',
},
{
enabled:
bulkConfirmOpen &&
selectedIds.size > 0 &&
bulkStatusTriggersNotifications,
staleTime: 30_000,
}
)
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({ const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`) toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
@ -524,15 +547,21 @@ export default function ProjectsPage() {
setSelectedIds(new Set()) setSelectedIds(new Set())
setAllMatchingSelected(false) setAllMatchingSelected(false)
setBulkStatus('') setBulkStatus('')
setBulkNotificationsConfirmed(false)
} }
const handleBulkApply = () => { const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return if (!bulkStatus || selectedIds.size === 0) return
setBulkNotificationsConfirmed(false)
setBulkConfirmOpen(true) setBulkConfirmOpen(true)
} }
const handleBulkConfirm = () => { const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0) return if (!bulkStatus || selectedIds.size === 0) return
if (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed) {
toast.error('Confirm participant recipients before sending notifications')
return
}
bulkUpdateStatus.mutate({ bulkUpdateStatus.mutate({
ids: Array.from(selectedIds), ids: Array.from(selectedIds),
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED', status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
@ -1283,7 +1312,15 @@ export default function ProjectsPage() {
)} )}
{/* Bulk Status Update Confirmation Dialog */} {/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}> <AlertDialog
open={bulkConfirmOpen}
onOpenChange={(open) => {
setBulkConfirmOpen(open)
if (!open) {
setBulkNotificationsConfirmed(false)
}
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle> <AlertDialogTitle>Update Project Status</AlertDialogTitle>
@ -1302,6 +1339,64 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
)} )}
{bulkStatusTriggersNotifications && (
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
<p className="text-sm font-medium">Participant Notification Check</p>
<p className="text-xs text-muted-foreground">
Review recipients before automated emails are sent.
</p>
{bulkNotificationPreview.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading recipients...
</div>
) : bulkNotificationPreview.data ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{bulkNotificationPreview.data.totalRecipients} recipient
{bulkNotificationPreview.data.totalRecipients !== 1 ? 's' : ''} across{' '}
{bulkNotificationPreview.data.projectsWithRecipients} project
{bulkNotificationPreview.data.projectsWithRecipients !== 1 ? 's' : ''}.
</p>
<div className="max-h-44 space-y-2 overflow-auto rounded-md border bg-background p-2">
{bulkNotificationPreview.data.projects
.filter((project) => project.recipientCount > 0)
.slice(0, 8)
.map((project) => (
<div key={project.id} className="text-xs">
<p className="font-medium">
{project.title} ({project.recipientCount})
</p>
<p className="text-muted-foreground">
{project.recipientsPreview.join(', ')}
{project.hasMoreRecipients ? ', ...' : ''}
</p>
</div>
))}
{bulkNotificationPreview.data.projectsWithRecipients === 0 && (
<p className="text-xs text-amber-700">
No linked participant accounts found. Status will update, but no team notifications will be sent.
</p>
)}
</div>
</div>
) : null}
<div className="flex items-start gap-2">
<Checkbox
id="bulk-notification-confirm"
checked={bulkNotificationsConfirmed}
onCheckedChange={(checked) => setBulkNotificationsConfirmed(checked === true)}
/>
<Label htmlFor="bulk-notification-confirm" className="text-sm font-normal leading-5">
I verified the recipient list and want to send these automated notifications.
</Label>
</div>
</div>
)}
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@ -1310,12 +1405,12 @@ export default function ProjectsPage() {
<AlertDialogAction <AlertDialogAction
onClick={handleBulkConfirm} onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''} className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending} disabled={bulkUpdateStatus.isPending || (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed)}
> >
{bulkUpdateStatus.isPending ? ( {bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null} ) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''} {bulkStatusTriggersNotifications ? 'Update + Notify' : 'Update'} {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -1,554 +1,12 @@
'use client' import { redirect } from 'next/navigation'
import { useState } from 'react' type AdvancedPipelinePageProps = {
import { useParams } from 'next/navigation' params: Promise<{ id: string }>
import Link from 'next/link'
import type { Route as NextRoute } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Save,
Loader2,
ChevronRight,
Layers,
GitBranch,
Route,
Play,
} from 'lucide-react'
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
const stageTypeColors: Record<string, string> = {
INTAKE: 'text-blue-600',
FILTER: 'text-amber-600',
EVALUATION: 'text-purple-600',
SELECTION: 'text-rose-600',
LIVE_FINAL: 'text-emerald-600',
RESULTS: 'text-cyan-600',
} }
type SelectedItem = export default async function AdvancedPipelinePage({
| { type: 'stage'; trackId: string; stageId: string } params,
| { type: 'track'; trackId: string } }: AdvancedPipelinePageProps) {
| null const { id } = await params
redirect(`/admin/rounds/pipeline/${id}` as never)
export default function AdvancedEditorPage() {
const params = useParams()
const pipelineId = params.id as string
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
const [configEditValue, setConfigEditValue] = useState('')
const [simulationProjectIds, setSimulationProjectIds] = useState('')
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
onSuccess: () => {
toast.success('Stage config saved')
refetch()
},
onError: (err) => toast.error(err.message),
})
const simulateMutation = trpc.pipeline.simulate.useMutation({
onSuccess: (data) => {
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
},
onError: (err) => toast.error(err.message),
})
const { data: routingRules } = trpc.routing.listRules.useQuery(
{ pipelineId },
{ enabled: !!pipelineId }
)
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-6 w-48" />
</div>
<div className="grid grid-cols-12 gap-4">
<Skeleton className="col-span-3 h-96" />
<Skeleton className="col-span-5 h-96" />
<Skeleton className="col-span-4 h-96" />
</div>
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={'/admin/rounds/pipelines' as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
</div>
</div>
)
}
const handleSelectStage = (trackId: string, stageId: string) => {
setSelectedItem({ type: 'stage', trackId, stageId })
const track = pipeline.tracks.find((t) => t.id === trackId)
const stage = track?.stages.find((s) => s.id === stageId)
setConfigEditValue(
JSON.stringify(stage?.configJson ?? {}, null, 2)
)
}
const executeSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
try {
const parsed = JSON.parse(configEditValue)
updateConfigMutation.mutate({
id: selectedItem.stageId,
configJson: parsed,
})
} catch {
toast.error('Invalid JSON in config editor')
}
}
const handleSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
// Validate JSON first
try {
JSON.parse(configEditValue)
} catch {
toast.error('Invalid JSON in config editor')
return
}
// If pipeline is active or stage has projects, require confirmation
const stage = pipeline?.tracks
.flatMap((t) => t.stages)
.find((s) => s.id === selectedItem.stageId)
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
const isActive = pipeline?.status === 'ACTIVE'
if (isActive || hasProjects) {
setShowSaveConfirm(true)
} else {
executeSaveConfig()
}
}
const handleSimulate = () => {
const ids = simulationProjectIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (ids.length === 0) {
toast.error('Enter at least one project ID')
return
}
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
}
const selectedTrack =
selectedItem?.type === 'stage'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: selectedItem?.type === 'track'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: null
const selectedStage =
selectedItem?.type === 'stage'
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
: null
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Advanced Editor</h1>
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
</div>
</div>
</div>
{/* Visualization */}
<PipelineVisualization tracks={pipeline.tracks} />
{/* Five Panel Layout */}
<div className="grid grid-cols-12 gap-4">
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
<div className="col-span-12 lg:col-span-3">
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Structure
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<div key={track.id}>
<button
type="button"
className={cn(
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
selectedItem?.type === 'track' &&
selectedItem.trackId === track.id
? 'bg-muted'
: ''
)}
onClick={() =>
setSelectedItem({ type: 'track', trackId: track.id })
}
>
<div className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3" />
<span>{track.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
{track.kind}
</Badge>
</div>
</button>
<div className="ml-4 space-y-0.5 mt-0.5">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<button
key={stage.id}
type="button"
className={cn(
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
selectedItem?.type === 'stage' &&
selectedItem.stageId === stage.id
? 'bg-muted font-medium'
: ''
)}
onClick={() =>
handleSelectStage(track.id, stage.id)
}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-[10px] font-mono',
stageTypeColors[stage.stageType] ?? ''
)}
>
{stage.stageType.slice(0, 3)}
</span>
<span className="truncate">{stage.name}</span>
{stage._count?.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[8px] h-3.5 px-1 ml-auto"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
</button>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* Panel 2 — Stage Config Editor (center) */}
<div className="col-span-12 lg:col-span-5">
<Card className="h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedStage
? `${selectedStage.name} Config`
: selectedTrack
? `${selectedTrack.name} Track`
: 'Select a stage'}
</CardTitle>
{selectedStage && (
<Button
size="sm"
variant="outline"
disabled={updateConfigMutation.isPending}
onClick={handleSaveConfig}
>
{updateConfigMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
Save
</Button>
)}
</div>
</CardHeader>
<CardContent>
{selectedStage ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-[10px]">
{selectedStage.stageType}
</Badge>
<span className="font-mono">{selectedStage.slug}</span>
</div>
<Textarea
value={configEditValue}
onChange={(e) => setConfigEditValue(e.target.value)}
className="font-mono text-xs min-h-[400px]"
placeholder="{ }"
/>
</div>
) : selectedTrack ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Kind</span>
<Badge variant="outline" className="text-xs">
{selectedTrack.kind}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Routing Mode</span>
<span className="text-xs font-mono">
{selectedTrack.routingMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Decision Mode</span>
<span className="text-xs font-mono">
{selectedTrack.decisionMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Stages</span>
<span className="font-medium">
{selectedTrack.stages.length}
</span>
</div>
{selectedTrack.specialAward && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium mb-1">Special Award</p>
<p className="text-xs text-muted-foreground">
{selectedTrack.specialAward.name} {' '}
{selectedTrack.specialAward.scoringMode}
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">
Select a track or stage from the tree to view or edit its
configuration
</p>
)}
</CardContent>
</Card>
</div>
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
<div className="col-span-12 lg:col-span-4 space-y-4">
{/* Routing Rules */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Route className="h-4 w-4" />
Routing Rules
</CardTitle>
</CardHeader>
<CardContent>
{routingRules && routingRules.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{routingRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
>
<Badge
variant={rule.isActive ? 'default' : 'secondary'}
className="text-[9px] h-4 shrink-0"
>
P{rule.priority}
</Badge>
<span className="truncate">
{rule.sourceTrack?.name ?? '—'} {' '}
{rule.destinationTrack?.name ?? '—'}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No routing rules configured
</p>
)}
</CardContent>
</Card>
{/* Transitions */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Transitions
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const allTransitions = pipeline.tracks.flatMap((track) =>
track.stages.flatMap((stage) =>
stage.transitionsFrom.map((t) => ({
id: t.id,
fromName: stage.name,
toName: t.toStage?.name ?? '?',
isDefault: t.isDefault,
}))
)
)
return allTransitions.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{allTransitions.map((t) => (
<div
key={t.id}
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
>
<span className="truncate">{t.fromName}</span>
<span className="text-muted-foreground"></span>
<span className="truncate">{t.toName}</span>
{t.isDefault && (
<Badge
variant="outline"
className="text-[8px] h-3.5 ml-auto shrink-0"
>
default
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No transitions defined
</p>
)
})()}
</CardContent>
</Card>
{/* Simulation */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Play className="h-4 w-4" />
Simulation
</CardTitle>
<CardDescription className="text-xs">
Test where projects would route
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs">Project IDs (comma-separated)</Label>
<Input
value={simulationProjectIds}
onChange={(e) => setSimulationProjectIds(e.target.value)}
placeholder="id1, id2, id3"
className="text-xs mt-1"
/>
</div>
<Button
size="sm"
className="w-full"
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
onClick={handleSimulate}
>
{simulateMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Simulation
</Button>
{simulateMutation.data?.simulations && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{simulateMutation.data.simulations.map((r, i) => (
<div
key={i}
className="text-xs py-1 border-b last:border-0"
>
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
<span className="text-muted-foreground"> </span>
<span>{r.targetTrackName ?? 'unrouted'}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Confirmation dialog for destructive config saves */}
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
<AlertDialogDescription>
This stage belongs to an active pipeline or has projects assigned to it.
Changing the configuration may affect ongoing evaluations and project processing.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowSaveConfirm(false)
executeSaveConfig()
}}
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
} }

View File

@ -1,9 +1,8 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -26,24 +25,25 @@ import {
MoreHorizontal, MoreHorizontal,
Rocket, Rocket,
Archive, Archive,
Settings2,
Layers, Layers,
GitBranch, GitBranch,
Loader2, Loader2,
ChevronDown, ChevronDown,
Save,
} from 'lucide-react' } from 'lucide-react'
import { InlineEditableText } from '@/components/ui/inline-editable-text' import { InlineEditableText } from '@/components/ui/inline-editable-text'
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart' import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor' import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit' import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel' import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel' import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel' import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel' import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel' import { normalizeStageConfig } from '@/lib/stage-config-schema'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel' import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700', DRAFT: 'bg-gray-100 text-gray-700',
@ -52,47 +52,86 @@ const statusColors: Record<string, string> = {
CLOSED: 'bg-blue-100 text-blue-700', CLOSED: 'bg-blue-100 text-blue-700',
} }
function StagePanel({ function toWizardTrackConfig(
stageId, track: {
stageType, id: string
configJson, name: string
}: { slug: string
stageId: string kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
stageType: string sortOrder: number
configJson: Record<string, unknown> | null routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
}) { decisionMode:
switch (stageType) { | 'JURY_VOTE'
case 'INTAKE': | 'AWARD_MASTER_DECISION'
return <IntakePanel stageId={stageId} configJson={configJson} /> | 'ADMIN_DECISION'
case 'FILTER': | null
return <FilterPanel stageId={stageId} configJson={configJson} /> stages: Array<{
case 'EVALUATION': id: string
return <EvaluationPanel stageId={stageId} configJson={configJson} /> name: string
case 'SELECTION': slug: string
return <SelectionPanel stageId={stageId} configJson={configJson} /> stageType:
case 'LIVE_FINAL': | 'INTAKE'
return <LiveFinalPanel stageId={stageId} configJson={configJson} /> | 'FILTER'
case 'RESULTS': | 'EVALUATION'
return <ResultsPanel stageId={stageId} configJson={configJson} /> | 'SELECTION'
default: | 'LIVE_FINAL'
return ( | 'RESULTS'
<Card> sortOrder: number
<CardContent className="py-8 text-center text-sm text-muted-foreground"> configJson: unknown
Unknown stage type: {stageType} }>
</CardContent> specialAward?: {
</Card> name: string
) description: string | null
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
} | null
}
): WizardTrackConfig {
return {
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingModeDefault: track.routingMode ?? undefined,
decisionMode: track.decisionMode ?? undefined,
stages: track.stages
.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown> | null
),
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
awardConfig: track.specialAward
? {
name: track.specialAward.name,
description: track.specialAward.description ?? undefined,
scoringMode: track.specialAward.scoringMode,
}
: undefined,
} }
} }
export default function PipelineDetailPage() { export default function PipelineDetailPage() {
const params = useParams() const params = useParams()
const pipelineId = params.id as string const pipelineId = params.id as string
const utils = trpc.useUtils()
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null) const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null) const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const stagePanelRef = useRef<HTMLDivElement>(null) const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
})
const [structureDirty, setStructureDirty] = useState(false)
const [settingsDirty, setSettingsDirty] = useState(false)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({ const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId, id: pipelineId,
@ -111,24 +150,107 @@ export default function PipelineDetailPage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Auto-select first track and stage on load const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
onSuccess: async () => {
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Pipeline structure updated')
setStructureDirty(false)
},
onError: (err) => toast.error(err.message),
})
const materializeRequirementsMutation =
trpc.file.materializeRequirementsFromConfig.useMutation({
onSuccess: async (result) => {
if (result.skipped && result.reason === 'already_materialized') {
toast.message('Requirements already materialized')
return
}
if (result.skipped && result.reason === 'no_config_requirements') {
toast.message('No legacy config requirements found')
return
}
await utils.file.listRequirements.invalidate()
toast.success(`Materialized ${result.created} requirement(s)`)
},
onError: (err) => toast.error(err.message),
})
// Auto-select first track on load
useEffect(() => { useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) { if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0] const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedTrackId(firstTrack.id) 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]) }, [pipeline, selectedTrackId])
// Scroll to stage panel when a stage is selected
useEffect(() => { useEffect(() => {
if (selectedStageId && stagePanelRef.current) { if (!pipeline) return
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
const nextTracks = pipeline.tracks
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) =>
toWizardTrackConfig({
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingMode: track.routingMode,
decisionMode: track.decisionMode,
stages: track.stages.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: stage.configJson,
})),
specialAward: track.specialAward
? {
name: track.specialAward.name,
description: track.specialAward.description,
scoringMode: track.specialAward.scoringMode,
} }
}, [selectedStageId]) : null,
})
)
setStructureTracks(nextTracks)
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
setNotificationConfig(
((settings.notificationConfig as Record<string, boolean> | undefined) ??
defaultNotificationConfig()) as Record<string, boolean>
)
setOverridePolicy(
((settings.overridePolicy as Record<string, unknown> | undefined) ?? {
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
}) as Record<string, unknown>
)
setStructureDirty(false)
setSettingsDirty(false)
}, [pipeline])
const trackOptionsForEditors = useMemo(
() =>
(pipeline?.tracks ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => ({
id: track.id,
name: track.name,
stages: track.stages
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => ({
id: stage.id,
name: stage.name,
sortOrder: stage.sortOrder,
})),
})),
[pipeline]
)
if (isLoading) { if (isLoading) {
return ( return (
@ -170,20 +292,18 @@ export default function PipelineDetailPage() {
const selectedStage = selectedTrack?.stages.find( const selectedStage = selectedTrack?.stages.find(
(s) => s.id === selectedStageId (s) => s.id === selectedStageId
) )
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
const hasAwardTracks = pipeline.tracks.some((t) => t.kind === 'AWARD')
const hasMultipleTracks = pipeline.tracks.length > 1
const handleTrackChange = (trackId: string) => { const handleTrackChange = (trackId: string) => {
setSelectedTrackId(trackId) setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
if (track && track.stages.length > 0) {
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedStageId(firstStage.id)
} else {
setSelectedStageId(null) setSelectedStageId(null)
} }
}
const handleStageSelect = (stageId: string) => { const handleStageSelect = (stageId: string) => {
setSelectedStageId(stageId) setSelectedStageId(stageId)
setSheetOpen(true)
} }
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => { const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
@ -193,11 +313,62 @@ export default function PipelineDetailPage() {
}) })
} }
const updateMainTrackStages = (stages: WizardTrackConfig['stages']) => {
setStructureTracks((prev) =>
prev.map((track) =>
track.kind === 'MAIN'
? {
...track,
stages,
}
: track
)
)
setStructureDirty(true)
}
const handleSaveStructure = async () => {
await updateStructureMutation.mutateAsync({
id: pipelineId,
tracks: structureTracks.map((track) => ({
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingModeDefault: track.routingModeDefault,
decisionMode: track.decisionMode,
stages: track.stages.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: stage.configJson,
})),
awardConfig: track.awardConfig,
})),
autoTransitions: false,
})
}
const handleSaveSettings = async () => {
const currentSettings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
await updatePipeline({
settingsJson: {
...currentSettings,
notificationConfig,
overridePolicy,
},
})
setSettingsDirty(false)
}
// Prepare flowchart data for the selected track // Prepare flowchart data for the selected track
const flowchartTracks = selectedTrack ? [selectedTrack] : [] const flowchartTracks = selectedTrack ? [selectedTrack] : []
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -272,15 +443,6 @@ export default function PipelineDetailPage() {
</div> </div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0"> <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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8"> <Button variant="outline" size="icon" className="h-8 w-8">
@ -373,7 +535,7 @@ export default function PipelineDetailPage() {
</div> </div>
{/* Track Switcher (only if multiple tracks) */} {/* Track Switcher (only if multiple tracks) */}
{pipeline.tracks.length > 1 && ( {hasMultipleTracks && (
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1"> <div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
{pipeline.tracks {pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
@ -407,11 +569,16 @@ export default function PipelineDetailPage() {
{/* Pipeline Flowchart */} {/* Pipeline Flowchart */}
{flowchartTracks.length > 0 ? ( {flowchartTracks.length > 0 ? (
<div>
<PipelineFlowchart <PipelineFlowchart
tracks={flowchartTracks} tracks={flowchartTracks}
selectedStageId={selectedStageId} selectedStageId={selectedStageId}
onStageSelect={handleStageSelect} onStageSelect={handleStageSelect}
/> />
<p className="text-xs text-muted-foreground mt-2">
Click a stage to edit its configuration
</p>
</div>
) : ( ) : (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
@ -420,42 +587,159 @@ export default function PipelineDetailPage() {
</Card> </Card>
)} )}
{/* Selected Stage Detail */} {/* Stage Detail Sheet */}
<div ref={stagePanelRef}> <StageDetailSheet
{selectedStage ? ( open={sheetOpen}
<div className="space-y-4"> onOpenChange={setSheetOpen}
<div className="border-t pt-4"> stage={
<h2 className="text-lg font-semibold text-muted-foreground"> selectedStage
Selected Stage: <span className="text-foreground">{selectedStage.name}</span> ? {
</h2> id: selectedStage.id,
</div> name: selectedStage.name,
stageType: selectedStage.stageType,
{/* Stage Config Editor */} configJson: selectedStage.configJson as Record<string, unknown> | null,
<StageConfigEditor }
stageId={selectedStage.id} : null
stageName={selectedStage.name} }
stageType={selectedStage.stageType} onSaveConfig={updateStageConfig}
configJson={selectedStage.configJson as Record<string, unknown> | null}
onSave={updateStageConfig}
isSaving={isUpdating} isSaving={isUpdating}
pipelineId={pipelineId}
materializeRequirements={(stageId) =>
materializeRequirementsMutation.mutate({ stageId })
}
isMaterializing={materializeRequirementsMutation.isPending}
/> />
{/* Stage Activity Panel */} {/* Stage Management */}
<StagePanel <div>
stageId={selectedStage.id} <h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
stageType={selectedStage.stageType} <p className="text-sm text-muted-foreground mb-4">
configJson={selectedStage.configJson as Record<string, unknown> | null} Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
/>
</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> </p>
<Card>
<CardContent className="pt-4 space-y-6">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">Pipeline Structure</h3>
<Button
type="button"
size="sm"
onClick={handleSaveStructure}
disabled={!structureDirty || updateStructureMutation.isPending}
>
{updateStructureMutation.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Structure
</Button>
</div>
{mainTrackDraft ? (
<MainTrackSection
stages={mainTrackDraft.stages}
onChange={updateMainTrackStages}
/>
) : (
<p className="text-sm text-muted-foreground">
No main track configured.
</p>
)}
<AwardsSection
tracks={structureTracks}
onChange={(tracks) => {
setStructureTracks(tracks)
setStructureDirty(true)
}}
/>
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Routing Rules (only if multiple tracks) */}
{hasMultipleTracks && (
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Routing Rules</h2>
<p className="text-sm text-muted-foreground mb-4">
Define conditions for routing projects between tracks.
</p>
<RoutingRulesEditor
pipelineId={pipelineId}
tracks={trackOptionsForEditors}
/>
</div>
)} )}
{/* Award Governance (only if award tracks exist) */}
{hasAwardTracks && (
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
<p className="text-sm text-muted-foreground mb-4">
Configure special awards, voting, and scoring for award tracks.
</p>
<AwardGovernanceEditor
pipelineId={pipelineId}
tracks={pipeline.tracks
.filter((track) => track.kind === 'AWARD')
.map((track) => ({
id: track.id,
name: track.name,
decisionMode: track.decisionMode,
specialAward: track.specialAward
? {
id: track.specialAward.id,
name: track.specialAward.name,
description: track.specialAward.description,
criteriaText: track.specialAward.criteriaText,
useAiEligibility: track.specialAward.useAiEligibility,
scoringMode: track.specialAward.scoringMode,
maxRankedPicks: track.specialAward.maxRankedPicks,
votingStartAt: track.specialAward.votingStartAt,
votingEndAt: track.specialAward.votingEndAt,
status: track.specialAward.status,
}
: null,
}))}
/>
</div>
)}
{/* Settings */}
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
<Card>
<CardContent className="pt-4 space-y-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
<Button
type="button"
size="sm"
onClick={handleSaveSettings}
disabled={!settingsDirty || isUpdating}
>
{isUpdating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Settings
</Button>
</div>
<NotificationsSection
config={notificationConfig}
onChange={(next) => {
setNotificationConfig(next)
setSettingsDirty(true)
}}
overridePolicy={overridePolicy}
onOverridePolicyChange={(next) => {
setOverridePolicy(next)
setSettingsDirty(true)
}}
/>
</CardContent>
</Card>
</div> </div>
</div> </div>
) )

View File

@ -19,7 +19,6 @@ import {
Calendar, Calendar,
Workflow, Workflow,
Pencil, Pencil,
Settings2,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
@ -233,18 +232,12 @@ export default function PipelineListPage() {
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="flex-1"> <Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
<Button size="sm" variant="outline" className="w-full"> <Button size="sm" variant="outline" className="w-full">
<Pencil className="h-3.5 w-3.5 mr-1.5" /> <Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit Edit
</Button> </Button>
</Link> </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> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -62,6 +62,7 @@ export default function ProfileSettingsPage() {
// Profile form state // Profile form state
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [bio, setBio] = useState('') const [bio, setBio] = useState('')
const [phoneNumber, setPhoneNumber] = useState('') const [phoneNumber, setPhoneNumber] = useState('')
const [notificationPreference, setNotificationPreference] = useState('EMAIL') const [notificationPreference, setNotificationPreference] = useState('EMAIL')
@ -85,6 +86,7 @@ export default function ProfileSettingsPage() {
useEffect(() => { useEffect(() => {
if (user && !profileLoaded) { if (user && !profileLoaded) {
setName(user.name || '') setName(user.name || '')
setEmail(user.email || '')
const meta = (user.metadataJson as Record<string, unknown>) || {} const meta = (user.metadataJson as Record<string, unknown>) || {}
setBio((meta.bio as string) || '') setBio((meta.bio as string) || '')
setPhoneNumber(user.phoneNumber || '') setPhoneNumber(user.phoneNumber || '')
@ -104,6 +106,7 @@ export default function ProfileSettingsPage() {
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
await updateProfile.mutateAsync({ await updateProfile.mutateAsync({
email: email || undefined,
name: name || undefined, name: name || undefined,
bio, bio,
phoneNumber: phoneNumber || null, phoneNumber: phoneNumber || null,
@ -222,8 +225,16 @@ export default function ProfileSettingsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled /> <Input
<p className="text-xs text-muted-foreground">Email cannot be changed</p> id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
<p className="text-xs text-muted-foreground">
This will be used for login and all notification emails.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -145,6 +145,14 @@ export function MembersContent() {
perPage: 20, perPage: 20,
}) })
const invitableIdsQuery = trpc.user.listInvitableIds.useQuery(
{
roles: roles,
search: search || undefined,
},
{ enabled: false }
)
const utils = trpc.useUtils() const utils = trpc.useUtils()
const bulkInvite = trpc.user.bulkSendInvitations.useMutation({ const bulkInvite = trpc.user.bulkSendInvitations.useMutation({
@ -209,10 +217,20 @@ export function MembersContent() {
} }
}, [allSelectableSelected, selectableUsers]) }, [allSelectableSelected, selectableUsers])
// Clear selection when filters/page change const selectAllMatching = useCallback(async () => {
useEffect(() => { const result = await invitableIdsQuery.refetch()
const ids = result.data?.userIds ?? []
if (ids.length === 0) {
toast.info('No invitable members match the current filter')
return
}
setSelectedIds(new Set(ids))
toast.success(`Selected ${ids.length} matching members`)
}, [invitableIdsQuery])
const clearSelection = useCallback(() => {
setSelectedIds(new Set()) setSelectedIds(new Set())
}, [tab, search, page]) }, [])
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
updateParams({ tab: value === 'all' ? null : value, page: '1' }) updateParams({ tab: value === 'all' ? null : value, page: '1' })
@ -265,6 +283,36 @@ export function MembersContent() {
<MembersSkeleton /> <MembersSkeleton />
) : data && data.users.length > 0 ? ( ) : data && data.users.length > 0 ? (
<> <>
{/* Bulk selection controls */}
<Card>
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Selection persists across pages and filters.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllMatching}
disabled={invitableIdsQuery.isFetching}
>
{invitableIdsQuery.isFetching ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
Select all matching
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
disabled={selectedIds.size === 0}
>
Clear selection
</Button>
</div>
</CardContent>
</Card>
{/* Desktop table */} {/* Desktop table */}
<Card className="hidden md:block"> <Card className="hidden md:block">
<Table> <Table>
@ -528,7 +576,7 @@ export function MembersContent() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setSelectedIds(new Set())} onClick={clearSelection}
disabled={bulkInvite.isPending} disabled={bulkInvite.isPending}
className="gap-1.5" className="gap-1.5"
> >

View File

@ -0,0 +1,358 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Save, Loader2 } from 'lucide-react'
type TrackAwardLite = {
id: string
name: string
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null
specialAward: {
id: string
name: string
description: string | null
criteriaText: string | null
useAiEligibility: boolean
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: number | null
votingStartAt: Date | null
votingEndAt: Date | null
status: string
} | null
}
type AwardGovernanceEditorProps = {
pipelineId: string
tracks: TrackAwardLite[]
}
type AwardDraft = {
trackId: string
awardId: string
awardName: string
description: string
criteriaText: string
useAiEligibility: boolean
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: string
votingStartAt: string
votingEndAt: string
}
function toDateTimeInputValue(value: Date | null | undefined): string {
if (!value) return ''
const date = new Date(value)
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
function toDateOrUndefined(value: string): Date | undefined {
if (!value) return undefined
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
export function AwardGovernanceEditor({
pipelineId,
tracks,
}: AwardGovernanceEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, AwardDraft>>({})
const awardTracks = useMemo(
() => tracks.filter((track) => !!track.specialAward),
[tracks]
)
const updateAward = trpc.specialAward.update.useMutation({
onError: (error) => toast.error(error.message),
})
const configureGovernance = trpc.award.configureGovernance.useMutation({
onError: (error) => toast.error(error.message),
})
useEffect(() => {
const nextDrafts: Record<string, AwardDraft> = {}
for (const track of awardTracks) {
const award = track.specialAward
if (!award) continue
nextDrafts[track.id] = {
trackId: track.id,
awardId: award.id,
awardName: award.name,
description: award.description ?? '',
criteriaText: award.criteriaText ?? '',
useAiEligibility: award.useAiEligibility,
decisionMode: track.decisionMode ?? 'JURY_VOTE',
scoringMode: award.scoringMode,
maxRankedPicks: award.maxRankedPicks?.toString() ?? '',
votingStartAt: toDateTimeInputValue(award.votingStartAt),
votingEndAt: toDateTimeInputValue(award.votingEndAt),
}
}
setDrafts(nextDrafts)
}, [awardTracks])
const isSaving = updateAward.isPending || configureGovernance.isPending
const handleSave = async (trackId: string) => {
const draft = drafts[trackId]
if (!draft) return
const votingStartAt = toDateOrUndefined(draft.votingStartAt)
const votingEndAt = toDateOrUndefined(draft.votingEndAt)
if (votingStartAt && votingEndAt && votingEndAt <= votingStartAt) {
toast.error('Voting end must be after voting start')
return
}
const maxRankedPicks = draft.maxRankedPicks
? parseInt(draft.maxRankedPicks, 10)
: undefined
await updateAward.mutateAsync({
id: draft.awardId,
name: draft.awardName.trim(),
description: draft.description.trim() || undefined,
criteriaText: draft.criteriaText.trim() || undefined,
useAiEligibility: draft.useAiEligibility,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await configureGovernance.mutateAsync({
trackId: draft.trackId,
decisionMode: draft.decisionMode,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Award governance updated')
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Award Governance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{awardTracks.length === 0 && (
<p className="text-sm text-muted-foreground">
No award tracks in this pipeline.
</p>
)}
{awardTracks.map((track) => {
const draft = drafts[track.id]
if (!draft) return null
return (
<div key={track.id} className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">{track.name}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Award Name</Label>
<Input
value={draft.awardName}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, awardName: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Decision Mode</Label>
<Select
value={draft.decisionMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
decisionMode: value as AwardDraft['decisionMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={draft.scoringMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
scoringMode: value as AwardDraft['scoringMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Ranked Picks</Label>
<Input
type="number"
min={1}
max={20}
value={draft.maxRankedPicks}
disabled={draft.scoringMode !== 'RANKED'}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
maxRankedPicks: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.useAiEligibility}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
useAiEligibility: checked,
},
}))
}
/>
<Label className="text-xs">AI Eligibility</Label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Voting Start</Label>
<Input
type="datetime-local"
value={draft.votingStartAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingStartAt: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Voting End</Label>
<Input
type="datetime-local"
value={draft.votingEndAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingEndAt: e.target.value },
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, description: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Eligibility Criteria</Label>
<Textarea
rows={3}
value={draft.criteriaText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, criteriaText: e.target.value },
}))
}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => handleSave(track.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Award Settings
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,379 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Play,
} from 'lucide-react'
type FilteringRulesEditorProps = {
stageId: string
}
type RuleDraft = {
id: string
name: string
ruleType: 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
priority: number
configText: string
}
const DEFAULT_CONFIG_BY_TYPE: Record<
RuleDraft['ruleType'],
Record<string, unknown>
> = {
FIELD_BASED: {
conditions: [
{
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
],
logic: 'AND',
action: 'PASS',
},
DOCUMENT_CHECK: {
requiredFileTypes: ['application/pdf'],
minFileCount: 1,
action: 'REJECT',
},
AI_SCREENING: {
criteriaText:
'Project must clearly demonstrate ocean impact and practical feasibility.',
action: 'FLAG',
},
}
export function FilteringRulesEditor({ stageId }: FilteringRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.filtering.getRules.useQuery({
stageId,
})
const createRule = trpc.filtering.createRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule created')
},
onError: (error) => toast.error(error.message),
})
const updateRule = trpc.filtering.updateRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule updated')
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.filtering.deleteRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.filtering.reorderRules.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
},
onError: (error) => toast.error(error.message),
})
const executeRules = trpc.filtering.executeRules.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering executed: ${data.passed} passed, ${data.filteredOut} filtered, ${data.flagged} flagged`
)
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => a.priority - b.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
ruleType: rule.ruleType,
priority: rule.priority,
configText: JSON.stringify(rule.configJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const priority = orderedRules.length
await createRule.mutateAsync({
stageId,
name: `Rule ${priority + 1}`,
ruleType: 'FIELD_BASED',
priority,
configJson: DEFAULT_CONFIG_BY_TYPE.FIELD_BASED,
})
}
const handleSaveRule = async (ruleId: string) => {
const draft = drafts[ruleId]
if (!draft) return
let parsedConfig: Record<string, unknown>
try {
parsedConfig = JSON.parse(draft.configText) as Record<string, unknown>
} catch {
toast.error('Rule config must be valid JSON')
return
}
await updateRule.mutateAsync({
id: ruleId,
name: draft.name.trim(),
ruleType: draft.ruleType,
priority: draft.priority,
configJson: parsedConfig,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
rules: reordered.map((rule, idx) => ({
id: rule.id,
priority: idx,
})),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Filtering Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Filtering Rules</CardTitle>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => executeRules.mutate({ stageId })}
disabled={executeRules.isPending}
>
{executeRules.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1.5 h-3.5 w-3.5" />
)}
Run
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateRule}
disabled={createRule.isPending}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No filtering rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
name: e.target.value,
},
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Rule Type</Label>
<Select
value={draft.ruleType}
onValueChange={(value) => {
const ruleType = value as RuleDraft['ruleType']
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
ruleType,
configText: JSON.stringify(
DEFAULT_CONFIG_BY_TYPE[ruleType],
null,
2
),
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">Document Check</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Rule Config (JSON)</Label>
<Textarea
className="font-mono text-xs min-h-28"
value={draft.configText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
configText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={updateRule.isPending}
>
{updateRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,196 @@
'use client'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Code } from 'lucide-react'
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category' },
{ value: 'oceanIssue', label: 'Ocean Issue' },
{ value: 'country', label: 'Country' },
{ value: 'geographicZone', label: 'Geographic Zone' },
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
{ value: 'tags', label: 'Tags' },
] as const
const OPERATOR_OPTIONS = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in' },
] as const
type SimplePredicate = {
field: string
operator: string
value: string
}
type PredicateBuilderProps = {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
return (
typeof obj.field === 'string' &&
typeof obj.operator === 'string' &&
(typeof obj.value === 'string' || typeof obj.value === 'boolean')
)
}
function isCompound(obj: Record<string, unknown>): boolean {
return 'or' in obj || 'and' in obj || 'not' in obj
}
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const compound = isCompound(value)
const simple = !compound && isSimplePredicate(value)
useEffect(() => {
if (compound) {
setJsonMode(true)
setJsonText(JSON.stringify(value, null, 2))
}
}, [compound, value])
if (jsonMode) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Label className="text-xs">Predicate (JSON)</Label>
{compound && (
<Badge variant="secondary" className="text-[10px]">
Complex condition
</Badge>
)}
</div>
{!compound && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>
onChange(parsed)
setJsonMode(false)
} catch {
// stay in JSON mode
}
}}
>
Switch to form
</Button>
)}
</div>
<Textarea
className="font-mono text-xs min-h-24"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value)
try {
const parsed = JSON.parse(e.target.value) as Record<string, unknown>
onChange(parsed)
} catch {
// don't update on invalid JSON
}
}}
/>
</div>
)
}
const predicate: SimplePredicate = simple
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
: { field: 'competitionCategory', operator: 'equals', value: '' }
const updateField = (field: string, val: string) => {
const next = { ...predicate, [field]: val }
onChange(next as unknown as Record<string, unknown>)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Condition</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs gap-1"
onClick={() => {
setJsonText(JSON.stringify(value, null, 2))
setJsonMode(true)
}}
>
<Code className="h-3 w-3" />
Edit as JSON
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Field</Label>
<Select
value={predicate.field}
onValueChange={(v) => updateField('field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Operator</Label>
<Select
value={predicate.operator}
onValueChange={(v) => updateField('operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Value</Label>
<Input
className="h-8 text-xs"
value={predicate.value}
onChange={(e) => updateField('value', e.target.value)}
placeholder="e.g. STARTUP"
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,452 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Power,
PowerOff,
} from 'lucide-react'
type StageLite = {
id: string
name: string
sortOrder: number
}
type TrackLite = {
id: string
name: string
stages: StageLite[]
}
type RoutingRulesEditorProps = {
pipelineId: string
tracks: TrackLite[]
}
type RuleDraft = {
id: string
name: string
scope: 'global' | 'track' | 'stage'
sourceTrackId: string | null
destinationTrackId: string
destinationStageId: string | null
priority: number
isActive: boolean
predicateJson: Record<string, unknown>
}
const DEFAULT_PREDICATE = {
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
}
export function RoutingRulesEditor({
pipelineId,
tracks,
}: RoutingRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
pipelineId,
})
const upsertRule = trpc.routing.upsertRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule saved')
},
onError: (error) => toast.error(error.message),
})
const toggleRule = trpc.routing.toggleRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.routing.deleteRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.routing.reorderRules.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => b.priority - a.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
scope: rule.scope as RuleDraft['scope'],
sourceTrackId: rule.sourceTrackId ?? null,
destinationTrackId: rule.destinationTrackId,
destinationStageId: rule.destinationStageId ?? null,
priority: rule.priority,
isActive: rule.isActive,
predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const defaultTrack = tracks[0]
if (!defaultTrack) {
toast.error('Create a track before adding routing rules')
return
}
await upsertRule.mutateAsync({
pipelineId,
name: `Routing Rule ${orderedRules.length + 1}`,
scope: 'global',
sourceTrackId: null,
destinationTrackId: defaultTrack.id,
destinationStageId: defaultTrack.stages[0]?.id ?? null,
priority: orderedRules.length + 1,
isActive: true,
predicateJson: DEFAULT_PREDICATE,
})
}
const handleSaveRule = async (id: string) => {
const draft = drafts[id]
if (!draft) return
await upsertRule.mutateAsync({
id: draft.id,
pipelineId,
name: draft.name.trim(),
scope: draft.scope,
sourceTrackId: draft.sourceTrackId,
destinationTrackId: draft.destinationTrackId,
destinationStageId: draft.destinationStageId,
priority: draft.priority,
isActive: draft.isActive,
predicateJson: draft.predicateJson,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
pipelineId,
orderedIds: reordered.map((rule) => rule.id),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Routing Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading routing rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Routing Rules</CardTitle>
<Button type="button" size="sm" onClick={handleCreateRule}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No routing rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
const destinationTrack = tracks.find(
(track) => track.id === draft.destinationTrackId
)
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Rule Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: { ...draft, name: e.target.value },
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Scope</Label>
<Select
value={draft.scope}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
scope: value as RuleDraft['scope'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="track">Track</SelectItem>
<SelectItem value="stage">Stage</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Source Track</Label>
<Select
value={draft.sourceTrackId ?? '__none__'}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
sourceTrackId: value === '__none__' ? null : value,
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Any Track</SelectItem>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Destination Track</Label>
<Select
value={draft.destinationTrackId}
onValueChange={(value) => {
const track = tracks.find((t) => t.id === value)
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
destinationTrackId: value,
destinationStageId: track?.stages[0]?.id ?? null,
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Destination Stage</Label>
<Select
value={draft.destinationStageId ?? '__none__'}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
destinationStageId: value === '__none__' ? null : value,
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Track Start</SelectItem>
{(destinationTrack?.stages ?? [])
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<PredicateBuilder
value={draft.predicateJson}
onChange={(predicate) =>
setDrafts((prev) => ({
...prev,
[rule.id]: { ...draft, predicateJson: predicate },
}))
}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
toggleRule.mutate({
id: rule.id,
isActive: !draft.isActive,
})
}
disabled={toggleRule.isPending}
>
{draft.isActive ? (
<Power className="mr-1.5 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
)}
{draft.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={upsertRule.isPending}
>
{upsertRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,93 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsSectionProps = {
config: ResultsConfig
onChange: (config: ResultsConfig) => void
isActive?: boolean
}
export function ResultsSection({
config,
onChange,
isActive,
}: ResultsSectionProps) {
const updateConfig = (updates: Partial<ResultsConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Publication Mode</Label>
<InfoTooltip content="Manual publish requires explicit admin action. Auto publish triggers on stage close." />
</div>
<Select
value={config.publicationMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
publicationMode: value as ResultsConfig['publicationMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_on_close">Auto on Close</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Detailed Scores</Label>
<InfoTooltip content="Expose detailed score breakdowns in published results." />
</div>
<p className="text-xs text-muted-foreground">
Controls score transparency in the results experience
</p>
</div>
<Switch
checked={config.showDetailedScores ?? false}
onCheckedChange={(checked) => updateConfig({ showDetailedScores: checked })}
disabled={isActive}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Rankings</Label>
<InfoTooltip content="Display ordered rankings in final results." />
</div>
<p className="text-xs text-muted-foreground">
Disable to show winners only without full ranking table
</p>
</div>
<Switch
checked={config.showRankings ?? true}
onCheckedChange={(checked) => updateConfig({ showRankings: checked })}
disabled={isActive}
/>
</div>
</div>
</div>
)
}

View File

@ -2,11 +2,12 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react' import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight, ShieldCheck } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip' import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation' import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard' import { normalizeStageConfig } from '@/lib/stage-config-schema'
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
type ReviewSectionProps = { type ReviewSectionProps = {
state: WizardState state: WizardState
@ -52,12 +53,34 @@ function ValidationSection({
) )
} }
function stagePolicySummary(stage: WizardStageConfig): string {
const config = normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown>
)
switch (stage.stageType) {
case 'INTAKE':
return `${String(config.lateSubmissionPolicy)} late policy, ${Array.isArray(config.fileRequirements) ? config.fileRequirements.length : 0} file reqs`
case 'FILTER':
return `${Array.isArray(config.rules) ? config.rules.length : 0} rules, AI ${config.aiRubricEnabled ? 'on' : 'off'}`
case 'EVALUATION':
return `${String(config.requiredReviews)} reviews, load ${String(config.minLoadPerJuror)}-${String(config.maxLoadPerJuror)}`
case 'SELECTION':
return `ranking ${String(config.rankingMethod)}, tie ${String(config.tieBreaker)}`
case 'LIVE_FINAL':
return `jury ${config.juryVotingEnabled ? 'on' : 'off'}, audience ${config.audienceVotingEnabled ? 'on' : 'off'}`
case 'RESULTS':
return `publication ${String(config.publicationMode)}, rankings ${config.showRankings ? 'shown' : 'hidden'}`
default:
return 'Configured'
}
}
export function ReviewSection({ state }: ReviewSectionProps) { export function ReviewSection({ state }: ReviewSectionProps) {
const validation = validateAll(state) const validation = validateAll(state)
const totalTracks = state.tracks.length const totalTracks = state.tracks.length
const mainTracks = state.tracks.filter((t) => t.kind === 'MAIN').length
const awardTracks = state.tracks.filter((t) => t.kind === 'AWARD').length
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0) const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
const totalTransitions = state.tracks.reduce( const totalTransitions = state.tracks.reduce(
(sum, t) => sum + Math.max(0, t.stages.length - 1), (sum, t) => sum + Math.max(0, t.stages.length - 1),
@ -65,42 +88,107 @@ export function ReviewSection({ state }: ReviewSectionProps) {
) )
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
const blockers = [
...validation.sections.basics.errors,
...validation.sections.tracks.errors,
...validation.sections.notifications.errors,
]
const warnings = [
...validation.sections.basics.warnings,
...validation.sections.tracks.warnings,
...validation.sections.notifications.warnings,
]
const hasMainTrack = state.tracks.some((track) => track.kind === 'MAIN')
const hasStages = totalStages > 0
const hasNotificationDefaults = enabledNotifications > 0
const publishReady = validation.valid && hasMainTrack && hasStages
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Overall Status */}
<div <div
className={cn( className={cn(
'rounded-lg border p-4', 'rounded-lg border p-4',
validation.valid publishReady
? 'border-emerald-200 bg-emerald-50' ? 'border-emerald-200 bg-emerald-50'
: 'border-destructive/30 bg-destructive/5' : 'border-destructive/30 bg-destructive/5'
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-start gap-2">
{validation.valid ? ( {publishReady ? (
<> <CheckCircle2 className="h-5 w-5 text-emerald-600 mt-0.5" />
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="font-medium text-emerald-800">
Pipeline is ready to be saved
</p>
</>
) : ( ) : (
<> <AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="font-medium text-destructive">
Pipeline has validation errors that must be fixed
</p>
</>
)} )}
<div>
<p className={cn('font-medium', publishReady ? 'text-emerald-800' : 'text-destructive')}>
{publishReady
? 'Pipeline is ready for publish'
: 'Pipeline has publish blockers'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Draft save can proceed with warnings. Publish should only proceed with zero blockers.
</p>
</div>
</div> </div>
</div> </div>
{/* Validation Checks */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Checks</CardTitle> <CardTitle className="text-sm">Readiness Checks</CardTitle>
<InfoTooltip content="Automated checks that verify all required fields are filled and configuration is consistent before saving." /> <InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{blockers.length}</p>
<p className="text-xs text-muted-foreground">Blockers</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{warnings.length}</p>
<p className="text-xs text-muted-foreground">Warnings</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalTracks}</p>
<p className="text-xs text-muted-foreground">Tracks</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalStages}</p>
<p className="text-xs text-muted-foreground">Stages</p>
</div>
</div>
{blockers.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-medium text-destructive mb-1">Publish Blockers</p>
{blockers.map((blocker, i) => (
<p key={i} className="text-xs text-destructive">
{blocker}
</p>
))}
</div>
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-700 mb-1">Warnings</p>
{warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-700">
{warn}
</p>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Detail</CardTitle>
<InfoTooltip content="Automated checks per setup section." />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="divide-y"> <CardContent className="divide-y">
@ -110,15 +198,14 @@ export function ReviewSection({ state }: ReviewSectionProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Structure Summary */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Structure Summary</CardTitle> <CardTitle className="text-sm">Structure and Policy Matrix</CardTitle>
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." /> <InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p> <p className="text-2xl font-bold">{totalTracks}</p>
@ -147,10 +234,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
</div> </div>
</div> </div>
{/* Track breakdown */} <div className="space-y-3">
<div className="mt-4 space-y-2">
{state.tracks.map((track, i) => ( {state.tracks.map((track, i) => (
<div key={i} className="flex items-center justify-between text-sm"> <div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant="secondary" variant="secondary"
@ -165,14 +252,61 @@ export function ReviewSection({ state }: ReviewSectionProps) {
> >
{track.kind} {track.kind}
</Badge> </Badge>
<span>{track.name || '(unnamed)'}</span> <span className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
</div> </div>
<span className="text-muted-foreground text-xs"> <span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
{track.stages.length} stages </div>
<div className="space-y-1">
{track.stages.map((stage, stageIndex) => (
<div
key={stageIndex}
className="flex items-center justify-between text-xs border-b last:border-0 py-1.5"
>
<span className="font-medium">
{stageIndex + 1}. {stage.name || '(unnamed stage)'} ({stage.stageType})
</span> </span>
<span className="text-muted-foreground">{stagePolicySummary(stage)}</span>
</div> </div>
))} ))}
</div> </div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Publish Guardrails
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between rounded-md border p-2">
<span>Main track present</span>
<Badge variant={hasMainTrack ? 'default' : 'destructive'}>
{hasMainTrack ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>At least one stage configured</span>
<Badge variant={hasStages ? 'default' : 'destructive'}>
{hasStages ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Validation blockers cleared</span>
<Badge variant={blockers.length === 0 ? 'default' : 'destructive'}>
{blockers.length === 0 ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Notification policy configured</span>
<Badge variant={hasNotificationDefaults ? 'default' : 'secondary'}>
{hasNotificationDefaults ? 'Configured' : 'Optional'}
</Badge>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -0,0 +1,108 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionSectionProps = {
config: SelectionConfig
onChange: (config: SelectionConfig) => void
isActive?: boolean
}
export function SelectionSection({
config,
onChange,
isActive,
}: SelectionSectionProps) {
const updateConfig = (updates: Partial<SelectionConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Finalist Count</Label>
<InfoTooltip content="Optional fixed finalist target for this stage." />
</div>
<Input
type="number"
min={1}
max={500}
value={config.finalistCount ?? ''}
placeholder="e.g. 6"
disabled={isActive}
onChange={(e) =>
updateConfig({
finalistCount:
e.target.value.trim().length === 0
? undefined
: parseInt(e.target.value, 10) || undefined,
})
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Ranking Method</Label>
<InfoTooltip content="How projects are ranked before finalist selection." />
</div>
<Select
value={config.rankingMethod ?? 'score_average'}
onValueChange={(value) =>
updateConfig({
rankingMethod: value as SelectionConfig['rankingMethod'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="score_average">Score Average</SelectItem>
<SelectItem value="weighted_criteria">Weighted Criteria</SelectItem>
<SelectItem value="binary_pass">Binary Pass</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Tie Breaker</Label>
<InfoTooltip content="Fallback policy used when projects tie in rank." />
</div>
<Select
value={config.tieBreaker ?? 'admin_decides'}
onValueChange={(value) =>
updateConfig({
tieBreaker: value as SelectionConfig['tieBreaker'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Re-vote</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@ -1,8 +1,10 @@
'use client' 'use client'
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react'
import { EditableCard } from '@/components/ui/editable-card' import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { import {
Inbox, Inbox,
@ -17,12 +19,16 @@ import { IntakeSection } from '@/components/admin/pipeline/sections/intake-secti
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section' import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section' import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section' import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
import { import {
defaultIntakeConfig, defaultIntakeConfig,
defaultFilterConfig, defaultFilterConfig,
defaultEvaluationConfig, defaultEvaluationConfig,
defaultLiveConfig, defaultLiveConfig,
defaultSelectionConfig,
defaultResultsConfig,
} from '@/lib/pipeline-defaults' } from '@/lib/pipeline-defaults'
import type { import type {
@ -30,6 +36,8 @@ import type {
FilterConfig, FilterConfig,
EvaluationConfig, EvaluationConfig,
LiveFinalConfig, LiveFinalConfig,
SelectionConfig,
ResultsConfig,
} from '@/types/pipeline-wizard' } from '@/types/pipeline-wizard'
type StageConfigEditorProps = { type StageConfigEditorProps = {
@ -39,6 +47,7 @@ type StageConfigEditorProps = {
configJson: Record<string, unknown> | null configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void> onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean isSaving?: boolean
alwaysEditable?: boolean
} }
const stageIcons: Record<string, React.ReactNode> = { const stageIcons: Record<string, React.ReactNode> = {
@ -249,11 +258,16 @@ export function StageConfigEditor({
configJson, configJson,
onSave, onSave,
isSaving = false, isSaving = false,
alwaysEditable = false,
}: StageConfigEditorProps) { }: StageConfigEditorProps) {
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>( const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
() => configJson ?? {} () => configJson ?? {}
) )
useEffect(() => {
setLocalConfig(configJson ?? {})
}, [stageId, configJson])
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
await onSave(stageId, localConfig) await onSave(stageId, localConfig)
}, [stageId, localConfig, onSave]) }, [stageId, localConfig, onSave])
@ -341,18 +355,53 @@ export function StageConfigEditor({
) )
} }
case 'SELECTION': case 'SELECTION':
return (
<SelectionSection
config={{
...defaultSelectionConfig(),
...(localConfig as SelectionConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
case 'RESULTS': case 'RESULTS':
return ( return (
<div className="text-sm text-muted-foreground py-4 text-center"> <ResultsSection
Configuration for {stageType.replace('_', ' ')} stages is managed config={{
through the stage settings. ...defaultResultsConfig(),
</div> ...(localConfig as ResultsConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
) )
default: default:
return null return null
} }
} }
if (alwaysEditable) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
{stageIcons[stageType] && (
<span className="text-muted-foreground">{stageIcons[stageType]}</span>
)}
<h3 className="text-sm font-semibold">{stageName} Configuration</h3>
<Badge variant="outline" className="text-[10px]">
{stageType.replace('_', ' ')}
</Badge>
</div>
{renderEditor()}
<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>
)
}
return ( return (
<EditableCard <EditableCard
title={`${stageName} Configuration`} title={`${stageName} Configuration`}

View File

@ -0,0 +1,178 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
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'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
type StageType = 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS'
type StageDetailSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
stage: {
id: string
name: string
stageType: StageType
configJson: Record<string, unknown> | null
} | null
onSaveConfig: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving: boolean
pipelineId: string
materializeRequirements?: (stageId: string) => void
isMaterializing?: boolean
}
function StagePanelContent({
stageId,
stageType,
configJson,
}: {
stageId: string
stageType: string
configJson: Record<string, unknown> | null
}) {
switch (stageType) {
case 'INTAKE':
return <IntakePanel stageId={stageId} configJson={configJson} />
case 'FILTER':
return <FilterPanel stageId={stageId} configJson={configJson} />
case 'EVALUATION':
return <EvaluationPanel stageId={stageId} configJson={configJson} />
case 'SELECTION':
return <SelectionPanel stageId={stageId} configJson={configJson} />
case 'LIVE_FINAL':
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
case 'RESULTS':
return <ResultsPanel stageId={stageId} configJson={configJson} />
default:
return (
<p className="text-sm text-muted-foreground py-4">
Unknown stage type: {stageType}
</p>
)
}
}
const stageTypeLabels: Record<string, string> = {
INTAKE: 'Intake',
FILTER: 'Filter',
EVALUATION: 'Evaluation',
SELECTION: 'Selection',
LIVE_FINAL: 'Live Final',
RESULTS: 'Results',
}
export function StageDetailSheet({
open,
onOpenChange,
stage,
onSaveConfig,
isSaving,
pipelineId: _pipelineId,
materializeRequirements,
isMaterializing = false,
}: StageDetailSheetProps) {
if (!stage) return null
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:w-[540px] lg:w-[640px] sm:max-w-[640px] overflow-y-auto p-0"
>
<div className="p-6 pb-0">
<SheetHeader>
<div className="flex items-center gap-2">
<SheetTitle className="text-base">{stage.name}</SheetTitle>
<Badge variant="outline" className="text-[10px]">
{stageTypeLabels[stage.stageType] ?? stage.stageType}
</Badge>
</div>
<SheetDescription>
Configure settings and view activity for this stage
</SheetDescription>
</SheetHeader>
</div>
<div className="px-6 pt-4 pb-6">
<Tabs defaultValue="configuration">
<TabsList className="w-full">
<TabsTrigger value="configuration" className="flex-1">
Configuration
</TabsTrigger>
<TabsTrigger value="activity" className="flex-1">
Activity
</TabsTrigger>
</TabsList>
<TabsContent value="configuration" className="space-y-4 mt-4">
<StageConfigEditor
stageId={stage.id}
stageName={stage.name}
stageType={stage.stageType}
configJson={stage.configJson}
onSave={onSaveConfig}
isSaving={isSaving}
alwaysEditable
/>
{stage.stageType === 'INTAKE' && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Intake File Requirements</h3>
{materializeRequirements && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => materializeRequirements(stage.id)}
disabled={isMaterializing}
>
{isMaterializing && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Import Legacy Requirements
</Button>
)}
</div>
<FileRequirementsEditor stageId={stage.id} />
</div>
)}
{stage.stageType === 'FILTER' && (
<FilteringRulesEditor stageId={stage.id} />
)}
</TabsContent>
<TabsContent value="activity" className="mt-4">
<StagePanelContent
stageId={stage.id}
stageType={stage.stageType}
configJson={stage.configJson}
/>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,344 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Save, Trash2, Loader2 } from 'lucide-react'
type StageLite = {
id: string
name: string
sortOrder: number
}
type StageTransitionsEditorProps = {
trackId: string
stages: StageLite[]
}
type TransitionDraft = {
id: string
isDefault: boolean
guardText: string
}
export function StageTransitionsEditor({
trackId,
stages,
}: StageTransitionsEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, TransitionDraft>>({})
const [fromStageId, setFromStageId] = useState<string>('')
const [toStageId, setToStageId] = useState<string>('')
const [newIsDefault, setNewIsDefault] = useState<boolean>(false)
const [newGuardText, setNewGuardText] = useState<string>('{}')
const { data: transitions = [], isLoading } =
trpc.stage.listTransitions.useQuery({ trackId })
const createTransition = trpc.stage.createTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition created')
setNewGuardText('{}')
setNewIsDefault(false)
},
onError: (error) => toast.error(error.message),
})
const updateTransition = trpc.stage.updateTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition updated')
},
onError: (error) => toast.error(error.message),
})
const deleteTransition = trpc.stage.deleteTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition deleted')
},
onError: (error) => toast.error(error.message),
})
const orderedTransitions = useMemo(
() =>
[...transitions].sort((a, b) => {
const aFromOrder =
stages.find((stage) => stage.id === a.fromStageId)?.sortOrder ?? 0
const bFromOrder =
stages.find((stage) => stage.id === b.fromStageId)?.sortOrder ?? 0
if (aFromOrder !== bFromOrder) return aFromOrder - bFromOrder
const aToOrder =
stages.find((stage) => stage.id === a.toStageId)?.sortOrder ?? 0
const bToOrder =
stages.find((stage) => stage.id === b.toStageId)?.sortOrder ?? 0
return aToOrder - bToOrder
}),
[stages, transitions]
)
useEffect(() => {
if (!fromStageId && stages.length > 0) {
setFromStageId(stages[0].id)
}
if (!toStageId && stages.length > 1) {
setToStageId(stages[1].id)
}
}, [fromStageId, toStageId, stages])
useEffect(() => {
const nextDrafts: Record<string, TransitionDraft> = {}
for (const transition of orderedTransitions) {
nextDrafts[transition.id] = {
id: transition.id,
isDefault: transition.isDefault,
guardText: JSON.stringify(transition.guardJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedTransitions])
const handleCreateTransition = async () => {
if (!fromStageId || !toStageId) {
toast.error('Select both from and to stages')
return
}
if (fromStageId === toStageId) {
toast.error('From and to stages must be different')
return
}
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(newGuardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await createTransition.mutateAsync({
fromStageId,
toStageId,
isDefault: newIsDefault,
guardJson,
})
}
const handleSaveTransition = async (id: string) => {
const draft = drafts[id]
if (!draft) return
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(draft.guardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await updateTransition.mutateAsync({
id,
isDefault: draft.isDefault,
guardJson,
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading transitions...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">Create Transition</p>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">From Stage</Label>
<Select value={fromStageId} onValueChange={setFromStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">To Stage</Label>
<Select value={toStageId} onValueChange={setToStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-start pb-2">
<div className="flex items-center gap-2">
<Switch
checked={newIsDefault}
onCheckedChange={setNewIsDefault}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON (optional)</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={newGuardText}
onChange={(e) => setNewGuardText(e.target.value)}
/>
</div>
<Button
type="button"
size="sm"
onClick={handleCreateTransition}
disabled={createTransition.isPending}
>
{createTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" />
)}
Add Transition
</Button>
</div>
{orderedTransitions.length === 0 && (
<p className="text-sm text-muted-foreground">
No transitions configured yet.
</p>
)}
{orderedTransitions.map((transition) => {
const fromName =
stages.find((stage) => stage.id === transition.fromStageId)?.name ??
transition.fromStage?.name ??
'Unknown'
const toName =
stages.find((stage) => stage.id === transition.toStageId)?.name ??
transition.toStage?.name ??
'Unknown'
const draft = drafts[transition.id]
if (!draft) return null
return (
<div key={transition.id} className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
{fromName} {'->'} {toName}
</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.isDefault}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
isDefault: checked,
},
}))
}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={draft.guardText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
guardText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveTransition(transition.id)}
disabled={updateTransition.isPending}
>
{updateTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteTransition.mutate({ id: transition.id })}
disabled={deleteTransition.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

135
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
)
type SheetContentProps = React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> &
VariantProps<typeof sheetVariants>
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -33,7 +33,11 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
// Combine sender name and email into "Name <email>" format // Combine sender name and email into "Name <email>" format
const fromName = db.email_from_name || 'MOPC Portal' const fromName = db.email_from_name || 'MOPC Portal'
const fromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com' const rawFromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
// Strip "Name <...>" wrapper if present — extract just the email address
const fromEmail = rawFromEmail.includes('<')
? rawFromEmail.match(/<([^>]+)>/)?.[1] || rawFromEmail
: rawFromEmail
const from = `${fromName} <${fromEmail}>` const from = `${fromName} <${fromEmail}>`
// Check if config changed since last call // Check if config changed since last call

View File

@ -1,4 +1,5 @@
import type { ValidationResult, WizardState, WizardTrackConfig, WizardStageConfig } from '@/types/pipeline-wizard' import type { ValidationResult, WizardState, WizardTrackConfig, WizardStageConfig } from '@/types/pipeline-wizard'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
function ok(): ValidationResult { function ok(): ValidationResult {
return { valid: true, errors: [], warnings: [] } return { valid: true, errors: [], warnings: [] }
@ -19,10 +20,32 @@ export function validateBasics(state: WizardState): ValidationResult {
export function validateStage(stage: WizardStageConfig): ValidationResult { export function validateStage(stage: WizardStageConfig): ValidationResult {
const errors: string[] = [] const errors: string[] = []
const warnings: string[] = []
if (!stage.name.trim()) errors.push(`Stage name is required`) if (!stage.name.trim()) errors.push(`Stage name is required`)
if (!stage.slug.trim()) errors.push(`Stage slug is required`) if (!stage.slug.trim()) errors.push(`Stage slug is required`)
else if (!/^[a-z0-9-]+$/.test(stage.slug)) errors.push(`Stage slug "${stage.slug}" is invalid`) else if (!/^[a-z0-9-]+$/.test(stage.slug)) errors.push(`Stage slug "${stage.slug}" is invalid`)
return errors.length ? fail(errors) : ok()
try {
parseAndValidateStageConfig(stage.stageType, stage.configJson, {
strictUnknownKeys: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid stage config'
errors.push(`Stage "${stage.name || stage.slug}" config invalid: ${message}`)
}
if (stage.windowOpenAt && stage.windowCloseAt && stage.windowCloseAt <= stage.windowOpenAt) {
errors.push(`Stage "${stage.name || stage.slug}" close window must be after open window`)
}
if (stage.stageType === 'SELECTION') {
const config = stage.configJson as Record<string, unknown>
if (config.finalistCount == null) {
warnings.push(`Selection stage "${stage.name || stage.slug}" has no finalist target`)
}
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
} }
export function validateTrack(track: WizardTrackConfig): ValidationResult { export function validateTrack(track: WizardTrackConfig): ValidationResult {
@ -49,6 +72,20 @@ export function validateTrack(track: WizardTrackConfig): ValidationResult {
warnings.push('Main track should have at least 2 stages') warnings.push('Main track should have at least 2 stages')
} }
if (track.kind === 'MAIN') {
const stageTypes = new Set(track.stages.map((s) => s.stageType))
const requiredStageTypes: Array<WizardStageConfig['stageType']> = [
'INTAKE',
'FILTER',
'EVALUATION',
]
for (const stageType of requiredStageTypes) {
if (!stageTypes.has(stageType)) {
warnings.push(`Main track is missing recommended ${stageType} stage`)
}
}
}
// AWARD tracks need awardConfig // AWARD tracks need awardConfig
if (track.kind === 'AWARD' && !track.awardConfig?.name) { if (track.kind === 'AWARD' && !track.awardConfig?.name) {
errors.push(`Award track "${track.name}" requires an award name`) errors.push(`Award track "${track.name}" requires an award name`)

View File

@ -0,0 +1,457 @@
import { z } from 'zod'
import type { StageType } from '@prisma/client'
const STAGE_TYPES = [
'INTAKE',
'FILTER',
'EVALUATION',
'SELECTION',
'LIVE_FINAL',
'RESULTS',
] as const
type StageTypeKey = (typeof STAGE_TYPES)[number]
type JsonObject = Record<string, unknown>
const fileRequirementSchema = z
.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
maxSizeMB: z.number().int().min(1).max(5000).optional(),
isRequired: z.boolean().default(false),
})
.strict()
const intakeSchema = z
.object({
submissionWindowEnabled: z.boolean().default(true),
lateSubmissionPolicy: z.enum(['reject', 'flag', 'accept']).default('flag'),
lateGraceHours: z.number().int().min(0).max(168).default(24),
fileRequirements: z.array(fileRequirementSchema).default([]),
})
.strict()
const filterRuleSchema = z
.object({
field: z.string().min(1),
operator: z.string().min(1),
value: z.union([z.string(), z.number(), z.boolean()]),
weight: z.number().min(0).max(1).default(1),
})
.strict()
const filterSchema = z
.object({
rules: z.array(filterRuleSchema).default([]),
aiRubricEnabled: z.boolean().default(false),
aiCriteriaText: z.string().default(''),
aiConfidenceThresholds: z
.object({
high: z.number().min(0).max(1).default(0.85),
medium: z.number().min(0).max(1).default(0.6),
low: z.number().min(0).max(1).default(0.4),
})
.strict()
.default({ high: 0.85, medium: 0.6, low: 0.4 }),
manualQueueEnabled: z.boolean().default(true),
})
.strict()
const evaluationSchema = z
.object({
requiredReviews: z.number().int().min(1).max(20).default(3),
maxLoadPerJuror: z.number().int().min(1).max(100).default(20),
minLoadPerJuror: z.number().int().min(0).max(50).default(5),
availabilityWeighting: z.boolean().default(true),
overflowPolicy: z
.enum(['queue', 'expand_pool', 'reduce_reviews'])
.default('queue'),
})
.strict()
.superRefine((value, ctx) => {
if (value.minLoadPerJuror > value.maxLoadPerJuror) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'minLoadPerJuror cannot exceed maxLoadPerJuror',
path: ['minLoadPerJuror'],
})
}
})
const selectionSchema = z
.object({
finalistCount: z.number().int().min(1).max(500).optional(),
rankingMethod: z
.enum(['score_average', 'weighted_criteria', 'binary_pass'])
.default('score_average'),
tieBreaker: z
.enum(['admin_decides', 'highest_individual', 'revote'])
.default('admin_decides'),
})
.strict()
const liveFinalSchema = z
.object({
juryVotingEnabled: z.boolean().default(true),
audienceVotingEnabled: z.boolean().default(false),
audienceVoteWeight: z.number().min(0).max(1).default(0),
cohortSetupMode: z.enum(['auto', 'manual']).default('manual'),
revealPolicy: z
.enum(['immediate', 'delayed', 'ceremony'])
.default('ceremony'),
})
.strict()
const resultsSchema = z
.object({
publicationMode: z.enum(['manual', 'auto_on_close']).default('manual'),
showDetailedScores: z.boolean().default(false),
showRankings: z.boolean().default(true),
})
.strict()
export const stageConfigSchemas: Record<
StageTypeKey,
z.ZodType<Record<string, unknown>>
> = {
INTAKE: intakeSchema,
FILTER: filterSchema,
EVALUATION: evaluationSchema,
SELECTION: selectionSchema,
LIVE_FINAL: liveFinalSchema,
RESULTS: resultsSchema,
}
const CANONICAL_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: [
'submissionWindowEnabled',
'lateSubmissionPolicy',
'lateGraceHours',
'fileRequirements',
],
FILTER: [
'rules',
'aiRubricEnabled',
'aiCriteriaText',
'aiConfidenceThresholds',
'manualQueueEnabled',
],
EVALUATION: [
'requiredReviews',
'maxLoadPerJuror',
'minLoadPerJuror',
'availabilityWeighting',
'overflowPolicy',
],
SELECTION: ['finalistCount', 'rankingMethod', 'tieBreaker'],
LIVE_FINAL: [
'juryVotingEnabled',
'audienceVotingEnabled',
'audienceVoteWeight',
'cohortSetupMode',
'revealPolicy',
],
RESULTS: ['publicationMode', 'showDetailedScores', 'showRankings'],
}
const LEGACY_ALIAS_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: ['lateSubmissionGrace', 'deadline', 'maxSubmissions'],
FILTER: ['deterministic', 'ai', 'confidenceBands'],
EVALUATION: [
'minAssignmentsPerJuror',
'maxAssignmentsPerJuror',
'criteriaVersion',
'assignmentStrategy',
],
SELECTION: ['finalistTarget', 'selectionMethod', 'rankingSource'],
LIVE_FINAL: [
'votingEnabled',
'audienceVoting',
'sessionMode',
'presentationDurationMinutes',
'qaDurationMinutes',
'votingMode',
'maxFavorites',
'requireIdentification',
'votingDurationMinutes',
],
RESULTS: ['publicationPolicy', 'rankingWeights', 'announcementDate'],
}
function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecord(value: unknown): JsonObject {
return isRecord(value) ? value : {}
}
function toStringSafe(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback
}
function toBool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
function toInt(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.trunc(value)
: fallback
}
function toFloat(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function mapLegacyMimeType(type: string | undefined): string[] {
switch ((type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
case 'PPT':
case 'PPTX':
return [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]
default:
return []
}
}
function normalizeIntakeConfig(raw: JsonObject): JsonObject {
const rawRequirements = Array.isArray(raw.fileRequirements)
? raw.fileRequirements
: []
const fileRequirements = rawRequirements
.map((item) => {
const req = asRecord(item)
const acceptedMimeTypes = Array.isArray(req.acceptedMimeTypes)
? req.acceptedMimeTypes.filter((mime) => typeof mime === 'string')
: mapLegacyMimeType(
typeof req.type === 'string' ? req.type : undefined
)
return {
name: toStringSafe(req.name, '').trim(),
description: toStringSafe(req.description, ''),
acceptedMimeTypes,
maxSizeMB:
typeof req.maxSizeMB === 'number' && Number.isFinite(req.maxSizeMB)
? Math.trunc(req.maxSizeMB)
: undefined,
isRequired: toBool(req.isRequired, toBool(req.required, false)),
}
})
.filter((req) => req.name.length > 0)
return {
submissionWindowEnabled: toBool(raw.submissionWindowEnabled, true),
lateSubmissionPolicy: toStringSafe(raw.lateSubmissionPolicy, 'flag'),
lateGraceHours: toInt(
raw.lateGraceHours ?? raw.lateSubmissionGrace,
24
),
fileRequirements,
}
}
function normalizeFilterConfig(raw: JsonObject): JsonObject {
const deterministic = asRecord(raw.deterministic)
const aiLegacy = asRecord(raw.ai)
const confidenceBands = asRecord(raw.confidenceBands)
const highBand = asRecord(confidenceBands.high)
const mediumBand = asRecord(confidenceBands.medium)
const lowBand = asRecord(confidenceBands.low)
const sourceRules = Array.isArray(raw.rules)
? raw.rules
: Array.isArray(deterministic.rules)
? deterministic.rules
: []
const rules = sourceRules
.map((item) => {
const rule = asRecord(item)
const value =
typeof rule.value === 'string' ||
typeof rule.value === 'number' ||
typeof rule.value === 'boolean'
? rule.value
: ''
return {
field: toStringSafe(rule.field, '').trim(),
operator: toStringSafe(rule.operator, 'equals'),
value,
weight: toFloat(rule.weight, 1),
}
})
.filter((rule) => rule.field.length > 0)
return {
rules,
aiRubricEnabled: toBool(raw.aiRubricEnabled, Object.keys(aiLegacy).length > 0),
aiCriteriaText: toStringSafe(
raw.aiCriteriaText ?? aiLegacy.criteriaText,
''
),
aiConfidenceThresholds: {
high: toFloat(
asRecord(raw.aiConfidenceThresholds).high ?? highBand.threshold,
0.85
),
medium: toFloat(
asRecord(raw.aiConfidenceThresholds).medium ?? mediumBand.threshold,
0.6
),
low: toFloat(
asRecord(raw.aiConfidenceThresholds).low ?? lowBand.threshold,
0.4
),
},
manualQueueEnabled: toBool(raw.manualQueueEnabled, true),
}
}
function normalizeEvaluationConfig(raw: JsonObject): JsonObject {
return {
requiredReviews: toInt(raw.requiredReviews, 3),
maxLoadPerJuror: toInt(
raw.maxLoadPerJuror ?? raw.maxAssignmentsPerJuror,
20
),
minLoadPerJuror: toInt(
raw.minLoadPerJuror ?? raw.minAssignmentsPerJuror,
5
),
availabilityWeighting: toBool(raw.availabilityWeighting, true),
overflowPolicy: toStringSafe(raw.overflowPolicy, 'queue'),
}
}
function normalizeSelectionConfig(raw: JsonObject): JsonObject {
const selectionMethod = toStringSafe(raw.selectionMethod, '')
const inferredRankingMethod =
selectionMethod === 'binary_pass'
? 'binary_pass'
: selectionMethod === 'weighted_criteria'
? 'weighted_criteria'
: 'score_average'
return {
finalistCount:
typeof raw.finalistCount === 'number'
? Math.trunc(raw.finalistCount)
: typeof raw.finalistTarget === 'number'
? Math.trunc(raw.finalistTarget)
: undefined,
rankingMethod: toStringSafe(raw.rankingMethod, inferredRankingMethod),
tieBreaker: toStringSafe(raw.tieBreaker, 'admin_decides'),
}
}
function normalizeLiveFinalConfig(raw: JsonObject): JsonObject {
return {
juryVotingEnabled: toBool(raw.juryVotingEnabled ?? raw.votingEnabled, true),
audienceVotingEnabled: toBool(
raw.audienceVotingEnabled ?? raw.audienceVoting,
false
),
audienceVoteWeight: toFloat(raw.audienceVoteWeight, 0),
cohortSetupMode: toStringSafe(raw.cohortSetupMode, 'manual'),
revealPolicy: toStringSafe(raw.revealPolicy, 'ceremony'),
}
}
function normalizeResultsConfig(raw: JsonObject): JsonObject {
const publicationModeRaw = toStringSafe(
raw.publicationMode ?? raw.publicationPolicy,
'manual'
)
const publicationMode =
publicationModeRaw === 'auto_on_close' ? 'auto_on_close' : 'manual'
return {
publicationMode,
showDetailedScores: toBool(raw.showDetailedScores, false),
showRankings: toBool(raw.showRankings, true),
}
}
export function normalizeStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown
): JsonObject {
const raw = asRecord(rawInput)
switch (stageType) {
case 'INTAKE':
return normalizeIntakeConfig(raw)
case 'FILTER':
return normalizeFilterConfig(raw)
case 'EVALUATION':
return normalizeEvaluationConfig(raw)
case 'SELECTION':
return normalizeSelectionConfig(raw)
case 'LIVE_FINAL':
return normalizeLiveFinalConfig(raw)
case 'RESULTS':
return normalizeResultsConfig(raw)
default:
return raw
}
}
function getUnknownRootKeys(
stageType: StageTypeKey,
rawInput: unknown
): string[] {
const raw = asRecord(rawInput)
const allowed = new Set([
...CANONICAL_KEYS[stageType],
...LEGACY_ALIAS_KEYS[stageType],
])
return Object.keys(raw).filter((key) => !allowed.has(key))
}
export type ParseStageConfigResult = {
config: JsonObject
normalized: JsonObject
}
export function parseAndValidateStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown,
options?: { strictUnknownKeys?: boolean }
): ParseStageConfigResult {
const strictUnknownKeys = options?.strictUnknownKeys ?? true
const stageTypeKey = stageType as StageTypeKey
if (!STAGE_TYPES.includes(stageTypeKey)) {
throw new Error(`Unsupported stage type: ${String(stageType)}`)
}
if (strictUnknownKeys) {
const unknownKeys = getUnknownRootKeys(stageTypeKey, rawInput)
if (unknownKeys.length > 0) {
throw new Error(
`Unknown config keys for ${stageTypeKey}: ${unknownKeys.join(', ')}`
)
}
}
const normalized = normalizeStageConfig(stageTypeKey, rawInput)
const config = stageConfigSchemas[stageTypeKey].parse(normalized)
return { config, normalized }
}

View File

@ -34,8 +34,14 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
const config = (stage.configJson ?? {}) as Record<string, unknown> const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3 const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1 const minAssignmentsPerJuror =
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 (config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const jurors = await prisma.user.findMany({ const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
@ -333,7 +339,10 @@ export const assignmentRouter = router({
]) ])
const config = (stage.configJson ?? {}) as Record<string, unknown> const config = (stage.configJson ?? {}) as Record<string, unknown>
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({ const currentCount = await ctx.prisma.assignment.count({
@ -452,7 +461,10 @@ export const assignmentRouter = router({
select: { configJson: true, name: true, windowCloseAt: true }, select: { configJson: true, name: true, windowCloseAt: true },
}) })
const config = (stage.configJson ?? {}) as Record<string, unknown> const config = (stage.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Track running counts to handle multiple assignments to the same juror in one batch // Track running counts to handle multiple assignments to the same juror in one batch
const runningCounts = new Map<string, number>() const runningCounts = new Map<string, number>()
@ -668,8 +680,14 @@ export const assignmentRouter = router({
}) })
const config = (stage.configJson ?? {}) as Record<string, unknown> const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3 const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1 const minAssignmentsPerJuror =
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 (config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const jurors = await ctx.prisma.user.findMany({ const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
@ -914,7 +932,10 @@ export const assignmentRouter = router({
select: { configJson: true }, select: { configJson: true },
}) })
const config = (stageData.configJson ?? {}) as Record<string, unknown> const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>() const runningCounts = new Map<string, number>()
for (const u of users) { for (const u of users) {
@ -1066,7 +1087,10 @@ export const assignmentRouter = router({
select: { configJson: true }, select: { configJson: true },
}) })
const config = (stageData.configJson ?? {}) as Record<string, unknown> const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20 const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>() const runningCounts = new Map<string, number>()
for (const u of users) { for (const u of users) {

View File

@ -826,6 +826,101 @@ export const fileRouter = router({
// FILE REQUIREMENTS // FILE REQUIREMENTS
// ========================================================================= // =========================================================================
/**
* Materialize legacy configJson file requirements into FileRequirement rows.
* No-op if the stage already has DB-backed requirements.
*/
materializeRequirementsFromConfig: adminProcedure
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: {
id: true,
stageType: true,
configJson: true,
},
})
if (stage.stageType !== 'INTAKE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Requirements can only be materialized for INTAKE stages',
})
}
const existingCount = await ctx.prisma.fileRequirement.count({
where: { stageId: input.stageId },
})
if (existingCount > 0) {
return { created: 0, skipped: true, reason: 'already_materialized' as const }
}
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
const configRequirements = Array.isArray(config.fileRequirements)
? (config.fileRequirements as Array<Record<string, unknown>>)
: []
if (configRequirements.length === 0) {
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
}
const mapLegacyMimeType = (type: unknown): string[] => {
switch (String(type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
case 'PPT':
case 'PPTX':
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
default:
return []
}
}
let created = 0
await ctx.prisma.$transaction(async (tx) => {
for (let i = 0; i < configRequirements.length; i++) {
const raw = configRequirements[i]
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
if (!name) continue
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
: mapLegacyMimeType(raw.type)
await tx.fileRequirement.create({
data: {
stageId: input.stageId,
name,
description:
typeof raw.description === 'string' && raw.description.trim().length > 0
? raw.description.trim()
: undefined,
acceptedMimeTypes,
maxSizeMB:
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
? Math.trunc(raw.maxSizeMB)
: undefined,
isRequired:
(raw.isRequired as boolean | undefined) ??
((raw.required as boolean | undefined) ?? false),
sortOrder: i,
},
})
created++
}
})
return { created, skipped: false as const }
}),
/** /**
* List file requirements for a stage (available to any authenticated user) * List file requirements for a stage (available to any authenticated user)
*/ */

View File

@ -272,6 +272,7 @@ export const filteringRouter = router({
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']).optional(),
configJson: z.record(z.unknown()).optional(), configJson: z.record(z.unknown()).optional(),
priority: z.number().int().optional(), priority: z.number().int().optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),

View File

@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
export const pipelineRouter = router({ export const pipelineRouter = router({
/** /**
@ -401,6 +402,26 @@ export const pipelineRouter = router({
// 3. Create stages for this track // 3. Create stages for this track
const createdStages: Array<{ id: string; name: string; sortOrder: number }> = [] const createdStages: Array<{ id: string; name: string; sortOrder: number }> = []
for (const stageInput of trackInput.stages) { for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
const stage = await tx.stage.create({ const stage = await tx.stage.create({
data: { data: {
trackId: track.id, trackId: track.id,
@ -408,7 +429,7 @@ export const pipelineRouter = router({
slug: stageInput.slug, slug: stageInput.slug,
stageType: stageInput.stageType, stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder, sortOrder: stageInput.sortOrder,
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined, configJson: parsedConfig,
}, },
}) })
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder }) createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
@ -508,7 +529,12 @@ export const pipelineRouter = router({
id: true, id: true,
name: true, name: true,
description: true, description: true,
criteriaText: true,
useAiEligibility: true,
scoringMode: true, scoringMode: true,
maxRankedPicks: true,
votingStartAt: true,
votingEndAt: true,
status: true, status: true,
}, },
}, },
@ -714,6 +740,26 @@ export const pipelineRouter = router({
// Create or update stages // Create or update stages
for (const stageInput of trackInput.stages) { for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
if (stageInput.id) { if (stageInput.id) {
await tx.stage.update({ await tx.stage.update({
where: { id: stageInput.id }, where: { id: stageInput.id },
@ -722,7 +768,7 @@ export const pipelineRouter = router({
slug: stageInput.slug, slug: stageInput.slug,
stageType: stageInput.stageType, stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder, sortOrder: stageInput.sortOrder,
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined, configJson: parsedConfig,
}, },
}) })
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId }) allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
@ -734,7 +780,7 @@ export const pipelineRouter = router({
slug: stageInput.slug, slug: stageInput.slug,
stageType: stageInput.stageType, stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder, sortOrder: stageInput.sortOrder,
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined, configJson: parsedConfig,
}, },
}) })
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId }) allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })

View File

@ -13,6 +13,7 @@ import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email' import { sendInvitationEmail } from '@/lib/email'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
// Valid project status transitions // Valid project status transitions
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = { const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
@ -245,6 +246,98 @@ export const projectRouter = router({
return { ids: projects.map((p) => p.id) } return { ids: projects.map((p) => p.id) }
}), }),
/**
* Preview project-team recipients before bulk status update notifications.
* Used by admin UI confirmation dialog to verify notification audience.
*/
previewStatusNotificationRecipients: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(10000),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.query(async ({ ctx, input }) => {
const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
)
if (!statusTriggersNotification) {
return {
status: input.status,
statusTriggersNotification,
totalProjects: 0,
projectsWithRecipients: 0,
totalRecipients: 0,
projects: [] as Array<{
id: string
title: string
recipientCount: number
recipientsPreview: string[]
hasMoreRecipients: boolean
}>,
}
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: {
id: true,
title: true,
teamMembers: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
},
orderBy: { title: 'asc' },
})
const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8
const mappedProjects = projects.map((project) => {
const uniqueEmails = Array.from(
new Set(
project.teamMembers
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
.filter((email) => email.length > 0)
)
)
return {
id: project.id,
title: project.title,
recipientCount: uniqueEmails.length,
recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
}
})
const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)
return {
status: input.status,
statusTriggersNotification,
totalProjects: mappedProjects.length,
projectsWithRecipients,
totalRecipients,
projects: mappedProjects,
}
}),
/** /**
* Get filter options for the project list (distinct values) * Get filter options for the project list (distinct values)
*/ */

View File

@ -255,6 +255,98 @@ export const routingRouter = router({
} }
}), }),
/**
* Delete a routing rule
*/
deleteRule: adminProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, pipelineId: true },
})
await ctx.prisma.$transaction(async (tx) => {
await tx.routingRule.delete({
where: { id: input.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Reorder routing rules by priority (highest first)
*/
reorderRules: adminProcedure
.input(
z.object({
pipelineId: z.string(),
orderedIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const rules = await ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true },
})
const ruleIds = new Set(rules.map((rule) => rule.id))
for (const id of input.orderedIds) {
if (!ruleIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Routing rule ${id} does not belong to this pipeline`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
const maxPriority = input.orderedIds.length
await Promise.all(
input.orderedIds.map((id, index) =>
tx.routingRule.update({
where: { id },
data: {
priority: maxPriority - index,
},
})
)
)
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
action: 'ROUTING_RULES_REORDERED',
ruleCount: input.orderedIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/** /**
* Toggle a routing rule on/off * Toggle a routing rule on/off
*/ */

View File

@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
// Valid stage status transitions // Valid stage status transitions
const VALID_STAGE_TRANSITIONS: Record<string, string[]> = { const VALID_STAGE_TRANSITIONS: Record<string, string[]> = {
@ -67,13 +68,33 @@ export const stageRouter = router({
} }
const { configJson, sortOrder: _so, ...rest } = input const { configJson, sortOrder: _so, ...rest } = input
let parsedConfigJson: Prisma.InputJsonValue | undefined
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
input.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => { const stage = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.stage.create({ const created = await tx.stage.create({
data: { data: {
...rest, ...rest,
sortOrder, sortOrder,
configJson: (configJson as Prisma.InputJsonValue) ?? undefined, configJson: parsedConfigJson,
}, },
}) })
@ -113,6 +134,13 @@ export const stageRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, configJson, ...data } = input const { id, configJson, ...data } = input
const existing = await ctx.prisma.stage.findUniqueOrThrow({
where: { id },
select: {
stageType: true,
},
})
let parsedConfigJson: Prisma.InputJsonValue | undefined
// Validate window dates if both provided // Validate window dates if both provided
if (data.windowOpenAt && data.windowCloseAt) { if (data.windowOpenAt && data.windowCloseAt) {
@ -124,12 +152,31 @@ export const stageRouter = router({
} }
} }
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
existing.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => { const stage = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.stage.update({ const updated = await tx.stage.update({
where: { id }, where: { id },
data: { data: {
...data, ...data,
configJson: (configJson as Prisma.InputJsonValue) ?? undefined, configJson: parsedConfigJson,
}, },
}) })
@ -224,6 +271,262 @@ export const stageRouter = router({
} }
}), }),
/**
* List transitions for a track
*/
listTransitions: protectedProcedure
.input(z.object({ trackId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.stageTransition.findMany({
where: {
fromStage: {
trackId: input.trackId,
},
},
include: {
fromStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
toStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
},
orderBy: [{ fromStage: { sortOrder: 'asc' } }, { toStage: { sortOrder: 'asc' } }],
})
}),
/**
* Create a transition between stages
*/
createTransition: adminProcedure
.input(
z.object({
fromStageId: z.string(),
toStageId: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.fromStageId === input.toStageId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'fromStageId and toStageId must be different',
})
}
const [fromStage, toStage] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.fromStageId },
select: { id: true, name: true, trackId: true },
}),
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.toStageId },
select: { id: true, name: true, trackId: true },
}),
])
if (fromStage.trackId !== toStage.trackId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Transitions can only connect stages within the same track',
})
}
const existing = await ctx.prisma.stageTransition.findUnique({
where: {
fromStageId_toStageId: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
},
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Transition already exists',
})
}
const transition = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: input.fromStageId },
data: { isDefault: false },
})
}
const created = await tx.stageTransition.create({
data: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: input.isDefault ?? false,
guardJson:
input.guardJson === undefined
? undefined
: (input.guardJson as Prisma.InputJsonValue),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'StageTransition',
entityId: created.id,
detailsJson: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: created.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return transition
}),
/**
* Update transition properties
*/
updateTransition: adminProcedure
.input(
z.object({
id: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
toStageId: true,
isDefault: true,
},
})
const updated = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: transition.fromStageId },
data: { isDefault: false },
})
}
const next = await tx.stageTransition.update({
where: { id: input.id },
data: {
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
...(input.guardJson !== undefined
? {
guardJson:
input.guardJson === null
? Prisma.JsonNull
: (input.guardJson as Prisma.InputJsonValue),
}
: {}),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
isDefault: input.isDefault,
guardUpdated: input.guardJson !== undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return next
})
return updated
}),
/**
* Delete a transition
*/
deleteTransition: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
isDefault: true,
},
})
const fromTransitionCount = await ctx.prisma.stageTransition.count({
where: { fromStageId: transition.fromStageId },
})
if (fromTransitionCount <= 1) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cannot delete the last transition from a stage',
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.stageTransition.delete({
where: { id: input.id },
})
if (transition.isDefault) {
const replacement = await tx.stageTransition.findFirst({
where: { fromStageId: transition.fromStageId },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
if (replacement) {
await tx.stageTransition.update({
where: { id: replacement.id },
data: { isDefault: true },
})
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
fromStageId: transition.fromStageId,
wasDefault: transition.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/** /**
* Transition a stage status (state machine) * Transition a stage status (state machine)
*/ */
@ -633,7 +936,10 @@ export const stageRouter = router({
(!stage.windowCloseAt || now <= stage.windowCloseAt) (!stage.windowCloseAt || now <= stage.windowCloseAt)
const config = (stage.configJson as Record<string, unknown>) ?? {} const config = (stage.configJson as Record<string, unknown>) ?? {}
const lateGraceHours = (config.lateSubmissionGrace as number) ?? 0 const lateGraceHours =
(config.lateGraceHours as number) ??
(config.lateSubmissionGrace as number) ??
0
const isLateWindow = const isLateWindow =
!isOpen && !isOpen &&
stage.windowCloseAt && stage.windowCloseAt &&

View File

@ -87,6 +87,7 @@ export const userRouter = router({
updateProfile: protectedProcedure updateProfile: protectedProcedure
.input( .input(
z.object({ z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(), bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(), phoneNumber: z.string().max(20).optional().nullable(),
@ -98,7 +99,34 @@ export const userRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, ...directFields } = input const {
bio,
expertiseTags,
availabilityJson,
preferredWorkload,
digestFrequency,
email,
...directFields
} = input
const normalizedEmail = email?.toLowerCase().trim()
if (normalizedEmail !== undefined) {
const existing = await ctx.prisma.user.findFirst({
where: {
email: normalizedEmail,
NOT: { id: ctx.user.id },
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another account already uses this email address',
})
}
}
// If bio is provided, merge it into metadataJson // If bio is provided, merge it into metadataJson
let metadataJson: Prisma.InputJsonValue | undefined let metadataJson: Prisma.InputJsonValue | undefined
@ -115,6 +143,7 @@ export const userRouter = router({
where: { id: ctx.user.id }, where: { id: ctx.user.id },
data: { data: {
...directFields, ...directFields,
...(normalizedEmail !== undefined && { email: normalizedEmail }),
...(metadataJson !== undefined && { metadataJson }), ...(metadataJson !== undefined && { metadataJson }),
...(expertiseTags !== undefined && { expertiseTags }), ...(expertiseTags !== undefined && { expertiseTags }),
...(digestFrequency !== undefined && { digestFrequency }), ...(digestFrequency !== undefined && { digestFrequency }),
@ -258,6 +287,46 @@ export const userRouter = router({
} }
}), }),
/**
* List all invitable user IDs for current filters (not paginated)
*/
listInvitableIds: adminProcedure
.input(
z.object({
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(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
status: { in: ['NONE', 'INVITED'] },
}
if (input.roles && input.roles.length > 0) {
where.role = { in: input.roles }
} else if (input.role) {
where.role = input.role
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
}
const users = await ctx.prisma.user.findMany({
where,
select: { id: true },
})
return {
userIds: users.map((u) => u.id),
total: users.length,
}
}),
/** /**
* Get a single user (admin only) * Get a single user (admin only)
*/ */
@ -347,6 +416,7 @@ export const userRouter = router({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
email: z.string().email().optional(),
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
@ -358,6 +428,7 @@ export const userRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, ...data } = input const { id, ...data } = input
const normalizedEmail = data.email?.toLowerCase().trim()
// Prevent changing super admin role // Prevent changing super admin role
const targetUser = await ctx.prisma.user.findUniqueOrThrow({ const targetUser = await ctx.prisma.user.findUniqueOrThrow({
@ -393,10 +464,32 @@ export const userRouter = router({
}) })
} }
if (normalizedEmail !== undefined) {
const existing = await ctx.prisma.user.findFirst({
where: {
email: normalizedEmail,
NOT: { id },
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another user already uses this email address',
})
}
}
const updateData = {
...data,
...(normalizedEmail !== undefined && { email: normalizedEmail }),
}
const user = await ctx.prisma.$transaction(async (tx) => { const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({ const updated = await tx.user.update({
where: { id }, where: { id },
data, data: updateData,
}) })
await logAudit({ await logAudit({
@ -405,7 +498,7 @@ export const userRouter = router({
action: 'UPDATE', action: 'UPDATE',
entityType: 'User', entityType: 'User',
entityId: id, entityId: id,
detailsJson: data, detailsJson: updateData,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })

View File

@ -604,8 +604,14 @@ export async function rebalance(
}) })
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {} const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
const minLoad = (stageConfig.minAssignmentsPerJuror as number) ?? 5 const minLoad =
const maxLoad = (stageConfig.maxAssignmentsPerJuror as number) ?? 20 (stageConfig.minLoadPerJuror as number) ??
(stageConfig.minAssignmentsPerJuror as number) ??
5
const maxLoad =
(stageConfig.maxLoadPerJuror as number) ??
(stageConfig.maxAssignmentsPerJuror as number) ??
20
const requiredReviews = (stageConfig.requiredReviews as number) ?? 3 const requiredReviews = (stageConfig.requiredReviews as number) ?? 3
// Load all assignments for this stage // Load all assignments for this stage

1085
tsc-audit.txt Normal file

File diff suppressed because it is too large Load Diff

BIN
tsc-check-final.txt Normal file

Binary file not shown.

BIN
tsc-check-new.txt Normal file

Binary file not shown.

0
tsc-check.txt Normal file
View File

1
tsc-output2.txt Normal file
View File

@ -0,0 +1 @@
src/components/forms/apply-steps/step-team.tsx(36,5): error TS2322: Type 'string' is not assignable to type 'never'.

0
tsc-output3.txt Normal file
View File