Compare commits
No commits in common. "c321d4711e001df8841aa5909b11b887ad72aa24" and "9ee767b6cd407ed9e5d5b241b934cb58288b3a07" have entirely different histories.
c321d4711e
...
9ee767b6cd
|
|
@ -1,12 +0,0 @@
|
||||||
* 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
|
|
||||||
|
|
@ -6,8 +6,8 @@ on:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: code.monaco-opc.com
|
REGISTRY: code.letsbe.solutions
|
||||||
IMAGE_NAME: mopc/mopc-portal
|
IMAGE_NAME: letsbe/mopc-app
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
288
build-check.txt
288
build-check.txt
|
|
@ -1,288 +0,0 @@
|
||||||
▲ 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
|
|
||||||
|
|
||||||
713
prisma/seed.ts
713
prisma/seed.ts
|
|
@ -141,119 +141,68 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🏷️ Creating expertise tags...')
|
console.log('\n🏷️ Creating expertise tags...')
|
||||||
|
|
||||||
const tagGroups = [
|
const expertiseTags = [
|
||||||
{
|
// Pollution & Waste — aligned with MOPC OceanIssue: POLLUTION_REDUCTION
|
||||||
category: '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 },
|
||||||
color: '#dc2626',
|
{ 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 },
|
||||||
tags: [
|
{ name: 'Wastewater & Runoff Treatment', description: 'Filtering agricultural runoff, industrial discharge, and urban wastewater before it reaches the ocean', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 2 },
|
||||||
{ name: 'Marine Plastic & Ghost Gear Cleanup', description: 'Collection and processing of plastic waste, fishing nets, and marine debris from coastal and ocean environments' },
|
{ name: 'Marine Debris Cleanup', description: 'Ocean and coastal cleanup technologies, collection vessels, and waste recovery systems', category: 'Pollution & Waste', color: '#dc2626', sortOrder: 3 },
|
||||||
{ name: 'Industrial & Wastewater Marine Protection', description: 'Systems reducing chemical discharge, nutrient runoff, and wastewater pollution before ocean impact' },
|
{ 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: '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
|
|
||||||
|
|
||||||
const expertiseTags = tagGroups.flatMap((group, groupIndex) =>
|
// Climate & Carbon — aligned with MOPC OceanIssue: CLIMATE_MITIGATION, BLUE_CARBON, OCEAN_ACIDIFICATION
|
||||||
group.tags.map((tag, tagIndex) => ({
|
{ name: 'Blue Carbon Ecosystems', description: 'Conservation and restoration of mangroves, seagrass beds, and salt marshes for carbon sequestration', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 10 },
|
||||||
name: tag.name,
|
{ name: 'Ocean Acidification Mitigation', description: 'Solutions addressing declining ocean pH, alkalinity enhancement, and impacts on calcifying organisms', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 11 },
|
||||||
description: tag.description,
|
{ 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 },
|
||||||
category: group.category,
|
{ name: 'Renewable Ocean Energy', description: 'Wave, tidal, offshore wind, and ocean thermal energy conversion technologies', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 13 },
|
||||||
color: group.color,
|
{ name: 'Carbon Capture & Sequestration', description: 'Marine-based carbon dioxide removal technologies including algae, mineralization, and ocean fertilization', category: 'Climate & Carbon', color: '#0284c7', sortOrder: 14 },
|
||||||
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,
|
||||||
|
|
@ -280,35 +229,18 @@ 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: isSuperAdmin
|
update: { passwordHash },
|
||||||
? {
|
|
||||||
status: UserStatus.ACTIVE,
|
|
||||||
passwordHash,
|
|
||||||
mustSetPassword: false,
|
|
||||||
passwordSetAt: new Date(),
|
|
||||||
onboardingCompletedAt: new Date(),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
status: UserStatus.NONE,
|
|
||||||
passwordHash: null,
|
|
||||||
mustSetPassword: true,
|
|
||||||
passwordSetAt: null,
|
|
||||||
onboardingCompletedAt: null,
|
|
||||||
inviteToken: null,
|
|
||||||
inviteTokenExpiresAt: null,
|
|
||||||
},
|
|
||||||
create: {
|
create: {
|
||||||
email: account.email,
|
email: account.email,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
status: UserStatus.ACTIVE,
|
||||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
passwordHash,
|
||||||
mustSetPassword: !isSuperAdmin,
|
mustSetPassword: false,
|
||||||
passwordSetAt: isSuperAdmin ? new Date() : null,
|
passwordSetAt: new Date(),
|
||||||
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
|
onboardingCompletedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
staffUsers[account.email] = user.id
|
staffUsers[account.email] = user.id
|
||||||
|
|
@ -335,9 +267,7 @@ 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,
|
||||||
|
|
@ -366,9 +296,7 @@ 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,
|
||||||
|
|
@ -394,9 +322,7 @@ 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,
|
||||||
|
|
@ -430,13 +356,8 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🔗 Creating pipeline...')
|
console.log('\n🔗 Creating pipeline...')
|
||||||
|
|
||||||
const pipeline = await prisma.pipeline.upsert({
|
const pipeline = await prisma.pipeline.create({
|
||||||
where: { slug: 'mopc-2026' },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -455,10 +376,8 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🛤️ Creating tracks...')
|
console.log('\n🛤️ Creating tracks...')
|
||||||
|
|
||||||
const mainTrack = await prisma.track.upsert({
|
const mainTrack = await prisma.track.create({
|
||||||
where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'main' } },
|
data: {
|
||||||
update: { name: 'Main Competition' },
|
|
||||||
create: {
|
|
||||||
pipelineId: pipeline.id,
|
pipelineId: pipeline.id,
|
||||||
name: 'Main Competition',
|
name: 'Main Competition',
|
||||||
slug: 'main',
|
slug: 'main',
|
||||||
|
|
@ -468,10 +387,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const innovationTrack = await prisma.track.upsert({
|
const innovationTrack = await prisma.track.create({
|
||||||
where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'innovation-award' } },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -483,10 +400,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const impactTrack = await prisma.track.upsert({
|
const impactTrack = await prisma.track.create({
|
||||||
where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'impact-award' } },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -498,10 +413,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const peoplesTrack = await prisma.track.upsert({
|
const peoplesTrack = await prisma.track.create({
|
||||||
where: { pipelineId_slug: { pipelineId: pipeline.id, slug: 'peoples-choice' } },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -524,10 +437,8 @@ async function main() {
|
||||||
|
|
||||||
// --- Main track stages ---
|
// --- Main track stages ---
|
||||||
const mainStages = await Promise.all([
|
const mainStages = await Promise.all([
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'intake' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.INTAKE,
|
stageType: StageType.INTAKE,
|
||||||
name: 'Application Intake',
|
name: 'Application Intake',
|
||||||
|
|
@ -544,10 +455,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'screening' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.FILTER,
|
stageType: StageType.FILTER,
|
||||||
name: 'AI Screening',
|
name: 'AI Screening',
|
||||||
|
|
@ -570,10 +479,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'evaluation' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.EVALUATION,
|
stageType: StageType.EVALUATION,
|
||||||
name: 'Expert Evaluation',
|
name: 'Expert Evaluation',
|
||||||
|
|
@ -589,10 +496,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'selection' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.SELECTION,
|
stageType: StageType.SELECTION,
|
||||||
name: 'Semi-Final Selection',
|
name: 'Semi-Final Selection',
|
||||||
|
|
@ -606,10 +511,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'grand-final' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.LIVE_FINAL,
|
stageType: StageType.LIVE_FINAL,
|
||||||
name: 'Grand Final',
|
name: 'Grand Final',
|
||||||
|
|
@ -626,10 +529,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: mainTrack.id, slug: 'results' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: mainTrack.id,
|
trackId: mainTrack.id,
|
||||||
stageType: StageType.RESULTS,
|
stageType: StageType.RESULTS,
|
||||||
name: 'Results & Awards',
|
name: 'Results & Awards',
|
||||||
|
|
@ -647,10 +548,8 @@ async function main() {
|
||||||
|
|
||||||
// --- Innovation Award track stages ---
|
// --- Innovation Award track stages ---
|
||||||
const innovationStages = await Promise.all([
|
const innovationStages = await Promise.all([
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: innovationTrack.id, slug: 'innovation-review' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: innovationTrack.id,
|
trackId: innovationTrack.id,
|
||||||
stageType: StageType.EVALUATION,
|
stageType: StageType.EVALUATION,
|
||||||
name: 'Innovation Jury Review',
|
name: 'Innovation Jury Review',
|
||||||
|
|
@ -664,10 +563,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: innovationTrack.id, slug: 'innovation-results' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: innovationTrack.id,
|
trackId: innovationTrack.id,
|
||||||
stageType: StageType.RESULTS,
|
stageType: StageType.RESULTS,
|
||||||
name: 'Innovation Results',
|
name: 'Innovation Results',
|
||||||
|
|
@ -681,10 +578,8 @@ async function main() {
|
||||||
|
|
||||||
// --- Impact Award track stages ---
|
// --- Impact Award track stages ---
|
||||||
const impactStages = await Promise.all([
|
const impactStages = await Promise.all([
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: impactTrack.id, slug: 'impact-review' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: impactTrack.id,
|
trackId: impactTrack.id,
|
||||||
stageType: StageType.EVALUATION,
|
stageType: StageType.EVALUATION,
|
||||||
name: 'Impact Assessment',
|
name: 'Impact Assessment',
|
||||||
|
|
@ -698,10 +593,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: impactTrack.id, slug: 'impact-results' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: impactTrack.id,
|
trackId: impactTrack.id,
|
||||||
stageType: StageType.RESULTS,
|
stageType: StageType.RESULTS,
|
||||||
name: 'Impact Results',
|
name: 'Impact Results',
|
||||||
|
|
@ -715,10 +608,8 @@ 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.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: peoplesTrack.id, slug: 'public-vote' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: peoplesTrack.id,
|
trackId: peoplesTrack.id,
|
||||||
stageType: StageType.LIVE_FINAL,
|
stageType: StageType.LIVE_FINAL,
|
||||||
name: 'Public Voting',
|
name: 'Public Voting',
|
||||||
|
|
@ -733,10 +624,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stage.upsert({
|
prisma.stage.create({
|
||||||
where: { trackId_slug: { trackId: peoplesTrack.id, slug: 'peoples-results' } },
|
data: {
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
trackId: peoplesTrack.id,
|
trackId: peoplesTrack.id,
|
||||||
stageType: StageType.RESULTS,
|
stageType: StageType.RESULTS,
|
||||||
name: "People's Choice Results",
|
name: "People's Choice Results",
|
||||||
|
|
@ -766,15 +655,8 @@ 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.upsert({
|
await prisma.stageTransition.create({
|
||||||
where: {
|
data: {
|
||||||
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,
|
||||||
|
|
@ -788,150 +670,138 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 11. Parse CSV & Create Applicants + Projects
|
// 11. Parse CSV & Create Applicants + Projects
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n📄 Checking for existing projects...')
|
console.log('\n📄 Parsing Candidatures2026.csv...')
|
||||||
|
|
||||||
|
const csvPath = join(__dirname, '..', 'docs', 'Candidatures2026.csv')
|
||||||
|
const csvContent = readFileSync(csvPath, 'utf-8')
|
||||||
|
|
||||||
|
// Remove BOM if present
|
||||||
|
const cleanContent = csvContent.replace(/^\uFEFF/, '')
|
||||||
|
|
||||||
|
const records: Record<string, string>[] = parse(cleanContent, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
trim: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(` Raw CSV rows: ${records.length}`)
|
||||||
|
|
||||||
|
// Filter and deduplicate
|
||||||
|
const seenEmails = new Set<string>()
|
||||||
|
const validRecords: Record<string, string>[] = []
|
||||||
|
|
||||||
|
for (const row of records) {
|
||||||
|
if (!isValidEntry(row)) continue
|
||||||
|
|
||||||
|
const email = (row['E-mail'] || '').trim().toLowerCase()
|
||||||
|
if (seenEmails.has(email)) continue
|
||||||
|
|
||||||
|
seenEmails.add(email)
|
||||||
|
validRecords.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Valid entries after filtering: ${validRecords.length}`)
|
||||||
|
|
||||||
|
// Create applicant users and projects
|
||||||
|
console.log('\n🚀 Creating applicant users and projects...')
|
||||||
|
|
||||||
|
const intakeStage = mainStages[0] // INTAKE - CLOSED
|
||||||
|
const filterStage = mainStages[1] // FILTER - ACTIVE
|
||||||
|
|
||||||
const existingProjectCount = await prisma.project.count({ where: { programId: program.id } })
|
|
||||||
let projectCount = 0
|
let projectCount = 0
|
||||||
|
for (const row of validRecords) {
|
||||||
|
const email = (row['E-mail'] || '').trim().toLowerCase()
|
||||||
|
const name = (row['Full name'] || '').trim()
|
||||||
|
const phone = (row['Téléphone'] || '').trim() || null
|
||||||
|
const country = (row['Country'] || '').trim() || null
|
||||||
|
const zone = (row['Tri par zone'] || '').trim() || null
|
||||||
|
const university = (row['University'] || '').trim() || null
|
||||||
|
const projectName = (row["Project's name"] || '').trim()
|
||||||
|
const teamMembers = (row['Team members'] || '').trim() || null
|
||||||
|
const category = mapCategory(row['Category'])
|
||||||
|
const issue = mapIssue(row['Issue'])
|
||||||
|
const comment = (row['Comment'] || row['Comment '] || '').trim() || null
|
||||||
|
const mentorship = (row['Mentorship'] || '').trim().toLowerCase() === 'true'
|
||||||
|
const referral = (row['How did you hear about MOPC?'] || '').trim() || null
|
||||||
|
const appStatus = (row['Application status'] || '').trim() || null
|
||||||
|
const phase1Url = (row['PHASE 1 - Submission'] || '').trim() || null
|
||||||
|
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
|
||||||
|
const foundedAt = parseFoundedDate(row['Date of creation'])
|
||||||
|
|
||||||
if (existingProjectCount > 0) {
|
// Create or get applicant user
|
||||||
projectCount = existingProjectCount
|
const user = await prisma.user.upsert({
|
||||||
console.log(` ⏭️ ${existingProjectCount} projects already exist, skipping CSV import`)
|
where: { email },
|
||||||
} else {
|
update: {},
|
||||||
console.log(' Parsing Candidatures2026.csv...')
|
create: {
|
||||||
|
email,
|
||||||
const csvPath = join(__dirname, '..', 'docs', 'Candidatures2026.csv')
|
name,
|
||||||
const csvContent = readFileSync(csvPath, 'utf-8')
|
role: UserRole.APPLICANT,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
// Remove BOM if present
|
phoneNumber: phone,
|
||||||
const cleanContent = csvContent.replace(/^\uFEFF/, '')
|
country,
|
||||||
|
metadataJson: university ? { institution: university } : undefined,
|
||||||
const records: Record<string, string>[] = parse(cleanContent, {
|
mustSetPassword: true,
|
||||||
columns: true,
|
},
|
||||||
skip_empty_lines: true,
|
|
||||||
relax_column_count: true,
|
|
||||||
trim: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(` Raw CSV rows: ${records.length}`)
|
// Create project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
title: projectName || `Project by ${name}`,
|
||||||
|
description: comment,
|
||||||
|
competitionCategory: category,
|
||||||
|
oceanIssue: issue,
|
||||||
|
country,
|
||||||
|
geographicZone: zone,
|
||||||
|
institution: university,
|
||||||
|
wantsMentorship: mentorship,
|
||||||
|
foundedAt,
|
||||||
|
phase1SubmissionUrl: phase1Url,
|
||||||
|
phase2SubmissionUrl: phase2Url,
|
||||||
|
referralSource: referral,
|
||||||
|
applicationStatus: appStatus,
|
||||||
|
submissionSource: SubmissionSource.CSV,
|
||||||
|
submittedByUserId: user.id,
|
||||||
|
submittedByEmail: email,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
status: ProjectStatus.SUBMITTED,
|
||||||
|
metadataJson: teamMembers ? { teamMembers } : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Filter and deduplicate
|
// Create ProjectStageState: INTAKE stage = PASSED (intake closed)
|
||||||
const seenEmails = new Set<string>()
|
await prisma.projectStageState.create({
|
||||||
const validRecords: Record<string, string>[] = []
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
trackId: mainTrack.id,
|
||||||
|
stageId: intakeStage.id,
|
||||||
|
state: ProjectStageStateValue.PASSED,
|
||||||
|
enteredAt: new Date('2026-01-15'),
|
||||||
|
exitedAt: new Date('2026-01-31'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
for (const row of records) {
|
// Create ProjectStageState: FILTER stage = PENDING (current active stage)
|
||||||
if (!isValidEntry(row)) continue
|
await prisma.projectStageState.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
trackId: mainTrack.id,
|
||||||
|
stageId: filterStage.id,
|
||||||
|
state: ProjectStageStateValue.PENDING,
|
||||||
|
enteredAt: new Date('2026-02-01'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const email = (row['E-mail'] || '').trim().toLowerCase()
|
projectCount++
|
||||||
if (seenEmails.has(email)) continue
|
if (projectCount % 50 === 0) {
|
||||||
|
console.log(` ... ${projectCount} projects created`)
|
||||||
seenEmails.add(email)
|
|
||||||
validRecords.push(row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` Valid entries after filtering: ${validRecords.length}`)
|
|
||||||
|
|
||||||
// Create applicant users and projects
|
|
||||||
console.log('\n🚀 Creating applicant users and projects...')
|
|
||||||
|
|
||||||
const intakeStage = mainStages[0] // INTAKE - CLOSED
|
|
||||||
const filterStage = mainStages[1] // FILTER - ACTIVE
|
|
||||||
|
|
||||||
for (const row of validRecords) {
|
|
||||||
const email = (row['E-mail'] || '').trim().toLowerCase()
|
|
||||||
const name = (row['Full name'] || '').trim()
|
|
||||||
const phone = (row['Téléphone'] || '').trim() || null
|
|
||||||
const country = (row['Country'] || '').trim() || null
|
|
||||||
const zone = (row['Tri par zone'] || '').trim() || null
|
|
||||||
const university = (row['University'] || '').trim() || null
|
|
||||||
const projectName = (row["Project's name"] || '').trim()
|
|
||||||
const teamMembers = (row['Team members'] || '').trim() || null
|
|
||||||
const category = mapCategory(row['Category'])
|
|
||||||
const issue = mapIssue(row['Issue'])
|
|
||||||
const comment = (row['Comment'] || row['Comment '] || '').trim() || null
|
|
||||||
const mentorship = (row['Mentorship'] || '').trim().toLowerCase() === 'true'
|
|
||||||
const referral = (row['How did you hear about MOPC?'] || '').trim() || null
|
|
||||||
const appStatus = (row['Application status'] || '').trim() || null
|
|
||||||
const phase1Url = (row['PHASE 1 - Submission'] || '').trim() || null
|
|
||||||
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
|
|
||||||
const foundedAt = parseFoundedDate(row['Date of creation'])
|
|
||||||
|
|
||||||
// Create or get applicant user
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { email },
|
|
||||||
update: {
|
|
||||||
status: UserStatus.NONE,
|
|
||||||
mustSetPassword: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
role: UserRole.APPLICANT,
|
|
||||||
status: UserStatus.NONE,
|
|
||||||
phoneNumber: phone,
|
|
||||||
country,
|
|
||||||
metadataJson: university ? { institution: university } : undefined,
|
|
||||||
mustSetPassword: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create project
|
|
||||||
const project = await prisma.project.create({
|
|
||||||
data: {
|
|
||||||
programId: program.id,
|
|
||||||
title: projectName || `Project by ${name}`,
|
|
||||||
description: comment,
|
|
||||||
competitionCategory: category,
|
|
||||||
oceanIssue: issue,
|
|
||||||
country,
|
|
||||||
geographicZone: zone,
|
|
||||||
institution: university,
|
|
||||||
wantsMentorship: mentorship,
|
|
||||||
foundedAt,
|
|
||||||
phase1SubmissionUrl: phase1Url,
|
|
||||||
phase2SubmissionUrl: phase2Url,
|
|
||||||
referralSource: referral,
|
|
||||||
applicationStatus: appStatus,
|
|
||||||
submissionSource: SubmissionSource.CSV,
|
|
||||||
submittedByUserId: user.id,
|
|
||||||
submittedByEmail: email,
|
|
||||||
submittedAt: new Date(),
|
|
||||||
status: ProjectStatus.SUBMITTED,
|
|
||||||
metadataJson: teamMembers ? { teamMembers } : undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create ProjectStageState: INTAKE stage = PASSED (intake closed)
|
|
||||||
await prisma.projectStageState.create({
|
|
||||||
data: {
|
|
||||||
projectId: project.id,
|
|
||||||
trackId: mainTrack.id,
|
|
||||||
stageId: intakeStage.id,
|
|
||||||
state: ProjectStageStateValue.PASSED,
|
|
||||||
enteredAt: new Date('2026-01-15'),
|
|
||||||
exitedAt: new Date('2026-01-31'),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create ProjectStageState: FILTER stage = PENDING (current active stage)
|
|
||||||
await prisma.projectStageState.create({
|
|
||||||
data: {
|
|
||||||
projectId: project.id,
|
|
||||||
trackId: mainTrack.id,
|
|
||||||
stageId: filterStage.id,
|
|
||||||
state: ProjectStageStateValue.PENDING,
|
|
||||||
enteredAt: new Date('2026-02-01'),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
projectCount++
|
|
||||||
if (projectCount % 50 === 0) {
|
|
||||||
console.log(` ... ${projectCount} projects created`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
@ -964,10 +834,8 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🏆 Creating special awards...')
|
console.log('\n🏆 Creating special awards...')
|
||||||
|
|
||||||
await prisma.specialAward.upsert({
|
await prisma.specialAward.create({
|
||||||
where: { trackId: innovationTrack.id },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -979,10 +847,8 @@ async function main() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await prisma.specialAward.upsert({
|
await prisma.specialAward.create({
|
||||||
where: { trackId: impactTrack.id },
|
data: {
|
||||||
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',
|
||||||
|
|
@ -1002,109 +868,44 @@ async function main() {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🔀 Creating routing rules...')
|
console.log('\n🔀 Creating routing rules...')
|
||||||
|
|
||||||
const existingTechRule = await prisma.routingRule.findFirst({
|
await prisma.routingRule.create({
|
||||||
where: { pipelineId: pipeline.id, name: 'Route Tech Innovation to Innovation Award' },
|
data: {
|
||||||
})
|
pipelineId: pipeline.id,
|
||||||
if (!existingTechRule) {
|
name: 'Route Tech Innovation to Innovation Award',
|
||||||
await prisma.routingRule.create({
|
scope: 'global',
|
||||||
data: {
|
destinationTrackId: innovationTrack.id,
|
||||||
pipelineId: pipeline.id,
|
predicateJson: {
|
||||||
name: 'Route Tech Innovation to Innovation Award',
|
field: 'oceanIssue',
|
||||||
scope: 'global',
|
operator: 'eq',
|
||||||
destinationTrackId: innovationTrack.id,
|
value: 'TECHNOLOGY_INNOVATION',
|
||||||
predicateJson: {
|
|
||||||
field: 'oceanIssue',
|
|
||||||
operator: 'eq',
|
|
||||||
value: 'TECHNOLOGY_INNOVATION',
|
|
||||||
},
|
|
||||||
priority: 10,
|
|
||||||
isActive: true,
|
|
||||||
},
|
},
|
||||||
})
|
priority: 10,
|
||||||
}
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const existingImpactRule = await prisma.routingRule.findFirst({
|
await prisma.routingRule.create({
|
||||||
where: { pipelineId: pipeline.id, name: 'Route Community Impact to Impact Award' },
|
data: {
|
||||||
})
|
pipelineId: pipeline.id,
|
||||||
if (!existingImpactRule) {
|
name: 'Route Community Impact to Impact Award',
|
||||||
await prisma.routingRule.create({
|
scope: 'global',
|
||||||
data: {
|
destinationTrackId: impactTrack.id,
|
||||||
pipelineId: pipeline.id,
|
predicateJson: {
|
||||||
name: 'Route Community Impact to Impact Award',
|
or: [
|
||||||
scope: 'global',
|
{ field: 'oceanIssue', operator: 'eq', value: 'COMMUNITY_CAPACITY' },
|
||||||
destinationTrackId: impactTrack.id,
|
{ field: 'oceanIssue', operator: 'eq', value: 'HABITAT_RESTORATION' },
|
||||||
predicateJson: {
|
],
|
||||||
or: [
|
|
||||||
{ field: 'oceanIssue', operator: 'eq', value: 'COMMUNITY_CAPACITY' },
|
|
||||||
{ field: 'oceanIssue', operator: 'eq', value: 'HABITAT_RESTORATION' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
priority: 5,
|
|
||||||
isActive: true,
|
|
||||||
},
|
},
|
||||||
})
|
priority: 5,
|
||||||
}
|
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. Notification Email Settings
|
// 15. Summary
|
||||||
// ==========================================================================
|
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ 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[]>([])
|
||||||
|
|
@ -84,7 +83,6 @@ 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 || [])
|
||||||
|
|
@ -96,7 +94,6 @@ 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',
|
||||||
|
|
@ -215,12 +212,7 @@ 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
|
<Input id="email" value={user.email} disabled />
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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,
|
||||||
|
|
@ -104,7 +103,6 @@ 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({
|
||||||
|
|
@ -174,24 +172,6 @@ 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(() => {
|
||||||
|
|
@ -214,14 +194,6 @@ 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,
|
||||||
|
|
@ -398,39 +370,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -618,10 +557,7 @@ 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
|
<Button type="submit" disabled={isPending}>
|
||||||
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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -392,14 +392,11 @@ 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(
|
||||||
{
|
{
|
||||||
|
|
@ -455,26 +452,6 @@ 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`)
|
||||||
|
|
@ -547,21 +524,15 @@ 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',
|
||||||
|
|
@ -1312,15 +1283,7 @@ export default function ProjectsPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bulk Status Update Confirmation Dialog */}
|
{/* Bulk Status Update Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
|
||||||
open={bulkConfirmOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setBulkConfirmOpen(open)
|
|
||||||
if (!open) {
|
|
||||||
setBulkNotificationsConfirmed(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
|
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
|
||||||
|
|
@ -1339,64 +1302,6 @@ 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>
|
||||||
|
|
@ -1405,12 +1310,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 || (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed)}
|
disabled={bulkUpdateStatus.isPending}
|
||||||
>
|
>
|
||||||
{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}
|
||||||
{bulkStatusTriggersNotifications ? 'Update + Notify' : 'Update'} {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,554 @@
|
||||||
import { redirect } from 'next/navigation'
|
'use client'
|
||||||
|
|
||||||
type AdvancedPipelinePageProps = {
|
import { useState } from 'react'
|
||||||
params: Promise<{ id: string }>
|
import { useParams } from 'next/navigation'
|
||||||
|
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',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdvancedPipelinePage({
|
type SelectedItem =
|
||||||
params,
|
| { type: 'stage'; trackId: string; stageId: string }
|
||||||
}: AdvancedPipelinePageProps) {
|
| { type: 'track'; trackId: string }
|
||||||
const { id } = await params
|
| null
|
||||||
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { 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'
|
||||||
|
|
@ -25,25 +26,24 @@ 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 { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
|
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
|
||||||
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 { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||||
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
|
||||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
|
||||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
|
||||||
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,86 +52,47 @@ const statusColors: Record<string, string> = {
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWizardTrackConfig(
|
function StagePanel({
|
||||||
track: {
|
stageId,
|
||||||
id: string
|
stageType,
|
||||||
name: string
|
configJson,
|
||||||
slug: string
|
}: {
|
||||||
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
stageId: string
|
||||||
sortOrder: number
|
stageType: string
|
||||||
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
|
configJson: Record<string, unknown> | null
|
||||||
decisionMode:
|
}) {
|
||||||
| 'JURY_VOTE'
|
switch (stageType) {
|
||||||
| 'AWARD_MASTER_DECISION'
|
case 'INTAKE':
|
||||||
| 'ADMIN_DECISION'
|
return <IntakePanel stageId={stageId} configJson={configJson} />
|
||||||
| null
|
case 'FILTER':
|
||||||
stages: Array<{
|
return <FilterPanel stageId={stageId} configJson={configJson} />
|
||||||
id: string
|
case 'EVALUATION':
|
||||||
name: string
|
return <EvaluationPanel stageId={stageId} configJson={configJson} />
|
||||||
slug: string
|
case 'SELECTION':
|
||||||
stageType:
|
return <SelectionPanel stageId={stageId} configJson={configJson} />
|
||||||
| 'INTAKE'
|
case 'LIVE_FINAL':
|
||||||
| 'FILTER'
|
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
|
||||||
| 'EVALUATION'
|
case 'RESULTS':
|
||||||
| 'SELECTION'
|
return <ResultsPanel stageId={stageId} configJson={configJson} />
|
||||||
| 'LIVE_FINAL'
|
default:
|
||||||
| 'RESULTS'
|
return (
|
||||||
sortOrder: number
|
<Card>
|
||||||
configJson: unknown
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
}>
|
Unknown stage type: {stageType}
|
||||||
specialAward?: {
|
</CardContent>
|
||||||
name: string
|
</Card>
|
||||||
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 [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
const stagePanelRef = useRef<HTMLDivElement>(null)
|
||||||
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,
|
||||||
|
|
@ -150,107 +111,24 @@ export default function PipelineDetailPage() {
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
// Auto-select first track and stage on load
|
||||||
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 (!pipeline) return
|
if (selectedStageId && stagePanelRef.current) {
|
||||||
|
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
const nextTracks = pipeline.tracks
|
}
|
||||||
.slice()
|
}, [selectedStageId])
|
||||||
.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,
|
|
||||||
}
|
|
||||||
: 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 (
|
||||||
|
|
@ -292,18 +170,20 @@ 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)
|
||||||
setSelectedStageId(null)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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') => {
|
||||||
|
|
@ -313,62 +193,11 @@ 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-8">
|
<div className="space-y-6">
|
||||||
{/* 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">
|
||||||
|
|
@ -443,6 +272,15 @@ 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">
|
||||||
|
|
@ -535,7 +373,7 @@ export default function PipelineDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Switcher (only if multiple tracks) */}
|
{/* Track Switcher (only if multiple tracks) */}
|
||||||
{hasMultipleTracks && (
|
{pipeline.tracks.length > 1 && (
|
||||||
<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)
|
||||||
|
|
@ -569,16 +407,11 @@ 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">
|
||||||
|
|
@ -587,159 +420,42 @@ export default function PipelineDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stage Detail Sheet */}
|
{/* Selected Stage Detail */}
|
||||||
<StageDetailSheet
|
<div ref={stagePanelRef}>
|
||||||
open={sheetOpen}
|
{selectedStage ? (
|
||||||
onOpenChange={setSheetOpen}
|
<div className="space-y-4">
|
||||||
stage={
|
<div className="border-t pt-4">
|
||||||
selectedStage
|
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||||
? {
|
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
|
||||||
id: selectedStage.id,
|
</h2>
|
||||||
name: selectedStage.name,
|
|
||||||
stageType: selectedStage.stageType,
|
|
||||||
configJson: selectedStage.configJson as Record<string, unknown> | null,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onSaveConfig={updateStageConfig}
|
|
||||||
isSaving={isUpdating}
|
|
||||||
pipelineId={pipelineId}
|
|
||||||
materializeRequirements={(stageId) =>
|
|
||||||
materializeRequirementsMutation.mutate({ stageId })
|
|
||||||
}
|
|
||||||
isMaterializing={materializeRequirementsMutation.isPending}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stage Management */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{mainTrackDraft ? (
|
{/* Stage Config Editor */}
|
||||||
<MainTrackSection
|
<StageConfigEditor
|
||||||
stages={mainTrackDraft.stages}
|
stageId={selectedStage.id}
|
||||||
onChange={updateMainTrackStages}
|
stageName={selectedStage.name}
|
||||||
/>
|
stageType={selectedStage.stageType}
|
||||||
) : (
|
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||||
|
onSave={updateStageConfig}
|
||||||
|
isSaving={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage Activity Panel */}
|
||||||
|
<StagePanel
|
||||||
|
stageId={selectedStage.id}
|
||||||
|
stageType={selectedStage.stageType}
|
||||||
|
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No main track configured.
|
Click a stage in the flowchart above to view its configuration and activity
|
||||||
</p>
|
</p>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<AwardsSection
|
)}
|
||||||
tracks={structureTracks}
|
|
||||||
onChange={(tracks) => {
|
|
||||||
setStructureTracks(tracks)
|
|
||||||
setStructureDirty(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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'
|
||||||
|
|
@ -232,12 +233,18 @@ 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="w-full">
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="flex-1">
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ 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')
|
||||||
|
|
@ -86,7 +85,6 @@ 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 || '')
|
||||||
|
|
@ -106,7 +104,6 @@ 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,
|
||||||
|
|
@ -225,16 +222,8 @@ 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
|
<Input id="email" value={user.email} disabled />
|
||||||
id="email"
|
<p className="text-xs text-muted-foreground">Email cannot be changed</p>
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -145,14 +145,6 @@ 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({
|
||||||
|
|
@ -217,20 +209,10 @@ export function MembersContent() {
|
||||||
}
|
}
|
||||||
}, [allSelectableSelected, selectableUsers])
|
}, [allSelectableSelected, selectableUsers])
|
||||||
|
|
||||||
const selectAllMatching = useCallback(async () => {
|
// Clear selection when filters/page change
|
||||||
const result = await invitableIdsQuery.refetch()
|
useEffect(() => {
|
||||||
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' })
|
||||||
|
|
@ -283,36 +265,6 @@ 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>
|
||||||
|
|
@ -576,7 +528,7 @@ export function MembersContent() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={clearSelection}
|
onClick={() => setSelectedIds(new Set())}
|
||||||
disabled={bulkInvite.isPending}
|
disabled={bulkInvite.isPending}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,379 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
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, ShieldCheck } from 'lucide-react'
|
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } 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 { normalizeStageConfig } from '@/lib/stage-config-schema'
|
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
|
||||||
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
|
|
||||||
|
|
||||||
type ReviewSectionProps = {
|
type ReviewSectionProps = {
|
||||||
state: WizardState
|
state: WizardState
|
||||||
|
|
@ -53,34 +52,12 @@ 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),
|
||||||
|
|
@ -88,107 +65,42 @@ 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',
|
||||||
publishReady
|
validation.valid
|
||||||
? '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-start gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{publishReady ? (
|
{validation.valid ? (
|
||||||
<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">Readiness Checks</CardTitle>
|
<CardTitle className="text-sm">Validation Checks</CardTitle>
|
||||||
<InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
|
<InfoTooltip content="Automated checks that verify all required fields are filled and configuration is consistent before saving." />
|
||||||
</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">
|
||||||
|
|
@ -198,14 +110,15 @@ 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 and Policy Matrix</CardTitle>
|
<CardTitle className="text-sm">Structure Summary</CardTitle>
|
||||||
<InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
|
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold">{totalTracks}</p>
|
<p className="text-2xl font-bold">{totalTracks}</p>
|
||||||
|
|
@ -234,81 +147,34 @@ export function ReviewSection({ state }: ReviewSectionProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Track breakdown */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
{state.tracks.map((track, i) => (
|
{state.tracks.map((track, i) => (
|
||||||
<div key={i} className="rounded-md border p-3 space-y-2">
|
<div key={i} className="flex items-center justify-between text-sm">
|
||||||
<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"
|
className={cn(
|
||||||
className={cn(
|
'text-[10px]',
|
||||||
'text-[10px]',
|
track.kind === 'MAIN'
|
||||||
track.kind === 'MAIN'
|
? 'bg-blue-100 text-blue-700'
|
||||||
? 'bg-blue-100 text-blue-700'
|
: track.kind === 'AWARD'
|
||||||
: track.kind === 'AWARD'
|
? 'bg-amber-100 text-amber-700'
|
||||||
? 'bg-amber-100 text-amber-700'
|
: 'bg-gray-100 text-gray-700'
|
||||||
: 'bg-gray-100 text-gray-700'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{track.kind}
|
||||||
{track.kind}
|
</Badge>
|
||||||
</Badge>
|
<span>{track.name || '(unnamed)'}</span>
|
||||||
<span className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
|
|
||||||
</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 className="text-muted-foreground">{stagePolicySummary(stage)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{track.stages.length} stages
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { EditableCard } from '@/components/ui/editable-card'
|
import { EditableCard } from '@/components/ui/editable-card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
|
|
@ -19,16 +17,12 @@ 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 {
|
||||||
|
|
@ -36,8 +30,6 @@ import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
EvaluationConfig,
|
EvaluationConfig,
|
||||||
LiveFinalConfig,
|
LiveFinalConfig,
|
||||||
SelectionConfig,
|
|
||||||
ResultsConfig,
|
|
||||||
} from '@/types/pipeline-wizard'
|
} from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type StageConfigEditorProps = {
|
type StageConfigEditorProps = {
|
||||||
|
|
@ -47,7 +39,6 @@ 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> = {
|
||||||
|
|
@ -258,16 +249,11 @@ 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])
|
||||||
|
|
@ -355,53 +341,18 @@ 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 (
|
||||||
<ResultsSection
|
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||||
config={{
|
Configuration for {stageType.replace('_', ' ')} stages is managed
|
||||||
...defaultResultsConfig(),
|
through the stage settings.
|
||||||
...(localConfig as ResultsConfig),
|
</div>
|
||||||
}}
|
|
||||||
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`}
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
'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,
|
|
||||||
}
|
|
||||||
|
|
@ -33,11 +33,7 @@ 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 rawFromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
|
const fromEmail = 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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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: [] }
|
||||||
|
|
@ -20,32 +19,10 @@ 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 {
|
||||||
|
|
@ -72,20 +49,6 @@ 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`)
|
||||||
|
|
|
||||||
|
|
@ -1,457 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -34,14 +34,8 @@ 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 =
|
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
|
||||||
(config.minLoadPerJuror as number) ??
|
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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' },
|
||||||
|
|
@ -339,10 +333,7 @@ export const assignmentRouter = router({
|
||||||
])
|
])
|
||||||
|
|
||||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
const maxAssignmentsPerJuror =
|
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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({
|
||||||
|
|
@ -461,10 +452,7 @@ 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 =
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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>()
|
||||||
|
|
@ -680,14 +668,8 @@ 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 =
|
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
|
||||||
(config.minLoadPerJuror as number) ??
|
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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' },
|
||||||
|
|
@ -932,10 +914,7 @@ 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 =
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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) {
|
||||||
|
|
@ -1087,10 +1066,7 @@ 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 =
|
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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) {
|
||||||
|
|
|
||||||
|
|
@ -826,101 +826,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,6 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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({
|
||||||
/**
|
/**
|
||||||
|
|
@ -402,26 +401,6 @@ 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,
|
||||||
|
|
@ -429,7 +408,7 @@ export const pipelineRouter = router({
|
||||||
slug: stageInput.slug,
|
slug: stageInput.slug,
|
||||||
stageType: stageInput.stageType,
|
stageType: stageInput.stageType,
|
||||||
sortOrder: stageInput.sortOrder,
|
sortOrder: stageInput.sortOrder,
|
||||||
configJson: parsedConfig,
|
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
|
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
|
||||||
|
|
@ -529,12 +508,7 @@ 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -740,26 +714,6 @@ 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 },
|
||||||
|
|
@ -768,7 +722,7 @@ export const pipelineRouter = router({
|
||||||
slug: stageInput.slug,
|
slug: stageInput.slug,
|
||||||
stageType: stageInput.stageType,
|
stageType: stageInput.stageType,
|
||||||
sortOrder: stageInput.sortOrder,
|
sortOrder: stageInput.sortOrder,
|
||||||
configJson: parsedConfig,
|
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
|
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
|
||||||
|
|
@ -780,7 +734,7 @@ export const pipelineRouter = router({
|
||||||
slug: stageInput.slug,
|
slug: stageInput.slug,
|
||||||
stageType: stageInput.stageType,
|
stageType: stageInput.stageType,
|
||||||
sortOrder: stageInput.sortOrder,
|
sortOrder: stageInput.sortOrder,
|
||||||
configJson: parsedConfig,
|
configJson: (stageInput.configJson as Prisma.InputJsonValue) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
|
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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[]> = {
|
||||||
|
|
@ -246,98 +245,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -255,98 +255,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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[]> = {
|
||||||
|
|
@ -68,33 +67,13 @@ 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: parsedConfigJson,
|
configJson: (configJson as Prisma.InputJsonValue) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -134,13 +113,6 @@ 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) {
|
||||||
|
|
@ -152,31 +124,12 @@ 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: parsedConfigJson,
|
configJson: (configJson as Prisma.InputJsonValue) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -271,262 +224,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|
@ -936,10 +633,7 @@ 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 =
|
const lateGraceHours = (config.lateSubmissionGrace as number) ?? 0
|
||||||
(config.lateGraceHours as number) ??
|
|
||||||
(config.lateSubmissionGrace as number) ??
|
|
||||||
0
|
|
||||||
const isLateWindow =
|
const isLateWindow =
|
||||||
!isOpen &&
|
!isOpen &&
|
||||||
stage.windowCloseAt &&
|
stage.windowCloseAt &&
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ 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(),
|
||||||
|
|
@ -99,34 +98,7 @@ export const userRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const {
|
const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, ...directFields } = input
|
||||||
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
|
||||||
|
|
@ -143,7 +115,6 @@ 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 }),
|
||||||
|
|
@ -287,46 +258,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|
@ -416,7 +347,6 @@ 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(),
|
||||||
|
|
@ -428,7 +358,6 @@ 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({
|
||||||
|
|
@ -464,32 +393,10 @@ 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: updateData,
|
data,
|
||||||
})
|
})
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|
@ -498,7 +405,7 @@ export const userRouter = router({
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: updateData,
|
detailsJson: data,
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -604,14 +604,8 @@ export async function rebalance(
|
||||||
})
|
})
|
||||||
|
|
||||||
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
|
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
|
||||||
const minLoad =
|
const minLoad = (stageConfig.minAssignmentsPerJuror as number) ?? 5
|
||||||
(stageConfig.minLoadPerJuror as number) ??
|
const maxLoad = (stageConfig.maxAssignmentsPerJuror as number) ?? 20
|
||||||
(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
1085
tsc-audit.txt
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
||||||
src/components/forms/apply-steps/step-team.tsx(36,5): error TS2322: Type 'string' is not assignable to type 'never'.
|
|
||||||
Loading…
Reference in New Issue